|
| 1 | +import assert from 'node:assert/strict' |
| 2 | +import fs from 'node:fs' |
| 3 | +import path from 'node:path' |
| 4 | +import test from 'node:test' |
| 5 | + |
| 6 | +import { normalizeInternalReturnPath } from '../src/lib/navigation-policy.ts' |
| 7 | + |
| 8 | +const projectRoot = path.resolve(import.meta.dirname, '..') |
| 9 | +const fallback = '/prompts' |
| 10 | + |
| 11 | +test('internal return paths preserve safe application navigation', () => { |
| 12 | + for (const [candidate, expected] of [ |
| 13 | + ['/prompts', '/prompts'], |
| 14 | + ['/prompts?lang=en', '/prompts?lang=en'], |
| 15 | + ['/folders/42?lang=en#details', '/folders/42?lang=en#details'], |
| 16 | + ['/folders/42/../43', '/folders/43'], |
| 17 | + ['/folders/%E4%B8%AD%E6%96%87', '/folders/%E4%B8%AD%E6%96%87'], |
| 18 | + ['/search?next=https%3A%2F%2Fexample.com%2Fx#results', '/search?next=https%3A%2F%2Fexample.com%2Fx#results'], |
| 19 | + ['/search?q=100%25', '/search?q=100%25'], |
| 20 | + ]) assert.equal(normalizeInternalReturnPath(candidate), expected, candidate) |
| 21 | +}) |
| 22 | + |
| 23 | +test('external, ambiguous, malformed, and control-bearing return paths fail closed', () => { |
| 24 | + const decodedProtocolRelative = new URLSearchParams('return=%2F%2Fevil.example').get('return') |
| 25 | + const decodedBackslashes = new URLSearchParams('return=%5C%5Cevil.example').get('return') |
| 26 | + const decodedJavascript = new URLSearchParams('return=javascript%3Aalert%281%29').get('return') |
| 27 | + |
| 28 | + const invalidValues = [ |
| 29 | + null, |
| 30 | + undefined, |
| 31 | + 42, |
| 32 | + new String('/prompts'), |
| 33 | + '', |
| 34 | + 'prompts', |
| 35 | + ' /prompts', |
| 36 | + '/prompts ', |
| 37 | + 'https://evil.example/path', |
| 38 | + 'http://evil.example/path', |
| 39 | + 'javascript:alert(1)', |
| 40 | + 'data:text/html,unsafe', |
| 41 | + 'mailto:test@example.com', |
| 42 | + decodedProtocolRelative, |
| 43 | + decodedBackslashes, |
| 44 | + decodedJavascript, |
| 45 | + '//evil.example', |
| 46 | + '///evil.example', |
| 47 | + '\\evil.example', |
| 48 | + '/\\evil.example', |
| 49 | + '/safe/%2f%2fevil.example', |
| 50 | + '/safe/%252f%252fevil.example', |
| 51 | + '/safe/%5c%5cevil.example', |
| 52 | + '/safe/%255c%255cevil.example', |
| 53 | + '/safe%0d', |
| 54 | + '/safe%250d', |
| 55 | + '/safe%C2%80', |
| 56 | + '/safe%25C2%2580', |
| 57 | + '/safe%E2%80%A8', |
| 58 | + '/safe%25E2%2580%25A8', |
| 59 | + '/safe\u0000', |
| 60 | + '/safe\u202e', |
| 61 | + '/bad%', |
| 62 | + '/bad%2', |
| 63 | + '/safe/..//evil.example', |
| 64 | + '/%2e%2e//evil.example', |
| 65 | + '/.//evil.example', |
| 66 | + `/${'a'.repeat(2_048)}`, |
| 67 | + ] |
| 68 | + |
| 69 | + for (const candidate of invalidValues) { |
| 70 | + assert.equal(normalizeInternalReturnPath(candidate), fallback, String(candidate)) |
| 71 | + } |
| 72 | +}) |
| 73 | + |
| 74 | +test('the prompt editor normalizes return once before every navigation sink', () => { |
| 75 | + const route = fs.readFileSync( |
| 76 | + path.join(projectRoot, 'src/app/prompts/edit/[id]/page.tsx'), |
| 77 | + 'utf8', |
| 78 | + ) |
| 79 | + |
| 80 | + assert.match(route, /import \{ normalizeInternalReturnPath \} from '@\/lib\/navigation-policy'/) |
| 81 | + assert.match( |
| 82 | + route, |
| 83 | + /setReturnPath\(normalizeInternalReturnPath\(searchParams\.get\('return'\)\)\)/, |
| 84 | + ) |
| 85 | + assert.doesNotMatch(route, /setReturnPath\(searchParams\.get\('return'\)/) |
| 86 | + assert.equal((route.match(/router\.push\(returnPath\)/g) || []).length, 4) |
| 87 | +}) |
0 commit comments