Skip to content

Commit c0b90de

Browse files
author
Henry
committed
security: constrain prompt return navigation
1 parent 5d0c9e5 commit c0b90de

3 files changed

Lines changed: 176 additions & 1 deletion

File tree

src/app/prompts/edit/[id]/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Prompt, PromptEditorSaveData } from '@/types'
88
import { api } from '@/lib/api'
99
import { useAuth } from '@/contexts/AuthContext'
1010
import { Loader2 } from 'lucide-react'
11+
import { normalizeInternalReturnPath } from '@/lib/navigation-policy'
1112
import { toast } from '@/hooks/use-toast'
1213

1314
export default function EditPromptPage() {
@@ -21,7 +22,7 @@ export default function EditPromptPage() {
2122
useEffect(() => {
2223
if (typeof window !== 'undefined') {
2324
const searchParams = new URLSearchParams(window.location.search)
24-
setReturnPath(searchParams.get('return') || '/prompts')
25+
setReturnPath(normalizeInternalReturnPath(searchParams.get('return')))
2526
}
2627
}, [])
2728

src/lib/navigation-policy.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
const DEFAULT_INTERNAL_RETURN_PATH = '/prompts'
2+
const INTERNAL_NAVIGATION_ORIGIN = 'https://noteprompt.invalid'
3+
const MAX_INTERNAL_RETURN_PATH_LENGTH = 2_048
4+
const MAX_DECODE_PASSES = 4
5+
6+
const CONTROL_OR_DIRECTIONAL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F\u2028\u2029\u202A-\u202E\u2066-\u2069]/
7+
const MALFORMED_PERCENT_ESCAPE = /%(?![0-9A-Fa-f]{2})/
8+
const ENCODED_PATH_SEPARATOR = /%(?:25)*(?:2f|5c)/i
9+
const ENCODED_ASCII_CONTROL = /%(?:25)*(?:0[0-9a-f]|1[0-9a-f]|7f)/i
10+
const ENCODED_C1_CONTROL = /%(?:25)*c2%(?:25)*[89][0-9a-f]/i
11+
const ENCODED_UNICODE_CONTROL = /%(?:25)*e2%(?:25)*(?:80%(?:25)*a[89a-e]|81%(?:25)*a[6-9])/i
12+
13+
function hasUnsafeEncodedPath(rawPath: string) {
14+
if (
15+
MALFORMED_PERCENT_ESCAPE.test(rawPath)
16+
|| ENCODED_PATH_SEPARATOR.test(rawPath)
17+
|| ENCODED_ASCII_CONTROL.test(rawPath)
18+
|| ENCODED_C1_CONTROL.test(rawPath)
19+
|| ENCODED_UNICODE_CONTROL.test(rawPath)
20+
) return true
21+
22+
let decodedPath = rawPath
23+
for (let pass = 0; pass < MAX_DECODE_PASSES; pass += 1) {
24+
let nextPath: string
25+
try {
26+
nextPath = decodeURIComponent(decodedPath)
27+
} catch {
28+
return true
29+
}
30+
31+
if (
32+
CONTROL_OR_DIRECTIONAL_CHARACTERS.test(nextPath)
33+
|| nextPath.includes('\\')
34+
|| ENCODED_PATH_SEPARATOR.test(nextPath)
35+
|| ENCODED_ASCII_CONTROL.test(nextPath)
36+
|| ENCODED_C1_CONTROL.test(nextPath)
37+
|| ENCODED_UNICODE_CONTROL.test(nextPath)
38+
) return true
39+
40+
if (nextPath === decodedPath) return false
41+
decodedPath = nextPath
42+
}
43+
44+
// Deep recursive encoding has no valid navigation use in NotePrompt. Failing
45+
// closed avoids a future decoder turning a reviewed path into a separator.
46+
return /%[0-9A-Fa-f]{2}/.test(decodedPath)
47+
}
48+
49+
/**
50+
* Accepts only a normalized, same-origin application path for client-side
51+
* navigation. Invalid or ambiguous values collapse to one fixed safe route.
52+
*/
53+
export function normalizeInternalReturnPath(value: unknown): string {
54+
if (typeof value !== 'string') return DEFAULT_INTERNAL_RETURN_PATH
55+
if (
56+
value.length === 0
57+
|| value.length > MAX_INTERNAL_RETURN_PATH_LENGTH
58+
|| value.trim() !== value
59+
|| value[0] !== '/'
60+
|| value[1] === '/'
61+
|| value.includes('\\')
62+
|| CONTROL_OR_DIRECTIONAL_CHARACTERS.test(value)
63+
|| ENCODED_ASCII_CONTROL.test(value)
64+
|| ENCODED_C1_CONTROL.test(value)
65+
|| ENCODED_UNICODE_CONTROL.test(value)
66+
) return DEFAULT_INTERNAL_RETURN_PATH
67+
68+
const pathEnd = value.search(/[?#]/)
69+
const rawPath = pathEnd === -1 ? value : value.slice(0, pathEnd)
70+
if (hasUnsafeEncodedPath(rawPath)) return DEFAULT_INTERNAL_RETURN_PATH
71+
72+
try {
73+
const parsed = new URL(value, INTERNAL_NAVIGATION_ORIGIN)
74+
const normalizedPath = parsed.pathname
75+
if (
76+
parsed.origin !== INTERNAL_NAVIGATION_ORIGIN
77+
|| normalizedPath[0] !== '/'
78+
|| normalizedPath[1] === '/'
79+
|| normalizedPath.includes('\\')
80+
|| CONTROL_OR_DIRECTIONAL_CHARACTERS.test(normalizedPath)
81+
) return DEFAULT_INTERNAL_RETURN_PATH
82+
83+
return `${normalizedPath}${parsed.search}${parsed.hash}`
84+
} catch {
85+
return DEFAULT_INTERNAL_RETURN_PATH
86+
}
87+
}

tests/navigation-policy.test.mjs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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

Comments
 (0)