authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 09:59:55-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
loga321e9b853d7d9eb4d05c508ee3c308042855c6e
treef8507bfc05adad57d7cdad0f17a018c6f67e7d9d
parentc3d894fed29df8d1a864de6a5bd1d0fda57882f5
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

End Snowglobe sessions when signing out of Shale

Keep Shale session revocation, then clear the current Snowglobe session and return to Shale. Require the Shale referrer and verify session-cookie replay is refused. Assisted-by: gpt-6

3 files changed, 51 insertions(+), 9 deletions(-)

dashboard/src/auth.rs+34-8
...@@ -705,21 +705,40 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp...@@ -705,21 +705,40 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
705 )705 )
706 .into_response());706 .into_response());
707 }707 }
708 if path == "/auth/sign-out" || path == "/auth/file/sign-out" {708 let shale_logout = path == "/auth/shale/sign-out";
709 if path == "/auth/sign-out" || path == "/auth/file/sign-out" || shale_logout {
709 if method == Method::GET && path == "/auth/file/sign-out" {710 if method == Method::GET && path == "/auth/file/sign-out" {
710 return Ok(axum::response::Html("<!doctype html><html><meta name=viewport content='width=device-width'><title>Sign out of Files</title><body><form method=post action='/auth/file/sign-out'><button>sign out of Files</button></form></body></html>").into_response());711 return Ok(axum::response::Html("<!doctype html><html><meta name=viewport content='width=device-width'><title>Sign out of Files</title><body><form method=post action='/auth/file/sign-out'><button>sign out of Files</button></form></body></html>").into_response());
711 }712 }
712 if method != Method::POST {713 if method
714 != (if shale_logout {
715 Method::GET
716 } else {
717 Method::POST
718 })
719 {
713 return Err(Error::new(405, "Use the sign-out button."));720 return Err(Error::new(405, "Use the sign-out button."));
714 }721 }
715 let expected = if path.contains("/file/") {722 let expected = if shale_logout {
723 &app.shale.origin
724 } else if path.contains("/file/") {
716 &auth.file725 &auth.file
717 } else {726 } else {
718 &auth.origin727 &auth.origin
719 };728 };
720 if headers.get("origin").and_then(|v| v.to_str().ok())729 let source = if shale_logout {
721 != Some(expected.origin().ascii_serialization().as_str())730 headers
722 {731 .get("referer")
732 .and_then(|v| v.to_str().ok())
733 .and_then(|v| url::Url::parse(v).ok())
734 .map(|v| v.origin().ascii_serialization())
735 } else {
736 headers
737 .get("origin")
738 .and_then(|v| v.to_str().ok())
739 .map(str::to_owned)
740 };
741 if source.as_deref() != Some(expected.origin().ascii_serialization().as_str()) {
723 return Err(Error::new(403, "Open your account to sign out."));742 return Err(Error::new(403, "Open your account to sign out."));
724 }743 }
725 if let Some(token) = cookie(&headers, COOKIE) {744 if let Some(token) = cookie(&headers, COOKIE) {
...@@ -728,12 +747,19 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp...@@ -728,12 +747,19 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp
728 .unwrap()747 .unwrap()
729 .execute("DELETE FROM sessions WHERE hash=?", [mcp::hash(&token)])?;748 .execute("DELETE FROM sessions WHERE hash=?", [mcp::hash(&token)])?;
730 }749 }
731 if path.contains("/file/") {750 if path.contains("/file/") || shale_logout {
732 return Ok((751 return Ok((
733 StatusCode::SEE_OTHER,752 StatusCode::SEE_OTHER,
734 [753 [
735 ("set-cookie", set_cookie(COOKIE, "", 0)),754 ("set-cookie", set_cookie(COOKIE, "", 0)),
736 ("location", "/".into()),755 (
756 "location",
757 if shale_logout {
758 app.shale.origin.to_string()
759 } else {
760 "/".into()
761 },
762 ),
737 ],763 ],
738 )764 )
739 .into_response());765 .into_response());
tools/dashboard-auth-test.py+13-1
...@@ -197,10 +197,22 @@ def main():...@@ -197,10 +197,22 @@ def main():
197 stop(); start()197 stop(); start()
198 request('/api/me', cookies=cookies)198 request('/api/me', cookies=cookies)
199 request('/auth/file/check', cookies=file_cookies, status=204, host=file)199 request('/auth/file/check', cookies=file_cookies, status=204, host=file)
200 shale_session = {}; shale_csrf = request('/auth/status', cookies=shale_session)['csrf']
201 request('/auth/password', 'POST', {**login, 'csrf': shale_csrf}, shale_session)
202 captured = shale_session.copy()
203 request('/auth/shale/sign-out', cookies=shale_session, status=403)
204 request('/auth/shale/sign-out', cookies=shale_session, status=403, extra={'Referer': 'https://evil.example/'})
205 request('/auth/shale/sign-out', cookies=shale_session, status=403, extra={'Referer': 'https://shale.paperclover.net.evil.example/'})
206 request('/auth/shale/sign-out', 'POST', {}, shale_session, 405, {'Referer': 'https://shale.paperclover.net/'})
207 request('/api/me', cookies=shale_session)
208 ended = request('/auth/shale/sign-out', cookies=shale_session, status=303, extra={'Referer': 'https://shale.paperclover.net/'})
209 assert ended['location'] == 'https://shale.paperclover.net/' and shale_session['__Host-snow-session'] == ''
210 request('/api/me', cookies=captured, status=401)
211 request('/api/me', cookies=cookies)
200 request('/api/users/' + actor + '/logout', 'POST', {}, cookies, 204)212 request('/api/users/' + actor + '/logout', 'POST', {}, cookies, 204)
201 request('/api/me', cookies=cookies, status=401)213 request('/api/me', cookies=cookies, status=401)
202 request('/auth/file/check', cookies=file_cookies, status=401, host=file)214 request('/auth/file/check', cookies=file_cookies, status=401, host=file)
203 print(json.dumps({'import': 'passed', 'password': 'passed', 'signed_legacy_passkey': 'passed', 'username_free_passkey': 'passed', 'remember_me': 'passed', 'registration': 'passed', 'csrf_and_header_forgery': 'passed', 'file_handoff_replay_and_binding': 'passed', 'invitation_one_use_and_revocation': 'passed', 'restart_and_logout': 'passed'}))215 print(json.dumps({'import': 'passed', 'password': 'passed', 'signed_legacy_passkey': 'passed', 'username_free_passkey': 'passed', 'remember_me': 'passed', 'registration': 'passed', 'csrf_and_header_forgery': 'passed', 'file_handoff_replay_and_binding': 'passed', 'invitation_one_use_and_revocation': 'passed', 'restart_and_logout': 'passed', 'shale_logout_and_cookie_replay': 'passed'}))
204 finally:216 finally:
205 if server and server.poll() is None: stop()217 if server and server.poll() is None: stop()
206 log.close()218 log.close()
tools/router.py+4
...@@ -196,6 +196,10 @@ def render(token):...@@ -196,6 +196,10 @@ def render(token):
196 lines += shale_attachment_routes(" ".join(sorted(route["upstreams"])),196 lines += shale_attachment_routes(" ".join(sorted(route["upstreams"])),
197 "/srv/staging/" + service)197 "/srv/staging/" + service)
198 if service == "shale":198 if service == "shale":
199 lines += [" handle /-/logout {", f" reverse_proxy {' '.join(sorted(route['upstreams']))} {{",
200 f" header_down Location https://snowglobe.{os.environ['STUDIO_DOMAIN']}/auth/shale/sign-out",
201 " @logged_out status 200 302 303 404", " handle_response @logged_out {",
202 " copy_response 303", " }", " }", " }"]
199 lines += shale_attachment_routes(" ".join(sorted(route["upstreams"])))203 lines += shale_attachment_routes(" ".join(sorted(route["upstreams"])))
200 lines += [" @userscript path_regexp userscript ^/userscripts/discord-pluralkit-predict(/.*)?$",204 lines += [" @userscript path_regexp userscript ^/userscripts/discord-pluralkit-predict(/.*)?$",
201 " redir @userscript /discord-pluralkit-predict{re.userscript.1}?{query} 308"]205 " redir @userscript /discord-pluralkit-predict{re.userscript.1}?{query} 308"]