Files
krow_talent_app/scripts/authreturnto-check.mjs
Aravind 1ef0c6fc8a
Some checks failed
CI / check (push) Failing after 5m6s
layout change ui fix
2026-09-29 16:59:13 +05:30

135 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,<script>alert(1)</script>'],
['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);