See the best posts from any Bluesky account
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Redirect to referring page after OAuth login

Captures the referer on the login form render (not just the handle
submit) so users return to the page they signed in from. Same-host
check and protocol-relative path rejection guard against open redirect
via a crafted referer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+58 -7
+24 -7
app/controllers/oauth_controller.ts
··· 26 26 const rawHandle = request.input('handle', '').trim() 27 27 const handle = rawHandle.startsWith('@') ? rawHandle.slice(1) : rawHandle 28 28 29 - if (!handle) { 30 - return view.render('pages/oauth/login') 31 - } 32 - 33 - // Store return URL so we can redirect back after login 29 + // Capture the page they came from so we can redirect back after login. 30 + // Runs on both the form render and the handle submit so we catch the 31 + // real origin (the submit's referer is /oauth/login itself). 34 32 const referer = request.header('referer') 35 - if (referer) { 33 + const requestHostname = request.hostname() 34 + if (referer && requestHostname) { 36 35 try { 37 36 const url = new URL(referer) 38 - session.put('oauth_return_url', url.pathname + url.search) 37 + // Same-host check defends against open redirect via the referer. 38 + // Without it, a referer like `https://attacker.com//evil.com/` parses 39 + // to pathname `//evil.com/`, which browsers treat as protocol-relative 40 + // in a Location header. 41 + if (url.hostname === requestHostname) { 42 + const path = url.pathname + url.search 43 + // Also reject same-host referers whose pathname itself is protocol- 44 + // relative (`//foo`, `/\foo`) — if a user landed on such a URL via 45 + // our 404 handler, clicking sign-in would carry that as the referer. 46 + const isLocalPath = 47 + path.startsWith('/') && !path.startsWith('//') && !path.startsWith('/\\') 48 + if (isLocalPath && !path.startsWith('/oauth/')) { 49 + session.put('oauth_return_url', path) 50 + } 51 + } 39 52 } catch { 40 53 // Invalid referer, ignore 41 54 } 55 + } 56 + 57 + if (!handle) { 58 + return view.render('pages/oauth/login') 42 59 } 43 60 44 61 try {
+34
tests/functional/oauth.spec.ts
··· 79 79 withAt.assertStatus(withoutAt.response.status) 80 80 }) 81 81 82 + test('stores return URL from referer when form is rendered', async ({ client }) => { 83 + const response = await client 84 + .get('/oauth/login') 85 + .header('referer', 'http://localhost:3333/profile/alice.bsky.social/likes') 86 + response.assertStatus(200) 87 + response.assertSession('oauth_return_url', '/profile/alice.bsky.social/likes') 88 + }) 89 + 90 + test('ignores cross-host referer (open redirect guard)', async ({ client }) => { 91 + const response = await client.get('/oauth/login').header('referer', 'https://evil.com/attack') 92 + response.assertStatus(200) 93 + response.assertSessionMissing('oauth_return_url') 94 + }) 95 + 96 + test('ignores same-host referer with protocol-relative path', async ({ client }) => { 97 + const response = await client 98 + .get('/oauth/login') 99 + .header('referer', 'http://localhost:3333//evil.com/attack') 100 + response.assertStatus(200) 101 + response.assertSessionMissing('oauth_return_url') 102 + }) 103 + 104 + test('does not overwrite return URL when referer is /oauth/login', async ({ client }) => { 105 + // The form submit referer is /oauth/login itself — it must not clobber the 106 + // return URL we captured when the user first landed on the login page. 107 + const response = await client 108 + .get('/oauth/login') 109 + .qs({ handle: 'test.bsky.social' }) 110 + .header('referer', 'http://localhost:3333/oauth/login') 111 + .withSession({ oauth_return_url: '/profile/alice.bsky.social/likes' }) 112 + .redirects(0) 113 + response.assertSession('oauth_return_url', '/profile/alice.bsky.social/likes') 114 + }) 115 + 82 116 test('redirects authenticated users away (guest middleware)', async ({ client }) => { 83 117 const account = await Account.create({ 84 118 did: 'did:plc:oauthtest1',