security: enforce nonce based content policy

This commit is contained in:
ik
2026-09-13 16:38:47 +07:00
parent 1305cccfa5
commit 86ed3a966a
8 changed files with 68 additions and 11 deletions
+38 -1
View File
@@ -39,6 +39,43 @@ const jsonLd = JSON.stringify({
"@context": "https://schema.org",
"@graph": jsonLdGraph,
}).replaceAll("<", "\\u003c");
const configuredFilesDomain = process.env.FILES_DOMAIN || "files.rf4spotter.ru";
const filesDomain = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i.test(configuredFilesDomain)
? configuredFilesDomain
: "files.rf4spotter.ru";
const isLoopback = (hostname: string) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
const safeOrigin = (value: string | undefined, fallback: string) => {
try {
const url = new URL(value || fallback);
return url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname)) ? url.origin : fallback;
} catch {
return fallback;
}
};
const apiOrigin = safeOrigin(import.meta.env.PUBLIC_API_URL, siteUrl);
const filesFallback = `https://${filesDomain}`;
const filesOrigin = (() => {
try {
const url = new URL(process.env.FILES_ORIGIN || filesFallback);
if ((url.protocol === "https:" && url.hostname === filesDomain) || (url.protocol === "http:" && isLoopback(url.hostname))) {
return url.origin;
}
} catch {
// Invalid deployment input falls back to the validated production hostname.
}
return filesFallback;
})();
const localOrigins = [apiOrigin, filesOrigin].filter((origin) => origin.startsWith("http://"));
const cspNonce = crypto.randomUUID().replaceAll("-", "");
Astro.response.headers.set("Content-Security-Policy", [
"default-src 'self'", "base-uri 'self'", "object-src 'none'", "frame-ancestors 'none'",
"form-action 'self'", `connect-src 'self'${apiOrigin === siteUrl ? "" : ` ${apiOrigin}`}`,
`img-src 'self' data: ${filesOrigin}`,
"font-src 'self'", "media-src 'self'", "manifest-src 'self'",
`script-src 'self' 'nonce-${cspNonce}'`, "script-src-attr 'none'",
"style-src 'self'", "style-src-attr 'none'",
...(localOrigins.length ? [] : ["upgrade-insecure-requests"]),
].join("; "));
---
<!doctype html>
<html lang="ru" data-theme={theme === "system" ? undefined : theme}>
@@ -70,7 +107,7 @@ const jsonLd = JSON.stringify({
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={socialImage} />
<meta name="twitter:image:alt" content="Лаймовый поплавок на тёмном озере с координатной сеткой" />
<script type="application/ld+json" set:html={jsonLd} is:inline />
<script type="application/ld+json" nonce={cspNonce} set:html={jsonLd} is:inline />
<title>{title}</title>
</head>
<body>
@@ -23,3 +23,20 @@ test("production bootstrap supports submission and moderation", async ({ page, r
expect(response.ok()).toBeTruthy();
expect(await response.text()).toContain(player);
});
test("SSR pages use a per-response CSP nonce for JSON-LD", async ({ page, request }) => {
for (const path of ["/", "/report", "/admin/"]) {
const response = await page.goto(path);
const policy = response?.headers()["content-security-policy"] ?? "";
expect(policy).toContain("script-src 'self' 'nonce-");
expect(policy).not.toContain("'unsafe-inline'");
expect(policy).toContain("style-src-attr 'none'");
const nonce = await page.locator('script[type="application/ld+json"]').evaluate((node: HTMLScriptElement) => node.nonce);
expect(nonce).toMatch(/^[a-f0-9]{32}$/);
expect(policy).toContain(`'nonce-${nonce}'`);
}
const image = await request.get("/og-rf4spotter.png");
expect(image.ok()).toBeTruthy();
expect(image.headers()["content-type"]).toContain("image/png");
});