@rimelight/security 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/index.d.mts +48 -0
- package/dist/config/index.mjs +109 -0
- package/dist/integrations/index.d.mts +9 -0
- package/dist/integrations/index.mjs +81 -0
- package/{src/middleware/dev-only.ts → dist/middleware/index.d.mts} +10 -6
- package/dist/middleware/index.mjs +149 -0
- package/package.json +18 -7
- package/src/components/SecurityHead.astro +11 -5
- package/src/config/index.ts +0 -1
- package/src/config/security.ts +0 -263
- package/src/integrations/index.ts +0 -1
- package/src/integrations/sri.ts +0 -137
- package/src/middleware/index.ts +0 -2
- package/src/middleware/security.ts +0 -250
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AstroUserConfig } from "astro";
|
|
2
|
+
//#region src/config/security.d.ts
|
|
3
|
+
type AstroSecurityConfig = NonNullable<AstroUserConfig["security"]>;
|
|
4
|
+
type AstroCspConfig = Exclude<NonNullable<AstroSecurityConfig["csp"]>, boolean>;
|
|
5
|
+
interface SecurityConfigOptions {
|
|
6
|
+
/**
|
|
7
|
+
* The base domain name for this project (e.g., "starter.rimelight.com") used to autoconfigure
|
|
8
|
+
* standard allowedDomains and img-src CDN targets.
|
|
9
|
+
*/
|
|
10
|
+
domain: string;
|
|
11
|
+
/**
|
|
12
|
+
* Additional Allowed Domains (appended to the default wildcard subdomains of `domain`)
|
|
13
|
+
*/
|
|
14
|
+
allowedDomains?: AstroSecurityConfig["allowedDomains"];
|
|
15
|
+
/**
|
|
16
|
+
* Additional sources to allow for img-src directive
|
|
17
|
+
*/
|
|
18
|
+
imgSrc?: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Additional sources to allow for connect-src directive
|
|
21
|
+
*/
|
|
22
|
+
connectSrc?: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Additional sources to allow for frame-src directive
|
|
25
|
+
*/
|
|
26
|
+
frameSrc?: string[];
|
|
27
|
+
/**
|
|
28
|
+
* Additional script resources
|
|
29
|
+
*/
|
|
30
|
+
scriptResources?: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Additional style resources
|
|
33
|
+
*/
|
|
34
|
+
styleResources?: string[];
|
|
35
|
+
/**
|
|
36
|
+
* Enables the 'require-trusted-types-for' directive
|
|
37
|
+
*/
|
|
38
|
+
trustedTypes?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Direct overrides for any security config fields
|
|
41
|
+
*/
|
|
42
|
+
overrides?: Partial<Omit<AstroSecurityConfig, "csp">> & {
|
|
43
|
+
csp?: Partial<AstroCspConfig>;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
declare function defineSecurity(options: SecurityConfigOptions): AstroSecurityConfig;
|
|
47
|
+
//#endregion
|
|
48
|
+
export { AstroCspConfig, AstroSecurityConfig, SecurityConfigOptions, defineSecurity };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
//#region src/config/security.ts
|
|
2
|
+
const normalizeDirective = (input) => {
|
|
3
|
+
if (!input) return [];
|
|
4
|
+
if (Array.isArray(input)) return input.map((item) => {
|
|
5
|
+
if (typeof item === "string") return {
|
|
6
|
+
resource: item,
|
|
7
|
+
kind: "element"
|
|
8
|
+
};
|
|
9
|
+
return item;
|
|
10
|
+
});
|
|
11
|
+
if (typeof input === "object" && "resources" in input && Array.isArray(input.resources)) return input.resources.map((res) => ({
|
|
12
|
+
resource: res,
|
|
13
|
+
kind: "element"
|
|
14
|
+
}));
|
|
15
|
+
return [];
|
|
16
|
+
};
|
|
17
|
+
function mergeDirective(current, incoming) {
|
|
18
|
+
if (!current && !incoming) return void 0;
|
|
19
|
+
const currentNormalized = normalizeDirective(current);
|
|
20
|
+
const incomingNormalized = normalizeDirective(incoming);
|
|
21
|
+
const mergedMap = /* @__PURE__ */ new Map();
|
|
22
|
+
for (const item of [...currentNormalized, ...incomingNormalized]) {
|
|
23
|
+
const key = `${item.resource}::${item.kind || "element"}`;
|
|
24
|
+
mergedMap.set(key, item);
|
|
25
|
+
}
|
|
26
|
+
const mergedList = Array.from(mergedMap.values());
|
|
27
|
+
if (!Array.isArray(current) && !Array.isArray(incoming) && current && typeof current === "object" && incoming && typeof incoming === "object" && !("length" in current) && !("length" in incoming)) return { resources: mergedList.map((item) => item.resource) };
|
|
28
|
+
return mergedList;
|
|
29
|
+
}
|
|
30
|
+
function defineSecurity(options) {
|
|
31
|
+
const { domain, overrides } = options;
|
|
32
|
+
const allowedDomains = [{
|
|
33
|
+
hostname: `**.${domain}`,
|
|
34
|
+
protocol: "https"
|
|
35
|
+
}, ...options.allowedDomains || []];
|
|
36
|
+
const imgSources = Array.from(/* @__PURE__ */ new Set([
|
|
37
|
+
"'self'",
|
|
38
|
+
"data:",
|
|
39
|
+
`https://cdn.${domain}`,
|
|
40
|
+
"https://i3.ytimg.com",
|
|
41
|
+
"https://www.youtube.com",
|
|
42
|
+
"https://www.youtube-nocookie.com",
|
|
43
|
+
...options.imgSrc || []
|
|
44
|
+
]));
|
|
45
|
+
const connectSources = Array.from(/* @__PURE__ */ new Set([
|
|
46
|
+
"'self'",
|
|
47
|
+
"https://cloudflareinsights.com",
|
|
48
|
+
...options.connectSrc || []
|
|
49
|
+
]));
|
|
50
|
+
const frameSources = Array.from(/* @__PURE__ */ new Set([
|
|
51
|
+
"https://www.youtube.com",
|
|
52
|
+
"https://www.youtube-nocookie.com",
|
|
53
|
+
...options.frameSrc || []
|
|
54
|
+
]));
|
|
55
|
+
const baseDirectives = [
|
|
56
|
+
"default-src 'none'",
|
|
57
|
+
`img-src ${imgSources.join(" ")}`,
|
|
58
|
+
"font-src 'self'",
|
|
59
|
+
`connect-src ${connectSources.join(" ")}`,
|
|
60
|
+
"frame-ancestors 'none'",
|
|
61
|
+
`frame-src ${frameSources.join(" ")}`,
|
|
62
|
+
"upgrade-insecure-requests",
|
|
63
|
+
"base-uri 'self'",
|
|
64
|
+
"form-action 'self'",
|
|
65
|
+
"object-src 'none'",
|
|
66
|
+
"manifest-src 'self'",
|
|
67
|
+
...options.trustedTypes ? ["require-trusted-types-for 'script'"] : []
|
|
68
|
+
];
|
|
69
|
+
const scriptResources = Array.from(/* @__PURE__ */ new Set([
|
|
70
|
+
"'self'",
|
|
71
|
+
"https://static.cloudflareinsights.com",
|
|
72
|
+
"https://betterlytics.io/analytics.js",
|
|
73
|
+
"'inline-speculation-rules'",
|
|
74
|
+
"'sha256-ZuMSxilKU+4KIM8LWna4lqBEKzd6WaiAjz6gamhm7zM='",
|
|
75
|
+
...options.scriptResources || []
|
|
76
|
+
]));
|
|
77
|
+
const styleResources = Array.from(/* @__PURE__ */ new Set([
|
|
78
|
+
"'self'",
|
|
79
|
+
"'sha256-SKuaOGnks7NAUq37nvw1PfGEE0lOAs5ERbiYRbGFIbw='",
|
|
80
|
+
...options.styleResources || []
|
|
81
|
+
]));
|
|
82
|
+
const securityConfig = {
|
|
83
|
+
checkOrigin: true,
|
|
84
|
+
allowedDomains,
|
|
85
|
+
csp: {
|
|
86
|
+
algorithm: "SHA-384",
|
|
87
|
+
directives: baseDirectives,
|
|
88
|
+
scriptDirective: { resources: scriptResources },
|
|
89
|
+
styleDirective: { resources: styleResources }
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
if (overrides) {
|
|
93
|
+
const { csp, ...restOverrides } = overrides;
|
|
94
|
+
Object.assign(securityConfig, restOverrides);
|
|
95
|
+
if (csp && typeof securityConfig.csp === "object" && securityConfig.csp !== null) {
|
|
96
|
+
const currentCsp = securityConfig.csp;
|
|
97
|
+
const mergedCsp = {
|
|
98
|
+
...currentCsp,
|
|
99
|
+
...csp
|
|
100
|
+
};
|
|
101
|
+
if (csp.scriptDirective || currentCsp.scriptDirective) mergedCsp.scriptDirective = mergeDirective(currentCsp.scriptDirective, csp.scriptDirective);
|
|
102
|
+
if (csp.styleDirective || currentCsp.styleDirective) mergedCsp.styleDirective = mergeDirective(currentCsp.styleDirective, csp.styleDirective);
|
|
103
|
+
securityConfig.csp = mergedCsp;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return securityConfig;
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
export { defineSecurity };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { AstroIntegration } from "astro";
|
|
2
|
+
//#region src/integrations/sri.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Astro SRI Integration Fetches external scripts at build-time and generates hashes for the SSR
|
|
5
|
+
* middleware.
|
|
6
|
+
*/
|
|
7
|
+
declare function sri(): AstroIntegration;
|
|
8
|
+
//#endregion
|
|
9
|
+
export { sri };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
//#region src/integrations/sri.ts
|
|
3
|
+
function assertSRIConfig(obj) {
|
|
4
|
+
if (!obj || typeof obj !== "object") throw new Error("Expected config");
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Astro SRI Integration Fetches external scripts at build-time and generates hashes for the SSR
|
|
8
|
+
* middleware.
|
|
9
|
+
*/
|
|
10
|
+
function sri() {
|
|
11
|
+
const sriManifest = {};
|
|
12
|
+
return {
|
|
13
|
+
name: "@rimelight/security",
|
|
14
|
+
hooks: { "astro:config:setup": async ({ updateConfig, config, command, logger }) => {
|
|
15
|
+
if (command === "dev") logger.info("Skipping SRI discovery in development mode.");
|
|
16
|
+
else {
|
|
17
|
+
assertSRIConfig(config);
|
|
18
|
+
const security = config.security;
|
|
19
|
+
const externalUrls = [];
|
|
20
|
+
if (security?.csp?.scriptDirective?.resources) {
|
|
21
|
+
for (const resource of security.csp.scriptDirective.resources) if (resource.startsWith("https://")) try {
|
|
22
|
+
const parsed = new URL(resource);
|
|
23
|
+
if (parsed.hostname.includes("*")) continue;
|
|
24
|
+
const path = parsed.pathname;
|
|
25
|
+
if (path === "/" || path === "" || path.endsWith("/")) continue;
|
|
26
|
+
externalUrls.push(resource);
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
if (externalUrls.length > 0) {
|
|
30
|
+
const results = await Promise.all(externalUrls.map(async (url) => {
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(url);
|
|
33
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
34
|
+
const buffer = await response.arrayBuffer();
|
|
35
|
+
const hash = createHash("sha384").update(Buffer.from(buffer)).digest("base64");
|
|
36
|
+
sriManifest[url] = `sha384-${hash}`;
|
|
37
|
+
return {
|
|
38
|
+
url,
|
|
39
|
+
success: true
|
|
40
|
+
};
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return {
|
|
43
|
+
url,
|
|
44
|
+
success: false,
|
|
45
|
+
error: err instanceof Error ? err.message : String(err)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}));
|
|
49
|
+
const succeeded = results.filter((r) => r.success).map((r) => r.url);
|
|
50
|
+
const failed = results.filter((r) => !r.success);
|
|
51
|
+
if (failed.length === 0) logger.info(`SRI Discovery: successfully hashed ${succeeded.length} external resource(s).\n Succeeded:\n` + succeeded.map((url) => ` - ${url}`).join("\n"));
|
|
52
|
+
else {
|
|
53
|
+
const succeededBlock = succeeded.length > 0 ? ` Succeeded:\n` + succeeded.map((url) => ` - ${url}`).join("\n") : "";
|
|
54
|
+
const failedBlock = ` Failed:\n` + failed.map((f) => ` - ${f.url} (${f.error})`).join("\n");
|
|
55
|
+
const parts = [
|
|
56
|
+
`SRI Discovery: hashed ${succeeded.length}/${results.length} external resource(s).`,
|
|
57
|
+
succeededBlock,
|
|
58
|
+
failedBlock
|
|
59
|
+
].filter(Boolean);
|
|
60
|
+
logger.warn(parts.join("\n\n"));
|
|
61
|
+
}
|
|
62
|
+
} else logger.info("SRI Discovery: no external resources found to hash.");
|
|
63
|
+
}
|
|
64
|
+
const virtualModuleId = "virtual:sri-manifest";
|
|
65
|
+
const resolvedVirtualModuleId = "\0" + virtualModuleId;
|
|
66
|
+
updateConfig({ vite: { plugins: [{
|
|
67
|
+
name: "vite-plugin-sri-manifest",
|
|
68
|
+
resolveId(id) {
|
|
69
|
+
if (id === virtualModuleId) return resolvedVirtualModuleId;
|
|
70
|
+
return null;
|
|
71
|
+
},
|
|
72
|
+
load(id) {
|
|
73
|
+
if (id === resolvedVirtualModuleId) return `export const manifest = ${JSON.stringify(sriManifest)};`;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}] } });
|
|
77
|
+
} }
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
export { sri };
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
//#region src/middleware/security.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Security Middleware: Injects SRI integrity attributes and security headers.
|
|
4
|
+
*/
|
|
5
|
+
declare const security: import("astro").MiddlewareHandler;
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/middleware/dev-only.d.ts
|
|
1
8
|
/**
|
|
2
9
|
* Hono middleware that blocks any path containing `/dev/` in non-development environments. Mount
|
|
3
10
|
* this early in your Hono app (before API routes and Astro pages) so it covers both `/dev/` page
|
|
@@ -8,9 +15,6 @@
|
|
|
8
15
|
* import { devOnly } from "@rimelight/security/middleware"
|
|
9
16
|
* app.use(devOnly)
|
|
10
17
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
return next()
|
|
16
|
-
}
|
|
18
|
+
declare const devOnly: (c: any, next: any) => Promise<Response>;
|
|
19
|
+
//#endregion
|
|
20
|
+
export { devOnly, security };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { defineMiddleware } from "astro:middleware";
|
|
2
|
+
import { manifest } from "virtual:sri-manifest";
|
|
3
|
+
//#region src/middleware/security.ts
|
|
4
|
+
function isManifestRecord(obj) {
|
|
5
|
+
return typeof obj === "object" && obj !== null && !Array.isArray(obj);
|
|
6
|
+
}
|
|
7
|
+
const manifest$1 = isManifestRecord(manifest) ? manifest : {};
|
|
8
|
+
function assertManifest(obj) {
|
|
9
|
+
if (!obj || typeof obj !== "object") throw new Error("Expected manifest");
|
|
10
|
+
}
|
|
11
|
+
assertManifest(manifest$1);
|
|
12
|
+
const manifestRegexes = Object.entries(manifest$1).map(([url, hash]) => ({
|
|
13
|
+
hash,
|
|
14
|
+
scriptRegex: new RegExp(`(<script[^>]+src=["']${url}["'][^>]*)(/?>)`, "g"),
|
|
15
|
+
linkRegex: new RegExp(`(<link[^>]+href=["']${url}["'][^>]*)(/?>)`, "g")
|
|
16
|
+
}));
|
|
17
|
+
function addSecurityHeaders(response, origin) {
|
|
18
|
+
try {
|
|
19
|
+
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
20
|
+
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
21
|
+
response.headers.set("X-Frame-Options", "DENY");
|
|
22
|
+
response.headers.set("Cross-Origin-Resource-Policy", "same-origin");
|
|
23
|
+
response.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
|
|
24
|
+
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
|
25
|
+
response.headers.set("No-Vary-Search", "except=(\"q\" \"search\" \"locale\"), params=?1");
|
|
26
|
+
if (origin) response.headers.set("Link", [
|
|
27
|
+
`<${origin}/sitemap-index.xml>; rel="sitemap"`,
|
|
28
|
+
`<${origin}/llms.txt>; rel="llms"`,
|
|
29
|
+
`<${origin}/.well-known/api-catalog>; rel="api-catalog"`,
|
|
30
|
+
`<${origin}/rss.xml>; rel="alternate"; type="application/rss+xml"`
|
|
31
|
+
].join(", "));
|
|
32
|
+
return response;
|
|
33
|
+
} catch {
|
|
34
|
+
const headers = new Headers(response.headers);
|
|
35
|
+
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
36
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
37
|
+
headers.set("X-Frame-Options", "DENY");
|
|
38
|
+
headers.set("Cross-Origin-Resource-Policy", "same-origin");
|
|
39
|
+
headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
|
|
40
|
+
headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
|
41
|
+
headers.set("No-Vary-Search", "except=(\"q\" \"search\" \"locale\"), params=?1");
|
|
42
|
+
if (origin) headers.set("Link", [
|
|
43
|
+
`<${origin}/sitemap-index.xml>; rel="sitemap"`,
|
|
44
|
+
`<${origin}/llms.txt>; rel="llms"`,
|
|
45
|
+
`<${origin}/.well-known/api-catalog>; rel="api-catalog"`,
|
|
46
|
+
`<${origin}/rss.xml>; rel="alternate"; type="application/rss+xml"`
|
|
47
|
+
].join(", "));
|
|
48
|
+
return new Response(response.body, {
|
|
49
|
+
status: response.status,
|
|
50
|
+
statusText: response.statusText,
|
|
51
|
+
headers
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Security Middleware: Injects SRI integrity attributes and security headers.
|
|
57
|
+
*/
|
|
58
|
+
const security = defineMiddleware(async (context, next) => {
|
|
59
|
+
const isProd = import.meta.env.PROD;
|
|
60
|
+
const isDev = import.meta.env.DEV;
|
|
61
|
+
const proto = context.request.headers.get("x-forwarded-proto");
|
|
62
|
+
if (isProd && proto === "http") {
|
|
63
|
+
const httpsUrl = context.request.url.replace(/^http:/, "https:");
|
|
64
|
+
return Response.redirect(httpsUrl, 301);
|
|
65
|
+
}
|
|
66
|
+
const response = await next();
|
|
67
|
+
if (!(response.headers.get("content-type") || "").includes("text/html")) return addSecurityHeaders(response, context.url.origin);
|
|
68
|
+
if (isDev) {
|
|
69
|
+
const cleanHeaders = new Headers(response.headers);
|
|
70
|
+
cleanHeaders.delete("content-security-policy");
|
|
71
|
+
let modifiedHtml = await response.text();
|
|
72
|
+
modifiedHtml = modifiedHtml.replace(/<meta\s[^>]*http-equiv\s*=\s*["']?content-security-policy[^>]*>/gi, "");
|
|
73
|
+
return addSecurityHeaders(new Response(modifiedHtml, {
|
|
74
|
+
status: response.status,
|
|
75
|
+
statusText: response.statusText,
|
|
76
|
+
headers: cleanHeaders
|
|
77
|
+
}), context.url.origin);
|
|
78
|
+
}
|
|
79
|
+
const nonce = crypto.randomUUID().replace(/-/g, "");
|
|
80
|
+
let cspHeader = response.headers.get("content-security-policy");
|
|
81
|
+
const newHeaders = new Headers(response.headers);
|
|
82
|
+
if (cspHeader) {
|
|
83
|
+
cspHeader = cspHeader.replace(/�*39;/g, "'").replace(/�*27;/gi, "'").replace(/"/g, "\"").replace(/&/g, "&");
|
|
84
|
+
cspHeader = cspHeader.replace(/(script-src\s)/i, `$1'nonce-${nonce}' `);
|
|
85
|
+
newHeaders.set("content-security-policy", cspHeader);
|
|
86
|
+
}
|
|
87
|
+
if (typeof HTMLRewriter !== "undefined") {
|
|
88
|
+
let rewriter = new HTMLRewriter();
|
|
89
|
+
if (manifestRegexes.length > 0) rewriter = rewriter.on("script[src]", { element(el) {
|
|
90
|
+
const src = el.getAttribute("src");
|
|
91
|
+
if (src && manifest$1[src]) {
|
|
92
|
+
el.setAttribute("integrity", manifest$1[src]);
|
|
93
|
+
el.setAttribute("crossorigin", "anonymous");
|
|
94
|
+
}
|
|
95
|
+
} }).on("link[rel=\"stylesheet\"][href]", { element(el) {
|
|
96
|
+
const href = el.getAttribute("href");
|
|
97
|
+
if (href && manifest$1[href]) {
|
|
98
|
+
el.setAttribute("integrity", manifest$1[href]);
|
|
99
|
+
el.setAttribute("crossorigin", "anonymous");
|
|
100
|
+
}
|
|
101
|
+
} });
|
|
102
|
+
rewriter = rewriter.on("script", { element(el) {
|
|
103
|
+
el.setAttribute("nonce", nonce);
|
|
104
|
+
} });
|
|
105
|
+
rewriter = rewriter.on("head", { element(el) {
|
|
106
|
+
el.append(`<meta name="csp-nonce" content="${nonce}">`, { html: true });
|
|
107
|
+
} });
|
|
108
|
+
rewriter = rewriter.on("meta[http-equiv=\"content-security-policy\" i]", { element(el) {
|
|
109
|
+
el.remove();
|
|
110
|
+
} });
|
|
111
|
+
return addSecurityHeaders(rewriter.transform(new Response(response.body, {
|
|
112
|
+
status: response.status,
|
|
113
|
+
statusText: response.statusText,
|
|
114
|
+
headers: newHeaders
|
|
115
|
+
})), context.url.origin);
|
|
116
|
+
}
|
|
117
|
+
let modifiedHtml = await response.text();
|
|
118
|
+
modifiedHtml = modifiedHtml.replace(/<meta\s[^>]*http-equiv\s*=\s*["']?content-security-policy[^>]*>/gi, "");
|
|
119
|
+
modifiedHtml = modifiedHtml.replace(/(<head(?:\s[^>]*)?>)/i, `$1<meta name="csp-nonce" content="${nonce}">`);
|
|
120
|
+
modifiedHtml = modifiedHtml.replace(/<script(\s[^>]*)?>/gi, (_match, attrs = "") => `<script${attrs} nonce="${nonce}">`);
|
|
121
|
+
for (const { hash, scriptRegex, linkRegex } of manifestRegexes) {
|
|
122
|
+
if (!hash) continue;
|
|
123
|
+
modifiedHtml = modifiedHtml.replace(scriptRegex, `$1 integrity="${hash}" crossorigin="anonymous"$2`);
|
|
124
|
+
modifiedHtml = modifiedHtml.replace(linkRegex, `$1 integrity="${hash}" crossorigin="anonymous"$2`);
|
|
125
|
+
}
|
|
126
|
+
return addSecurityHeaders(new Response(modifiedHtml, {
|
|
127
|
+
status: response.status,
|
|
128
|
+
statusText: response.statusText,
|
|
129
|
+
headers: newHeaders
|
|
130
|
+
}), context.url.origin);
|
|
131
|
+
});
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/middleware/dev-only.ts
|
|
134
|
+
/**
|
|
135
|
+
* Hono middleware that blocks any path containing `/dev/` in non-development environments. Mount
|
|
136
|
+
* this early in your Hono app (before API routes and Astro pages) so it covers both `/dev/` page
|
|
137
|
+
* routes and `/api/dev/` API routes in a single place.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* // fetch.ts
|
|
141
|
+
* import { devOnly } from "@rimelight/security/middleware"
|
|
142
|
+
* app.use(devOnly)
|
|
143
|
+
*/
|
|
144
|
+
const devOnly = async (c, next) => {
|
|
145
|
+
if (!import.meta.env.DEV && c.req.path.includes("/dev/")) return c.notFound();
|
|
146
|
+
return next();
|
|
147
|
+
};
|
|
148
|
+
//#endregion
|
|
149
|
+
export { devOnly, security };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rimelight/security",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Rimelight Entertainment's Security Package",
|
|
6
6
|
"homepage": "https://rimelight.com/docs",
|
|
@@ -16,13 +16,23 @@
|
|
|
16
16
|
"url": "git+https://github.com/Rimelight-Entertainment/rimelight.git"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
-
"
|
|
19
|
+
"dist",
|
|
20
|
+
"src/components"
|
|
20
21
|
],
|
|
21
22
|
"type": "module",
|
|
22
23
|
"exports": {
|
|
23
|
-
".":
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/integrations/index.d.mts",
|
|
26
|
+
"import": "./dist/integrations/index.mjs"
|
|
27
|
+
},
|
|
28
|
+
"./middleware": {
|
|
29
|
+
"types": "./dist/middleware/index.d.mts",
|
|
30
|
+
"import": "./dist/middleware/index.mjs"
|
|
31
|
+
},
|
|
32
|
+
"./config": {
|
|
33
|
+
"types": "./dist/config/index.d.mts",
|
|
34
|
+
"import": "./dist/config/index.mjs"
|
|
35
|
+
},
|
|
26
36
|
"./*": "./src/*"
|
|
27
37
|
},
|
|
28
38
|
"publishConfig": {
|
|
@@ -30,8 +40,8 @@
|
|
|
30
40
|
},
|
|
31
41
|
"devDependencies": {
|
|
32
42
|
"@astrojs/check": "0.9.10",
|
|
33
|
-
"@rimelight/config": "0.0.
|
|
34
|
-
"astro": "7.
|
|
43
|
+
"@rimelight/config": "0.0.4",
|
|
44
|
+
"astro": "7.3.1",
|
|
35
45
|
"typescript": "6.0.3"
|
|
36
46
|
},
|
|
37
47
|
"peerDependencies": {
|
|
@@ -41,6 +51,7 @@
|
|
|
41
51
|
"node": ">=26.7.0"
|
|
42
52
|
},
|
|
43
53
|
"scripts": {
|
|
54
|
+
"build": "vp pack",
|
|
44
55
|
"check": "vp check --fix && astro check"
|
|
45
56
|
}
|
|
46
57
|
}
|
|
@@ -10,11 +10,17 @@
|
|
|
10
10
|
(function() {
|
|
11
11
|
const w = window as any;
|
|
12
12
|
if (w.trustedTypes && w.trustedTypes.createPolicy) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
try {
|
|
14
|
+
if (!w.trustedTypes.defaultPolicy) {
|
|
15
|
+
w.trustedTypes.createPolicy('default', {
|
|
16
|
+
createHTML: function(input: string) { return input; },
|
|
17
|
+
createScript: function(input: string) { return input; },
|
|
18
|
+
createScriptURL: function(input: string) { return input; }
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
// Policy already exists or cannot be created
|
|
23
|
+
}
|
|
18
24
|
}
|
|
19
25
|
})();
|
|
20
26
|
</script>
|
package/src/config/index.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from "./security"
|
package/src/config/security.ts
DELETED
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
import type { AstroUserConfig } from "astro"
|
|
2
|
-
|
|
3
|
-
export type AstroSecurityConfig = NonNullable<AstroUserConfig["security"]>
|
|
4
|
-
export type AstroCspConfig = Exclude<NonNullable<AstroSecurityConfig["csp"]>, boolean>
|
|
5
|
-
|
|
6
|
-
export interface SecurityConfigOptions {
|
|
7
|
-
/**
|
|
8
|
-
* The base domain name for this project (e.g., "starter.rimelight.com") used to autoconfigure
|
|
9
|
-
* standard allowedDomains and img-src CDN targets.
|
|
10
|
-
*/
|
|
11
|
-
domain: string
|
|
12
|
-
/**
|
|
13
|
-
* Additional Allowed Domains (appended to the default wildcard subdomains of `domain`)
|
|
14
|
-
*/
|
|
15
|
-
allowedDomains?: AstroSecurityConfig["allowedDomains"]
|
|
16
|
-
/**
|
|
17
|
-
* Additional sources to allow for img-src directive
|
|
18
|
-
*/
|
|
19
|
-
imgSrc?: string[]
|
|
20
|
-
/**
|
|
21
|
-
* Additional sources to allow for connect-src directive
|
|
22
|
-
*/
|
|
23
|
-
connectSrc?: string[]
|
|
24
|
-
/**
|
|
25
|
-
* Additional sources to allow for frame-src directive
|
|
26
|
-
*/
|
|
27
|
-
frameSrc?: string[]
|
|
28
|
-
/**
|
|
29
|
-
* Additional script resources
|
|
30
|
-
*/
|
|
31
|
-
scriptResources?: string[]
|
|
32
|
-
/**
|
|
33
|
-
* Additional style resources
|
|
34
|
-
*/
|
|
35
|
-
styleResources?: string[]
|
|
36
|
-
/**
|
|
37
|
-
* Enables the 'require-trusted-types-for' directive
|
|
38
|
-
*/
|
|
39
|
-
trustedTypes?: boolean
|
|
40
|
-
/**
|
|
41
|
-
* Direct overrides for any security config fields
|
|
42
|
-
*/
|
|
43
|
-
overrides?: Partial<Omit<AstroSecurityConfig, "csp">> & {
|
|
44
|
-
csp?: Partial<AstroCspConfig>
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Generates a standard, strictly configured Security/CSP object for Astro configurations with
|
|
50
|
-
* support for easy customization and additions per project.
|
|
51
|
-
*
|
|
52
|
-
* @example
|
|
53
|
-
* ;```ts
|
|
54
|
-
* // Default returned shape:
|
|
55
|
-
* {
|
|
56
|
-
* checkOrigin: true,
|
|
57
|
-
* allowedDomains: [
|
|
58
|
-
* {
|
|
59
|
-
* hostname: "**.domain.com",
|
|
60
|
-
* protocol: "https"
|
|
61
|
-
* }
|
|
62
|
-
* ],
|
|
63
|
-
* csp: {
|
|
64
|
-
* algorithm: "SHA-384",
|
|
65
|
-
* directives: [
|
|
66
|
-
* "default-src 'none'",
|
|
67
|
-
* "img-src 'self' data: https://cdn.domain.com https://i3.ytimg.com https://www.youtube.com https://www.youtube-nocookie.com",
|
|
68
|
-
* "font-src 'self'",
|
|
69
|
-
* "connect-src 'self' https://cloudflareinsights.com",
|
|
70
|
-
* "frame-ancestors 'none'",
|
|
71
|
-
* "frame-src https://www.youtube.com https://www.youtube-nocookie.com",
|
|
72
|
-
* "upgrade-insecure-requests",
|
|
73
|
-
* "base-uri 'self'",
|
|
74
|
-
* "form-action 'self'"
|
|
75
|
-
* ],
|
|
76
|
-
* scriptDirective: {
|
|
77
|
-
* resources: [
|
|
78
|
-
* "'self'",
|
|
79
|
-
* "https://static.cloudflareinsights.com",
|
|
80
|
-
* "https://betterlytics.io/analytics.js",
|
|
81
|
-
* "'inline-speculation-rules'"
|
|
82
|
-
* ]
|
|
83
|
-
* },
|
|
84
|
-
* styleDirective: {
|
|
85
|
-
* resources: [
|
|
86
|
-
* "'self'",
|
|
87
|
-
* "'sha256-SKuaOGnks7NAUq37nvw1PfGEE0lOAs5ERbiYRbGFIbw='"
|
|
88
|
-
* ]
|
|
89
|
-
* }
|
|
90
|
-
* }
|
|
91
|
-
* }
|
|
92
|
-
* ```
|
|
93
|
-
*/
|
|
94
|
-
type DirectiveItem = { resource: string; kind?: "element" | "attribute" }
|
|
95
|
-
type DirectiveInput = string[] | DirectiveItem[] | { resources?: string[] } | null | undefined
|
|
96
|
-
|
|
97
|
-
const normalizeDirective = (input: DirectiveInput): DirectiveItem[] => {
|
|
98
|
-
if (!input) return []
|
|
99
|
-
if (Array.isArray(input)) {
|
|
100
|
-
return input.map((item) => {
|
|
101
|
-
if (typeof item === "string") {
|
|
102
|
-
return { resource: item, kind: "element" }
|
|
103
|
-
}
|
|
104
|
-
return item
|
|
105
|
-
})
|
|
106
|
-
}
|
|
107
|
-
if (typeof input === "object" && "resources" in input && Array.isArray(input.resources)) {
|
|
108
|
-
return input.resources.map((res) => ({ resource: res, kind: "element" }))
|
|
109
|
-
}
|
|
110
|
-
return []
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function mergeDirective(current: any, incoming: any): any {
|
|
114
|
-
if (!current && !incoming) return undefined
|
|
115
|
-
|
|
116
|
-
const currentNormalized = normalizeDirective(current)
|
|
117
|
-
const incomingNormalized = normalizeDirective(incoming)
|
|
118
|
-
|
|
119
|
-
const mergedMap = new Map<string, DirectiveItem>()
|
|
120
|
-
for (const item of [...currentNormalized, ...incomingNormalized]) {
|
|
121
|
-
const key = `${item.resource}::${item.kind || "element"}`
|
|
122
|
-
mergedMap.set(key, item)
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const mergedList = Array.from(mergedMap.values())
|
|
126
|
-
|
|
127
|
-
const currentWasArray = Array.isArray(current)
|
|
128
|
-
const incomingWasArray = Array.isArray(incoming)
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
!currentWasArray &&
|
|
132
|
-
!incomingWasArray &&
|
|
133
|
-
current &&
|
|
134
|
-
typeof current === "object" &&
|
|
135
|
-
incoming &&
|
|
136
|
-
typeof incoming === "object" &&
|
|
137
|
-
!("length" in current) &&
|
|
138
|
-
!("length" in incoming)
|
|
139
|
-
) {
|
|
140
|
-
return {
|
|
141
|
-
resources: mergedList.map((item) => item.resource)
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return mergedList
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function defineSecurity(options: SecurityConfigOptions): AstroSecurityConfig {
|
|
149
|
-
const { domain, overrides } = options
|
|
150
|
-
const domainWithWildcard = `**.${domain}`
|
|
151
|
-
|
|
152
|
-
// 1. Build default allowed domains
|
|
153
|
-
const allowedDomains = [
|
|
154
|
-
{
|
|
155
|
-
hostname: domainWithWildcard,
|
|
156
|
-
protocol: "https" as const
|
|
157
|
-
},
|
|
158
|
-
...(options.allowedDomains || [])
|
|
159
|
-
]
|
|
160
|
-
|
|
161
|
-
// 2. Build CSP directives
|
|
162
|
-
const imgSources = Array.from(
|
|
163
|
-
new Set([
|
|
164
|
-
"'self'",
|
|
165
|
-
"data:",
|
|
166
|
-
`https://cdn.${domain}`,
|
|
167
|
-
"https://i3.ytimg.com",
|
|
168
|
-
"https://www.youtube.com",
|
|
169
|
-
"https://www.youtube-nocookie.com",
|
|
170
|
-
...(options.imgSrc || [])
|
|
171
|
-
])
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
const connectSources = Array.from(
|
|
175
|
-
new Set(["'self'", "https://cloudflareinsights.com", ...(options.connectSrc || [])])
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
const frameSources = Array.from(
|
|
179
|
-
new Set([
|
|
180
|
-
"https://www.youtube.com",
|
|
181
|
-
"https://www.youtube-nocookie.com",
|
|
182
|
-
...(options.frameSrc || [])
|
|
183
|
-
])
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
const baseDirectives: AstroCspConfig["directives"] = [
|
|
187
|
-
"default-src 'none'",
|
|
188
|
-
`img-src ${imgSources.join(" ")}`,
|
|
189
|
-
"font-src 'self'",
|
|
190
|
-
`connect-src ${connectSources.join(" ")}`,
|
|
191
|
-
"frame-ancestors 'none'",
|
|
192
|
-
`frame-src ${frameSources.join(" ")}`,
|
|
193
|
-
"upgrade-insecure-requests",
|
|
194
|
-
"base-uri 'self'",
|
|
195
|
-
"form-action 'self'",
|
|
196
|
-
"object-src 'none'",
|
|
197
|
-
"manifest-src 'self'",
|
|
198
|
-
...(options.trustedTypes ? ["require-trusted-types-for 'script'" as const] : [])
|
|
199
|
-
]
|
|
200
|
-
|
|
201
|
-
// 3. Build script & style directives
|
|
202
|
-
const scriptResources = Array.from(
|
|
203
|
-
new Set([
|
|
204
|
-
"'self'",
|
|
205
|
-
"https://static.cloudflareinsights.com",
|
|
206
|
-
"https://betterlytics.io/analytics.js",
|
|
207
|
-
"'inline-speculation-rules'",
|
|
208
|
-
// Astro ClientRouter (View Transitions)
|
|
209
|
-
"'sha256-ZuMSxilKU+4KIM8LWna4lqBEKzd6WaiAjz6gamhm7zM='",
|
|
210
|
-
...(options.scriptResources || [])
|
|
211
|
-
])
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
const styleResources = Array.from(
|
|
215
|
-
new Set([
|
|
216
|
-
"'self'",
|
|
217
|
-
// Astro ClientRouter (View Transitions)
|
|
218
|
-
"'sha256-SKuaOGnks7NAUq37nvw1PfGEE0lOAs5ERbiYRbGFIbw='",
|
|
219
|
-
...(options.styleResources || [])
|
|
220
|
-
])
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
// 4. Build base SecurityConfig
|
|
224
|
-
const securityConfig: AstroSecurityConfig = {
|
|
225
|
-
checkOrigin: true,
|
|
226
|
-
allowedDomains,
|
|
227
|
-
csp: {
|
|
228
|
-
algorithm: "SHA-384",
|
|
229
|
-
directives: baseDirectives,
|
|
230
|
-
scriptDirective: {
|
|
231
|
-
resources: scriptResources
|
|
232
|
-
},
|
|
233
|
-
styleDirective: {
|
|
234
|
-
resources: styleResources
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// 5. Merge overrides
|
|
240
|
-
if (overrides) {
|
|
241
|
-
const { csp, ...restOverrides } = overrides
|
|
242
|
-
Object.assign(securityConfig, restOverrides)
|
|
243
|
-
if (csp && typeof securityConfig.csp === "object" && securityConfig.csp !== null) {
|
|
244
|
-
const currentCsp = securityConfig.csp
|
|
245
|
-
const mergedCsp: AstroCspConfig = {
|
|
246
|
-
...currentCsp,
|
|
247
|
-
...csp
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
if (csp.scriptDirective || currentCsp.scriptDirective) {
|
|
251
|
-
mergedCsp.scriptDirective = mergeDirective(currentCsp.scriptDirective, csp.scriptDirective)
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (csp.styleDirective || currentCsp.styleDirective) {
|
|
255
|
-
mergedCsp.styleDirective = mergeDirective(currentCsp.styleDirective, csp.styleDirective)
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
securityConfig.csp = mergedCsp
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
return securityConfig
|
|
263
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from "./sri"
|
package/src/integrations/sri.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import type { AstroIntegration } from "astro"
|
|
2
|
-
import { createHash } from "node:crypto"
|
|
3
|
-
|
|
4
|
-
interface SRIConfig {
|
|
5
|
-
security?: {
|
|
6
|
-
csp?: {
|
|
7
|
-
scriptDirective?: {
|
|
8
|
-
resources?: string[]
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function assertSRIConfig(obj: unknown): asserts obj is SRIConfig {
|
|
15
|
-
if (!obj || typeof obj !== "object") throw new Error("Expected config")
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Astro SRI Integration Fetches external scripts at build-time and generates hashes for the SSR
|
|
20
|
-
* middleware.
|
|
21
|
-
*/
|
|
22
|
-
export function sri(): AstroIntegration {
|
|
23
|
-
const sriManifest: Record<string, string> = {}
|
|
24
|
-
|
|
25
|
-
return {
|
|
26
|
-
name: "@rimelight/security",
|
|
27
|
-
hooks: {
|
|
28
|
-
"astro:config:setup": async ({ updateConfig, config, command, logger }) => {
|
|
29
|
-
const isDev = command === "dev"
|
|
30
|
-
|
|
31
|
-
if (isDev) {
|
|
32
|
-
logger.info("Skipping SRI discovery in development mode.")
|
|
33
|
-
} else {
|
|
34
|
-
// 1. Identify External Scripts from CSP config
|
|
35
|
-
// We look into the custom 'security' block in astro.config.mjs
|
|
36
|
-
assertSRIConfig(config)
|
|
37
|
-
|
|
38
|
-
const security = config.security
|
|
39
|
-
const externalUrls: string[] = []
|
|
40
|
-
|
|
41
|
-
if (security?.csp?.scriptDirective?.resources) {
|
|
42
|
-
for (const resource of security.csp.scriptDirective.resources) {
|
|
43
|
-
if (resource.startsWith("https://")) {
|
|
44
|
-
try {
|
|
45
|
-
const parsed = new URL(resource)
|
|
46
|
-
// Skip wildcard domains
|
|
47
|
-
if (parsed.hostname.includes("*")) continue
|
|
48
|
-
|
|
49
|
-
// Skip root domains or directories (no actual script/style file)
|
|
50
|
-
const path = parsed.pathname
|
|
51
|
-
if (path === "/" || path === "" || path.endsWith("/")) continue
|
|
52
|
-
|
|
53
|
-
externalUrls.push(resource)
|
|
54
|
-
} catch {
|
|
55
|
-
// Skip invalid URLs
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (externalUrls.length > 0) {
|
|
62
|
-
// 2. Fetch and Hash External Scripts
|
|
63
|
-
const results = await Promise.all(
|
|
64
|
-
externalUrls.map(async (url) => {
|
|
65
|
-
try {
|
|
66
|
-
const response = await fetch(url)
|
|
67
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
68
|
-
|
|
69
|
-
const buffer = await response.arrayBuffer()
|
|
70
|
-
const hash = createHash("sha384").update(Buffer.from(buffer)).digest("base64")
|
|
71
|
-
|
|
72
|
-
sriManifest[url] = `sha384-${hash}`
|
|
73
|
-
return { url, success: true }
|
|
74
|
-
} catch (err: unknown) {
|
|
75
|
-
const message = err instanceof Error ? err.message : String(err)
|
|
76
|
-
return { url, success: false, error: message }
|
|
77
|
-
}
|
|
78
|
-
})
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
const succeeded = results.filter((r) => r.success).map((r) => r.url)
|
|
82
|
-
const failed = results.filter((r) => !r.success)
|
|
83
|
-
|
|
84
|
-
if (failed.length === 0) {
|
|
85
|
-
logger.info(
|
|
86
|
-
`SRI Discovery: successfully hashed ${succeeded.length} external resource(s).\n` +
|
|
87
|
-
` Succeeded:\n` +
|
|
88
|
-
succeeded.map((url) => ` - ${url}`).join("\n")
|
|
89
|
-
)
|
|
90
|
-
} else {
|
|
91
|
-
const succeededBlock =
|
|
92
|
-
succeeded.length > 0
|
|
93
|
-
? ` Succeeded:\n` + succeeded.map((url) => ` - ${url}`).join("\n")
|
|
94
|
-
: ""
|
|
95
|
-
const failedBlock =
|
|
96
|
-
` Failed:\n` + failed.map((f) => ` - ${f.url} (${f.error})`).join("\n")
|
|
97
|
-
|
|
98
|
-
const parts = [
|
|
99
|
-
`SRI Discovery: hashed ${succeeded.length}/${results.length} external resource(s).`,
|
|
100
|
-
succeededBlock,
|
|
101
|
-
failedBlock
|
|
102
|
-
].filter(Boolean)
|
|
103
|
-
|
|
104
|
-
logger.warn(parts.join("\n\n"))
|
|
105
|
-
}
|
|
106
|
-
} else {
|
|
107
|
-
logger.info("SRI Discovery: no external resources found to hash.")
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// 3. Provide hashes to the runtime via Vite's virtual module
|
|
112
|
-
const virtualModuleId = "virtual:sri-manifest"
|
|
113
|
-
const resolvedVirtualModuleId = "\0" + virtualModuleId
|
|
114
|
-
|
|
115
|
-
updateConfig({
|
|
116
|
-
vite: {
|
|
117
|
-
plugins: [
|
|
118
|
-
{
|
|
119
|
-
name: "vite-plugin-sri-manifest",
|
|
120
|
-
resolveId(id: string) {
|
|
121
|
-
if (id === virtualModuleId) return resolvedVirtualModuleId
|
|
122
|
-
return null
|
|
123
|
-
},
|
|
124
|
-
load(id: string) {
|
|
125
|
-
if (id === resolvedVirtualModuleId) {
|
|
126
|
-
return `export const manifest = ${JSON.stringify(sriManifest)};`
|
|
127
|
-
}
|
|
128
|
-
return null
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
]
|
|
132
|
-
}
|
|
133
|
-
})
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
package/src/middleware/index.ts
DELETED
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
import type { APIContext, MiddlewareNext } from "astro"
|
|
2
|
-
import { defineMiddleware } from "astro:middleware"
|
|
3
|
-
|
|
4
|
-
// @ts-ignore virtual module resolved by SRI Vite plugin
|
|
5
|
-
import { manifest as sriManifest } from "virtual:sri-manifest"
|
|
6
|
-
|
|
7
|
-
function isManifestRecord(obj: unknown): obj is Record<string, string> {
|
|
8
|
-
return typeof obj === "object" && obj !== null && !Array.isArray(obj)
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const manifest: Record<string, string> = isManifestRecord(sriManifest) ? sriManifest : {}
|
|
12
|
-
|
|
13
|
-
declare const HTMLRewriter: any
|
|
14
|
-
|
|
15
|
-
function assertManifest(obj: unknown): asserts obj is Record<string, string> {
|
|
16
|
-
if (!obj || typeof obj !== "object") throw new Error("Expected manifest")
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// Assert and pre-compile regular expressions once at startup
|
|
20
|
-
assertManifest(manifest)
|
|
21
|
-
|
|
22
|
-
const manifestRegexes = Object.entries(manifest).map(([url, hash]) => ({
|
|
23
|
-
hash,
|
|
24
|
-
scriptRegex: new RegExp(`(<script[^>]+src=["']${url}["'][^>]*)(/?>)`, "g"),
|
|
25
|
-
linkRegex: new RegExp(`(<link[^>]+href=["']${url}["'][^>]*)(/?>)`, "g")
|
|
26
|
-
}))
|
|
27
|
-
|
|
28
|
-
function addSecurityHeaders(response: Response, origin?: string): Response {
|
|
29
|
-
try {
|
|
30
|
-
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
31
|
-
response.headers.set("X-Content-Type-Options", "nosniff")
|
|
32
|
-
response.headers.set("X-Frame-Options", "DENY")
|
|
33
|
-
response.headers.set("Cross-Origin-Resource-Policy", "same-origin")
|
|
34
|
-
response.headers.set(
|
|
35
|
-
"Strict-Transport-Security",
|
|
36
|
-
"max-age=31536000; includeSubDomains; preload"
|
|
37
|
-
)
|
|
38
|
-
response.headers.set(
|
|
39
|
-
"Permissions-Policy",
|
|
40
|
-
"camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
|
41
|
-
)
|
|
42
|
-
response.headers.set("No-Vary-Search", 'except=("q" "search" "locale"), params=?1')
|
|
43
|
-
if (origin) {
|
|
44
|
-
response.headers.set(
|
|
45
|
-
"Link",
|
|
46
|
-
[
|
|
47
|
-
`<${origin}/sitemap-index.xml>; rel="sitemap"`,
|
|
48
|
-
`<${origin}/llms.txt>; rel="llms"`,
|
|
49
|
-
`<${origin}/.well-known/api-catalog>; rel="api-catalog"`,
|
|
50
|
-
`<${origin}/rss.xml>; rel="alternate"; type="application/rss+xml"`
|
|
51
|
-
].join(", ")
|
|
52
|
-
)
|
|
53
|
-
}
|
|
54
|
-
return response
|
|
55
|
-
} catch {
|
|
56
|
-
const headers = new Headers(response.headers)
|
|
57
|
-
headers.set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
58
|
-
headers.set("X-Content-Type-Options", "nosniff")
|
|
59
|
-
headers.set("X-Frame-Options", "DENY")
|
|
60
|
-
headers.set("Cross-Origin-Resource-Policy", "same-origin")
|
|
61
|
-
headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
|
62
|
-
headers.set(
|
|
63
|
-
"Permissions-Policy",
|
|
64
|
-
"camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
|
65
|
-
)
|
|
66
|
-
headers.set("No-Vary-Search", 'except=("q" "search" "locale"), params=?1')
|
|
67
|
-
if (origin) {
|
|
68
|
-
headers.set(
|
|
69
|
-
"Link",
|
|
70
|
-
[
|
|
71
|
-
`<${origin}/sitemap-index.xml>; rel="sitemap"`,
|
|
72
|
-
`<${origin}/llms.txt>; rel="llms"`,
|
|
73
|
-
`<${origin}/.well-known/api-catalog>; rel="api-catalog"`,
|
|
74
|
-
`<${origin}/rss.xml>; rel="alternate"; type="application/rss+xml"`
|
|
75
|
-
].join(", ")
|
|
76
|
-
)
|
|
77
|
-
}
|
|
78
|
-
return new Response(response.body, {
|
|
79
|
-
status: response.status,
|
|
80
|
-
statusText: response.statusText,
|
|
81
|
-
headers
|
|
82
|
-
})
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Security Middleware: Injects SRI integrity attributes and security headers.
|
|
88
|
-
*/
|
|
89
|
-
export const security = defineMiddleware(async (context: APIContext, next: MiddlewareNext) => {
|
|
90
|
-
// HTTP to HTTPS redirect only in production based on x-forwarded-proto
|
|
91
|
-
const isProd = import.meta.env.PROD
|
|
92
|
-
const isDev = import.meta.env.DEV
|
|
93
|
-
const proto = context.request.headers.get("x-forwarded-proto")
|
|
94
|
-
if (isProd && proto === "http") {
|
|
95
|
-
const httpsUrl = context.request.url.replace(/^http:/, "https:")
|
|
96
|
-
return Response.redirect(httpsUrl, 301)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const response = await next()
|
|
100
|
-
const contentType = response.headers.get("content-type") || ""
|
|
101
|
-
|
|
102
|
-
// Early return for non-HTML responses (avoid body parsing/corruption/overhead)
|
|
103
|
-
if (!contentType.includes("text/html")) {
|
|
104
|
-
return addSecurityHeaders(response, context.url.origin)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (isDev) {
|
|
108
|
-
const cleanHeaders = new Headers(response.headers)
|
|
109
|
-
cleanHeaders.delete("content-security-policy")
|
|
110
|
-
let modifiedHtml = await response.text()
|
|
111
|
-
modifiedHtml = modifiedHtml.replace(
|
|
112
|
-
/<meta\s[^>]*http-equiv\s*=\s*["']?content-security-policy[^>]*>/gi,
|
|
113
|
-
""
|
|
114
|
-
)
|
|
115
|
-
return addSecurityHeaders(
|
|
116
|
-
new Response(modifiedHtml, {
|
|
117
|
-
status: response.status,
|
|
118
|
-
statusText: response.statusText,
|
|
119
|
-
headers: cleanHeaders
|
|
120
|
-
}),
|
|
121
|
-
context.url.origin
|
|
122
|
-
)
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Generate a per-request nonce. Cloudflare reads 'nonce-*' from the CSP header and
|
|
126
|
-
// automatically applies it to any scripts it injects (e.g. challenge-platform).
|
|
127
|
-
// The nonce is also stamped on every <script> tag so Astro's own scripts still load.
|
|
128
|
-
const nonce = crypto.randomUUID().replace(/-/g, "")
|
|
129
|
-
|
|
130
|
-
let cspHeader = response.headers.get("content-security-policy")
|
|
131
|
-
const newHeaders = new Headers(response.headers)
|
|
132
|
-
|
|
133
|
-
if (cspHeader) {
|
|
134
|
-
// Unescape HTML entities (like ' or ' to ') that Astro's native CSP generation mistakenly puts in the header
|
|
135
|
-
cspHeader = cspHeader
|
|
136
|
-
.replace(/�*39;/g, "'")
|
|
137
|
-
.replace(/�*27;/gi, "'")
|
|
138
|
-
.replace(/"/g, '"')
|
|
139
|
-
.replace(/&/g, "&")
|
|
140
|
-
|
|
141
|
-
// Inject the nonce into script-src (additive alongside existing hashes)
|
|
142
|
-
cspHeader = cspHeader.replace(/(script-src\s)/i, `$1'nonce-${nonce}' `)
|
|
143
|
-
|
|
144
|
-
newHeaders.set("content-security-policy", cspHeader)
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (typeof HTMLRewriter !== "undefined") {
|
|
148
|
-
let rewriter = new HTMLRewriter()
|
|
149
|
-
|
|
150
|
-
// Only register handlers if there are items to hash
|
|
151
|
-
if (manifestRegexes.length > 0) {
|
|
152
|
-
rewriter = rewriter
|
|
153
|
-
.on("script[src]", {
|
|
154
|
-
element(el: any) {
|
|
155
|
-
const src = el.getAttribute("src")
|
|
156
|
-
if (src && manifest[src]) {
|
|
157
|
-
el.setAttribute("integrity", manifest[src])
|
|
158
|
-
el.setAttribute("crossorigin", "anonymous")
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
})
|
|
162
|
-
.on('link[rel="stylesheet"][href]', {
|
|
163
|
-
element(el: any) {
|
|
164
|
-
const href = el.getAttribute("href")
|
|
165
|
-
if (href && manifest[href]) {
|
|
166
|
-
el.setAttribute("integrity", manifest[href])
|
|
167
|
-
el.setAttribute("crossorigin", "anonymous")
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
})
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Stamp nonce on every <script> tag so Astro's own scripts are still allowed
|
|
174
|
-
rewriter = rewriter.on("script", {
|
|
175
|
-
element(el: any) {
|
|
176
|
-
el.setAttribute("nonce", nonce)
|
|
177
|
-
}
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
// Inject a <meta name="csp-nonce"> so client JS can reliably read the
|
|
181
|
-
// current page's nonce (especially after View Transition navigations).
|
|
182
|
-
rewriter = rewriter.on("head", {
|
|
183
|
-
element(el: any) {
|
|
184
|
-
el.append(`<meta name="csp-nonce" content="${nonce}">`, { html: true })
|
|
185
|
-
}
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
rewriter = rewriter.on('meta[http-equiv="content-security-policy" i]', {
|
|
189
|
-
element(el: any) {
|
|
190
|
-
el.remove()
|
|
191
|
-
}
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
return addSecurityHeaders(
|
|
195
|
-
rewriter.transform(
|
|
196
|
-
new Response(response.body, {
|
|
197
|
-
status: response.status,
|
|
198
|
-
statusText: response.statusText,
|
|
199
|
-
headers: newHeaders
|
|
200
|
-
})
|
|
201
|
-
),
|
|
202
|
-
context.url.origin
|
|
203
|
-
)
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
let modifiedHtml = await response.text()
|
|
207
|
-
|
|
208
|
-
// Remove Astro-injected CSP meta tag to prevent duplicate CSP headers and HTML escaping errors
|
|
209
|
-
modifiedHtml = modifiedHtml.replace(
|
|
210
|
-
/<meta\s[^>]*http-equiv\s*=\s*["']?content-security-policy[^>]*>/gi,
|
|
211
|
-
""
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
// Inject a <meta name="csp-nonce"> into <head> so client JS can reliably
|
|
215
|
-
// read the current page's nonce after View Transition navigations.
|
|
216
|
-
modifiedHtml = modifiedHtml.replace(
|
|
217
|
-
/(<head(?:\s[^>]*)?>)/i,
|
|
218
|
-
`$1<meta name="csp-nonce" content="${nonce}">`
|
|
219
|
-
)
|
|
220
|
-
|
|
221
|
-
// Stamp nonce on every <script> tag (regex fallback path)
|
|
222
|
-
modifiedHtml = modifiedHtml.replace(
|
|
223
|
-
/<script(\s[^>]*)?>/gi,
|
|
224
|
-
(_match, attrs = "") => `<script${attrs} nonce="${nonce}">`
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
// Use pre-compiled regexes
|
|
228
|
-
for (const { hash, scriptRegex, linkRegex } of manifestRegexes) {
|
|
229
|
-
if (!hash) continue
|
|
230
|
-
|
|
231
|
-
modifiedHtml = modifiedHtml.replace(
|
|
232
|
-
scriptRegex,
|
|
233
|
-
`$1 integrity="${hash}" crossorigin="anonymous"$2`
|
|
234
|
-
)
|
|
235
|
-
|
|
236
|
-
modifiedHtml = modifiedHtml.replace(
|
|
237
|
-
linkRegex,
|
|
238
|
-
`$1 integrity="${hash}" crossorigin="anonymous"$2`
|
|
239
|
-
)
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
return addSecurityHeaders(
|
|
243
|
-
new Response(modifiedHtml, {
|
|
244
|
-
status: response.status,
|
|
245
|
-
statusText: response.statusText,
|
|
246
|
-
headers: newHeaders
|
|
247
|
-
}),
|
|
248
|
-
context.url.origin
|
|
249
|
-
)
|
|
250
|
-
})
|