Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions services/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { sendEmail } from './email.js';
import { store } from './store/routes.js';
import { swarmRoutes } from './swarm.js';
import { dnsRoutes } from './dns.js';
import { safeRedirect } from './redirect.js';

const CP = {
clientId: process.env.COINPAY_CLIENT_ID || '',
Expand Down Expand Up @@ -76,8 +77,15 @@ app.get('/api/auth/coinpay/login', (c) => {
app.get('/api/auth/coinpay/callback', async (c) => {
const code = c.req.query('code');
const state = c.req.query('state');
if (!code || state !== getCookie(c, 'cp_state')) return c.text('invalid oauth state', 400);
const redirect = getCookie(c, 'cp_redirect') || '';
const expectedState = getCookie(c, 'cp_state');
// Require a non-empty state that matches the cookie. Without the `!state`/
// `!expectedState` guards, a missing cookie AND missing state param both read
// as undefined, so `undefined !== undefined` is false and the CSRF check is
// silently bypassed (login CSRF).
if (!code || !state || !expectedState || state !== expectedState) {
return c.text('invalid oauth state', 400);
}
const redirect = safeRedirect(getCookie(c, 'cp_redirect'), APP_URL);

const basic = Buffer.from(`${CP.clientId}:${CP.clientSecret}`).toString('base64');
const tokRes = await fetch(CP.tokenUrl, {
Expand Down
32 changes: 32 additions & 0 deletions services/api/src/redirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { safeRedirect } from './redirect.js';

const APP = 'https://tronbrowser.dev';

describe('safeRedirect', () => {
it('allows site-relative paths (resolved against our origin)', () => {
expect(safeRedirect('/dashboard', APP)).toBe('https://tronbrowser.dev/dashboard');
expect(safeRedirect('/a/b?x=1', APP)).toBe('https://tronbrowser.dev/a/b?x=1');
});

it('allows absolute same-origin URLs', () => {
expect(safeRedirect('https://tronbrowser.dev/x', APP)).toBe('https://tronbrowser.dev/x');
});

it('rejects external hosts (prevents session-token exfiltration)', () => {
expect(safeRedirect('https://evil.com', APP)).toBeUndefined();
expect(safeRedirect('https://tronbrowser.dev.evil.com/x', APP)).toBeUndefined();
});

it('rejects protocol-relative, scheme and cross-scheme tricks', () => {
expect(safeRedirect('//evil.com', APP)).toBeUndefined();
expect(safeRedirect('javascript:alert(1)', APP)).toBeUndefined();
expect(safeRedirect('http://tronbrowser.dev/x', APP)).toBeUndefined(); // http != https origin
});

it('rejects empty / nullish input', () => {
expect(safeRedirect('', APP)).toBeUndefined();
expect(safeRedirect(undefined, APP)).toBeUndefined();
expect(safeRedirect(null, APP)).toBeUndefined();
});
});
24 changes: 24 additions & 0 deletions services/api/src/redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Validate a post-login redirect target.
*
* The OAuth/login flow appends the freshly minted session token to the
* redirect URL's fragment (`${redirect}#token=...`), so an unvalidated
* redirect (`?redirect=https://evil.com`) lets an attacker exfiltrate the
* victim's session token. Only honor targets that stay on our own origin —
* an absolute same-origin URL or a site-relative path; everything else
* (external hosts, protocol-relative `//evil.com`, `javascript:` URLs,
* unparseable input) is rejected so the caller falls back to a safe default.
*/
export function safeRedirect(
redirect: string | undefined | null,
appUrl: string,
): string | undefined {
if (!redirect) return undefined;
try {
const target = new URL(redirect, appUrl);
if (target.origin === new URL(appUrl).origin) return target.toString();
} catch {
/* not a parseable URL — reject */
}
return undefined;
}
Loading