/** * returnTo validation check. * * Runs the REAL module through Vite's SSR loader, the same way * skill-check.mjs does, so the `@/` alias and the TypeScript compile are the * app's own rather than a reimplementation of them. No test framework is added * for a 130-line module; this follows the convention already in this directory. * * node scripts/authreturnto-check.mjs * * Exits non-zero on failure, so it can gate a build. * * WHAT THIS IS DEFENDING * * safeReturnTo decides whether a URL somebody else supplied may be navigated * to. The cases below are therefore mostly hostile input, and each asserts the * result is null rather than merely "not the attacker's value" — a wrong answer * that is still a navigation is not a pass. */ import { createServer } from 'vite'; const results = []; const record = (name, pass, detail = '') => { results.push({ name, pass, detail }); console.log(`[${pass ? ' ok ' : ' FAIL '}] ${name}${detail ? ` — ${detail}` : ''}`); }; const ORIGIN = 'https://platform.krowforce.com'; // A real authorization URL, with every parameter the flow depends on, built the // way the Go server builds it: path + RawQuery, percent-escaped into ?returnTo=. const AUTHORIZE = '/oauth/authorize?client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22' + '&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback' + '&response_type=code' + '&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' + '&code_challenge_method=S256' + '&resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp' + '&scope=krow.read' + '&state=vT7nQ2xK_Lp9'; const q = (v) => '?returnTo=' + encodeURIComponent(v); const server = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' }); try { globalThis.window = { location: { origin: ORIGIN, search: '' } }; const { safeReturnTo } = await server.ssrLoadModule('/src/lib/authReturnTo.ts'); /* ── 1–2. The OAuth authorize URL, and its query byte for byte ─────────── */ const oauth = safeReturnTo(q(AUTHORIZE)); record('1. /oauth/authorize is accepted', oauth !== null && oauth.path === AUTHORIZE, oauth ? `via=${oauth.via}` : 'returned null'); record('12. and is marked for full browser navigation', oauth?.via === 'browser', `via=${oauth?.via} — React Router has no such route`); for (const [name, literal] of [ ['client_id', 'client_id=989c3ec1-4afa-4d76-93fa-7f45f1d45e22'], ['redirect_uri', 'redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback'], ['response_type', 'response_type=code'], ['code_challenge', 'code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'], ['code_challenge_method', 'code_challenge_method=S256'], ['resource', 'resource=https%3A%2F%2Fplatform.krowforce.com%2Fmcp'], ['scope', 'scope=krow.read'], ['state', 'state=vT7nQ2xK_Lp9'], ]) { record(`2. ${name} preserved exactly`, Boolean(oauth?.path.includes(literal))); } record('2. percent-encoding is not rewritten', Boolean(oauth?.path.includes('%2F')), '%2F must not become /'); record('2. an encoded space survives', safeReturnTo(q('/oauth/authorize?scope=krow.read%20krow.write'))?.path.includes('%20') === true, '%20 must not become +'); /* ── 3, 13. Internal admin routes keep router navigation ───────────────── */ const admin = safeReturnTo(q('/admin/candidates?stage=applied')); record('3. /admin/... is accepted', admin?.path === '/admin/candidates?stage=applied'); record('13. and is marked for router navigation', admin?.via === 'router', `via=${admin?.via} — must not reload the app`); record('3. bare /admin is accepted', safeReturnTo(q('/admin'))?.via === 'router'); /* ── 4–10. Hostile and malformed values are refused ────────────────────── */ const refuse = [ ['4. external URL', 'https://evil.example'], ['4. external URL with our path', 'https://evil.example/oauth/authorize'], ['4. userinfo trick', 'https://platform.krowforce.com@evil.example/'], ['4. another port on our host', 'https://platform.krowforce.com:8443/admin'], ['5. protocol-relative', '//evil.example'], ['5. protocol-relative with path', '//evil.example/steal'], ['5. triple slash', '///evil.example'], ['6. javascript:', 'javascript:alert(document.cookie)'], ['6. javascript: mixed case', 'JaVaScRiPt:alert(1)'], ['6. tab-obfuscated scheme', 'java\tscript:alert(1)'], ['7. data:', 'data:text/html,'], ['8. backslash', '/\\evil.example'], // These two reach the slash/backslash guard specifically: the path is on // the allowlist, so only the guard can refuse them. Without them the guard // is unfalsifiable — removing it leaves every other case still passing, // which a mutation run showed. ['8. backslash in the query of an allowed path', '/admin/candidates?a=\\evil'], ['8. backslash escape smuggled past the allowlist', '/admin/x?next=/\\evil.example'], ['8. dot-slash-slash', '/.//evil.example'], ['9. /admin/login itself', '/admin/login'], ['9. /admin/login with a query', '/admin/login?returnTo=%2Fadmin'], ['9. bare /login', '/login'], ['10. malformed', 'http://[::1'], ['10. file scheme', 'file:///etc/passwd'], ['10. unrelated backend route', '/oauth/token'], ['10. unrelated app route', '/apply'], ['10. the MCP endpoint', '/mcp'], ]; for (const [name, value] of refuse) { const got = safeReturnTo(q(value)); record(`${name} is refused`, got === null, got ? `returned ${JSON.stringify(got)}` : ''); } /* ── 11. Absent or empty falls back safely ─────────────────────────────── */ for (const [name, search] of [ ['no query at all', ''], ['other parameters only', '?foo=bar'], ['empty returnTo', '?returnTo='], ]) { record(`11. ${name} returns null`, safeReturnTo(search) === null); } } finally { await server.close(); } const failed = results.filter((r) => !r.pass).length; console.log(`\n${results.length - failed} passed, ${failed} failed\n`); process.exit(failed === 0 ? 0 : 1);