Skip to content

Commit 60a9584

Browse files
committed
test(webapp): fail if a route sets an over-broad image CSP
1 parent 07b83da commit 60a9584

1 file changed

Lines changed: 156 additions & 0 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// `withImgSrc` lets a route's own img-src win, so the document policy only protects
2+
// the other AI surfaces while no route sets a broader one. This scans literal policy
3+
// strings in the webapp sources — it cannot see one assembled at runtime.
4+
import { readFileSync, readdirSync, statSync } from "node:fs";
5+
import { join, relative } from "node:path";
6+
import { fileURLToPath } from "node:url";
7+
import { describe, expect, it } from "vitest";
8+
9+
const WEBAPP_ROOT = fileURLToPath(new URL("..", import.meta.url));
10+
const SCAN_ROOTS = ["app", "server.ts"];
11+
const SKIP_DIRS = new Set(["node_modules", "build", "dist", "coverage", ".turbo", ".cache"]);
12+
const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
13+
const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/;
14+
15+
const CSP_HEADER_OWNER = "app/entry.server.tsx";
16+
17+
/** Sources that resolve inside the document itself, so they carry nothing outward. */
18+
const LOCAL_SOURCES = new Set(["'self'", "'none'", "data:", "blob:", "filesystem:", "mediastream:"]);
19+
20+
/**
21+
* Hosts that serve content any stranger can upload. Necessarily incomplete — the
22+
* shape checks below are what actually holds the line.
23+
*/
24+
const PUBLIC_UPLOAD_HOSTS = [
25+
"raw.githubusercontent.com",
26+
"user-images.githubusercontent.com",
27+
"gist.githubusercontent.com",
28+
"objects.githubusercontent.com",
29+
"camo.githubusercontent.com",
30+
"imgur.com",
31+
"cdn.discordapp.com",
32+
"media.discordapp.net",
33+
"s3.amazonaws.com",
34+
"storage.googleapis.com",
35+
"lh3.googleusercontent.com",
36+
"blob.core.windows.net",
37+
"pages.dev",
38+
"vercel.app",
39+
"netlify.app",
40+
"ngrok.io",
41+
"ngrok-free.app",
42+
"trycloudflare.com",
43+
];
44+
45+
const IMAGE_DIRECTIVES = new Set(["img-src", "default-src"]);
46+
const HAS_IMAGE_DIRECTIVE = /(^|;)\s*(img-src|default-src)\s+\S/i;
47+
const STRING_LITERAL = /"((?:[^"\\\n]|\\.)*)"|'((?:[^'\\\n]|\\.)*)'|`((?:[^`\\]|\\.)*)`/g;
48+
49+
function hostOf(source: string): string | undefined {
50+
const withoutScheme = source.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
51+
const host = withoutScheme.split("/")[0]?.split(":")[0];
52+
return host && host.includes(".") ? host.toLowerCase() : undefined;
53+
}
54+
55+
/** Why this source would let an image request carry data off-origin, if it would. */
56+
function overBroadReason(source: string): string | undefined {
57+
if (LOCAL_SOURCES.has(source.toLowerCase())) return undefined;
58+
if (source === "*" || source.includes("*")) {
59+
return "wildcard matches hosts nobody vetted";
60+
}
61+
if (/^[a-z][a-z0-9+.-]*:$/i.test(source)) {
62+
return "a bare scheme allows every host on it";
63+
}
64+
const host = hostOf(source);
65+
if (!host) return undefined;
66+
const match = PUBLIC_UPLOAD_HOSTS.find((h) => host === h || host.endsWith(`.${h}`));
67+
return match ? `${match} accepts uploads from anyone` : undefined;
68+
}
69+
70+
/** Every over-broad image source in a policy string, as `directive source: reason`. */
71+
export function overBroadImageSources(policy: string): string[] {
72+
const findings: string[] = [];
73+
74+
for (const segment of policy.split(";")) {
75+
const tokens = segment.trim().split(/\s+/).filter(Boolean);
76+
const [directive, ...sources] = tokens;
77+
if (!directive || !IMAGE_DIRECTIVES.has(directive.toLowerCase())) continue;
78+
79+
for (const source of sources) {
80+
const reason = overBroadReason(source);
81+
if (reason) findings.push(`${directive} ${source}: ${reason}`);
82+
}
83+
}
84+
85+
return findings;
86+
}
87+
88+
function collectSourceFiles(): string[] {
89+
const files: string[] = [];
90+
91+
const walk = (absolute: string) => {
92+
const stats = statSync(absolute);
93+
if (stats.isFile()) {
94+
if (SOURCE_FILE.test(absolute) && !TEST_FILE.test(absolute)) files.push(absolute);
95+
return;
96+
}
97+
for (const entry of readdirSync(absolute, { withFileTypes: true })) {
98+
if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue;
99+
walk(join(absolute, entry.name));
100+
}
101+
};
102+
103+
for (const root of SCAN_ROOTS) walk(join(WEBAPP_ROOT, root));
104+
return files;
105+
}
106+
107+
const sourceFiles = collectSourceFiles().map((absolute) => ({
108+
path: relative(WEBAPP_ROOT, absolute).replaceAll("\\", "/"),
109+
contents: readFileSync(absolute, "utf8"),
110+
}));
111+
112+
function stringLiteralsIn(contents: string): string[] {
113+
return [...contents.matchAll(STRING_LITERAL)].map((m) => m[1] ?? m[2] ?? m[3] ?? "");
114+
}
115+
116+
describe("route-level image CSP", () => {
117+
it("scans the webapp sources", () => {
118+
expect(sourceFiles.length).toBeGreaterThan(500);
119+
expect(sourceFiles.map((f) => f.path)).toContain(CSP_HEADER_OWNER);
120+
expect(sourceFiles.map((f) => f.path)).toContain("server.ts");
121+
});
122+
123+
it("recognises the over-broad shapes", () => {
124+
expect(overBroadImageSources("img-src *")).toHaveLength(1);
125+
expect(overBroadImageSources("img-src 'self' https:")).toHaveLength(1);
126+
expect(overBroadImageSources("img-src https://*.example.com")).toHaveLength(1);
127+
expect(overBroadImageSources("default-src * ; img-src 'self'")).toHaveLength(1);
128+
expect(overBroadImageSources("img-src https://raw.githubusercontent.com")).toHaveLength(1);
129+
expect(
130+
overBroadImageSources("frame-ancestors *; img-src 'self' data: blob: https://a.example.com")
131+
).toEqual([]);
132+
});
133+
134+
it("finds no over-broad image policy in any source", () => {
135+
const findings: string[] = [];
136+
137+
for (const file of sourceFiles) {
138+
for (const literal of stringLiteralsIn(file.contents)) {
139+
if (!HAS_IMAGE_DIRECTIVE.test(literal)) continue;
140+
for (const finding of overBroadImageSources(literal)) {
141+
findings.push(`${file.path}: ${finding}`);
142+
}
143+
}
144+
}
145+
146+
expect(findings).toEqual([]);
147+
});
148+
149+
it("sets the Content-Security-Policy header in one place only", () => {
150+
const setters = sourceFiles
151+
.filter((file) => /["']Content-Security-Policy["']/i.test(file.contents))
152+
.map((file) => file.path);
153+
154+
expect(setters).toEqual([CSP_HEADER_OWNER]);
155+
});
156+
});

0 commit comments

Comments
 (0)