This commit is contained in:
134
scripts/authreturnto-check.mjs
Normal file
134
scripts/authreturnto-check.mjs
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 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);
|
||||
@@ -7976,8 +7976,30 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
return html.slice(0, at) + html.slice(i);
|
||||
};
|
||||
|
||||
record('the layout controls are present and separable',
|
||||
now.includes('<div data-ui-controls') && stripControls(now).indexOf('data-ui-controls') === -1);
|
||||
/**
|
||||
* A page at rest draws no layout controls at all.
|
||||
*
|
||||
* This used to assert the opposite — that the controls were PRESENT — back
|
||||
* when they led with a `Customise layout` button that was on screen whether
|
||||
* or not anybody was arranging anything. That button is gone: Owliver is the
|
||||
* only way to propose a layout change now, and what is left is the bar that
|
||||
* lets a person accept or reject one. With nothing proposed and nothing
|
||||
* saved there is nothing to accept, so the component renders nothing.
|
||||
*
|
||||
* `stripControls` is kept and still exercised. The bar does appear once a
|
||||
* change is being previewed, and the comparison below must stay able to lift
|
||||
* it out when it does.
|
||||
*/
|
||||
record('a page at rest draws no layout controls',
|
||||
!now.includes('<div data-ui-controls'));
|
||||
|
||||
record('layout controls stay separable when they are drawn', (() => {
|
||||
const withBar = `<main><p>kept</p><div data-ui-controls="editor"><div><span>Apply</span></div></div><p>also kept</p></main>`;
|
||||
const stripped = stripControls(withBar);
|
||||
return !stripped.includes('data-ui-controls')
|
||||
&& stripped.includes('<p>kept</p>')
|
||||
&& stripped.includes('<p>also kept</p>');
|
||||
})());
|
||||
|
||||
now = stripControls(now);
|
||||
|
||||
@@ -8308,6 +8330,92 @@ console.log('\n── Candidates vs Talent Pool ──');
|
||||
return r.ok && ids.indexOf('timeline') < ids.indexOf('activity-privileged-notice');
|
||||
})());
|
||||
|
||||
/* ── Where "to the top" is allowed to be said ──────────────────────────── */
|
||||
|
||||
/**
|
||||
* The destination phrasings, because the missing ones read as a broken feature.
|
||||
*
|
||||
* Reported from production: "show the pipeline move to top" was answered "I
|
||||
* could not find that on this page." The target resolved perfectly well — the
|
||||
* refusal came from `planMove`, which knew `to the top` and did not know
|
||||
* `to top`, and a move with no destination falls through to `unknown`. The
|
||||
* user cannot tell that apart from the section not existing.
|
||||
*/
|
||||
const movesFirst = (q) => {
|
||||
const m = ask(q);
|
||||
if (m?.kind !== 'plan' || m.op.op !== 'reorder') return false;
|
||||
return m.op.order[0] === 'timeline';
|
||||
};
|
||||
const movesLast = (q) => {
|
||||
const m = ask(q);
|
||||
if (m?.kind !== 'plan' || m.op.op !== 'reorder') return false;
|
||||
return m.op.order[m.op.order.length - 1] === 'timeline';
|
||||
};
|
||||
|
||||
for (const q of [
|
||||
'move timeline to top',
|
||||
'move timeline to the top',
|
||||
'move the timeline to the very top',
|
||||
'move timeline up',
|
||||
'move the timeline first',
|
||||
'put the timeline at the top',
|
||||
'show the timeline move to top',
|
||||
]) {
|
||||
record(`"${q}" → timeline first`, movesFirst(q));
|
||||
}
|
||||
|
||||
for (const q of [
|
||||
'move timeline to bottom',
|
||||
'move timeline to the bottom',
|
||||
'move the timeline to the end',
|
||||
'move timeline down',
|
||||
'move the timeline last',
|
||||
]) {
|
||||
record(`"${q}" → timeline last`, movesLast(q));
|
||||
}
|
||||
|
||||
/**
|
||||
* A destination word inside a section's NAME is not a destination.
|
||||
*
|
||||
* `over` lives inside `coverage`, `end` inside `trends`. Matched as
|
||||
* substrings, "move coverage trends to the bottom" satisfied both the top
|
||||
* reading and the bottom one — and the top is tested first, so it moved the
|
||||
* opposite way from the one asked for. Whole-word matching is what fixed it;
|
||||
* this is the case that proves it.
|
||||
*/
|
||||
const coverageCase = (() => {
|
||||
const placed = opsMod4.applyOperation(tree4, {
|
||||
op: 'add', parent: null,
|
||||
node: {
|
||||
id: 'cov-1', type: 'flow', data: { source: 'candidates.activity' },
|
||||
props: { title: 'Coverage trends' },
|
||||
},
|
||||
}, { registry: reg4 });
|
||||
if (!placed.ok) return { pass: false, detail: 'fixture could not be placed' };
|
||||
|
||||
/* Put it at the TOP first, so "to the bottom" is a real change. Added at
|
||||
the end, it is already there and the answer is a refusal rather than a
|
||||
plan — which would pass this check for the wrong reason. */
|
||||
const atTop = opsMod4.applyOperation(placed.tree, {
|
||||
op: 'reorder',
|
||||
parent: null,
|
||||
order: ['cov-1', ...placed.tree.map((n) => n.id).filter((id) => id !== 'cov-1')],
|
||||
}, { registry: reg4 });
|
||||
if (!atTop.ok) return { pass: false, detail: 'fixture could not be placed first' };
|
||||
|
||||
const m = intentMod.matchUiEdit('move coverage trends to the bottom',
|
||||
{ tree: atTop.tree, registry: reg4 });
|
||||
if (m?.kind !== 'plan' || m.op.op !== 'reorder') {
|
||||
return { pass: false, detail: JSON.stringify(m) };
|
||||
}
|
||||
return {
|
||||
pass: m.op.order[m.op.order.length - 1] === 'cov-1',
|
||||
detail: m.op.order.join(', '),
|
||||
};
|
||||
})();
|
||||
record('a section named "Coverage trends" still moves to the bottom',
|
||||
coverageCase.pass, coverageCase.detail);
|
||||
|
||||
record('"Change the hiring activity to a table." → replace with table', (() => {
|
||||
const m = ask('Change the hiring activity to a table.');
|
||||
return m?.kind === 'plan' && m.op.op === 'replace' && m.op.target === 'flow-1' && m.op.type === 'table';
|
||||
|
||||
Reference in New Issue
Block a user