1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
|
import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { safeRedirect, isSafeRedirect } from "../lib/safe-redirect";
describe("isSafeRedirect rejects off-origin targets", () => {
const attacks: Array<[string, string]> = [
["https://evil.example", "absolute URL"],
["http://evil.example/x", "absolute URL, plain http"],
["//evil.example", "protocol-relative — browsers treat it as absolute"],
["//evil.example/path?a=b", "protocol-relative with a path"],
["/\\evil.example", "browsers fold the backslash to a slash"],
["\\\\evil.example", "UNC-style, folds to protocol-relative"],
["javascript:alert(1)", "scheme URL"],
["data:text/html,<script>", "data URL"],
[" /dashboard", "leading whitespace, no leading slash"],
["dashboard", "bare relative path, not rooted"],
["", "empty"],
];
for (const [value, why] of attacks) {
it(`rejects ${JSON.stringify(value)} (${why})`, () => {
expect(isSafeRedirect(value)).toBe(false);
expect(safeRedirect(value, "/dashboard")).toBe("/dashboard");
});
}
it("rejects control characters (header injection)", () => {
expect(isSafeRedirect("/foo\r\nSet-Cookie: a=b")).toBe(false);
expect(isSafeRedirect("/foo\nLocation: https://evil.example")).toBe(false);
expect(isSafeRedirect("/foo\tbar")).toBe(false);
expect(isSafeRedirect("/foo\x00bar")).toBe(false);
});
it("rejects non-strings", () => {
expect(isSafeRedirect(undefined)).toBe(false);
expect(isSafeRedirect(null)).toBe(false);
expect(isSafeRedirect(42)).toBe(false);
expect(isSafeRedirect(["/a"])).toBe(false);
});
});
describe("isSafeRedirect accepts ordinary same-origin paths", () => {
const ok = [
"/",
"/dashboard",
"/onboarding?welcome=1",
"/ccantynz/Gluecron.com/pulls?state=open",
"/settings/tokens#new",
"/a/b/c/d/e",
"/repo/with-dash_and.dot",
"/search?q=hello+world&page=2",
];
for (const value of ok) {
it(`accepts ${JSON.stringify(value)}`, () => {
expect(isSafeRedirect(value)).toBe(true);
expect(safeRedirect(value, "/fallback")).toBe(value);
});
}
it("defaults the fallback to the site root", () => {
expect(safeRedirect("https://evil.example")).toBe("/");
});
});
function sourceWithoutLineComments(...rel: string[]): string {
const text = readFileSync(join(import.meta.dir, "..", ...rel), "utf8");
const stripped = text
.split("\n")
.filter((l) => !l.trim().startsWith("//"))
.join("\n");
expect(stripped.length).toBeGreaterThan(0);
return stripped;
}
describe("redirect sinks are validated", () => {
it("auth.tsx never reads the redirect query without validating it", () => {
const src = sourceWithoutLineComments("routes", "auth.tsx");
const bare = src.match(/(?<!safeRedirect\(\s*)c\.req\.query\("redirect"\)/g);
expect(bare).toBeNull();
expect(src).toContain("safeRedirect(c.req.query(\"redirect\")");
const uses = src.split("safeRedirect(").length - 1;
expect(uses).toBeGreaterThanOrEqual(5);
});
it("personal-chat.tsx validates its form-supplied redirect", () => {
const src = sourceWithoutLineComments("routes", "personal-chat.tsx");
expect(src).toContain("safeRedirect(body.redirect");
expect(src).not.toContain('String(body.redirect || "/chat")');
});
});
|