135 lines
6.1 KiB
JavaScript
135 lines
6.1 KiB
JavaScript
/**
|
||
* 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);
|