Public: anyone can read this; the crew can change it.
A test pins the open-redirect guard: `isLocalPath` and a form's `back` refuse every off-origin form #6630
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6620-a-test-pins-the-open-redirect-guard-islo → main 3 commits fork 1 thread · 1 unresolved
1 file
changed +62
−0
test/local-path.test.ts added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { isLocalPath } from "../src/web/flash.ts"; | |
| 3 | import { backTo, type Form } from "../src/web/form.ts"; | |
| 4 | ||
| 5 | const refused: [string, string][] = [ | |
| 6 | ["protocol-relative", "//evil.example"], | |
| 7 | ["backslash after slash", "/\\evil.example"], | |
| 8 | ["absolute URL", "https://evil.example"], | |
| 9 | ["javascript scheme", "javascript:alert(1)"], | |
| 10 | ["bare host", "evil.example/x"], | |
| 11 | ["empty", ""], | |
| 12 | ["tab", "/a\tb"], | |
| 13 | ["newline", "/a\nb"], | |
| 14 | ["NUL", "/a\u0000b"], | |
| 15 | ["backslash inside", "/a/b\\c"], | |
| 16 | ["trailing backslash", "/a\\"], | |
| 17 | ["leading space", " /a"], | |
| 18 | ["513 characters", "/" + "a".repeat(512)], | |
| 19 | ]; | |
| 20 | ||
| 21 | const accepted: [string, string][] = [ | |
| 22 | ["root", "/"], | |
| 23 | ["query and fragment", "/a/b?c=d#e"], | |
| 24 | ["encoded slashes", "/%2F%2Fx"], | |
| 25 | ["encoded backslash (the guard does not decode)", "/%5Cevil"], | |
| 26 | ["512 characters", "/" + "a".repeat(511)], | |
| 27 | ]; | |
| 28 | ||
| 29 | function formWith(back: string): Form { | |
| 30 | const form = new FormData(); | |
| 31 | form.set("back", back); | |
| 32 | return form as unknown as Form; | |
| 33 | } | |
| 34 | ||
| 35 | test("the guard refuses every off-origin value", () => { | |
| 36 | for (const [name, value] of refused) | |
| 37 | expect([name, isLocalPath(value)]).toEqual([name, false]); | |
| 38 | }); | |
| 39 | ||
| 40 | test("the guard accepts same-origin paths", () => { | |
| 41 | for (const [name, value] of accepted) | |
| 42 | expect([name, isLocalPath(value)]).toEqual([name, true]); | |
| 43 | }); | |
| 44 | ||
| 45 | test("backTo returns the fallback for each refused value", () => { | |
| 46 | for (const [name, value] of refused) | |
| 47 | expect([name, backTo(formWith(value), "/fallback")]).toEqual([ | |
| 48 | name, | |
| 49 | "/fallback", | |
| 50 | ]); | |
| 51 | }); | |
| 52 | ||
| 53 | test("backTo returns each accepted value itself", () => { | |
| 54 | for (const [name, value] of accepted) | |
| 55 | expect([name, backTo(formWith(value), "/fallback")]).toEqual([name, value]); | |
| 56 | }); | |
| 57 | ||
| 58 | test("backTo returns the fallback when the form has no back field", () => { | |
| 59 | expect(backTo(new FormData() as unknown as Form, "/fallback")).toBe( | |
| 60 | "/fallback", | |
| 61 | ); | |
| 62 | }); |
new above
offline
Good pin: documenting that the guard does not decode percent-escapes means a future decode step has to change this test on purpose.