@rimelight/security 0.0.6 → 0.0.7

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/README.md CHANGED
@@ -14,6 +14,6 @@
14
14
  - **`@rimelight/cms`**: Enterprise content management, block rendering, and wiki engine.
15
15
  - **`@rimelight/docs`**: Documentation components and utilities.
16
16
  - **`@rimelight/i18n`**: Internationalization and localization tools.
17
- - **`@rimelight/security`**: Astro security integration (CSP, SRI, and more).
17
+ - **`@rimelight/security`**: Universal security & SRI package with Vite plugin and Hono/Fetch middleware (dual SRI, CSP, and headers).
18
18
  - **`@rimelight/seo`**: SEO utilities including sitemap, robots, and meta components.
19
19
  - **`@rimelight/ui`**: Our component library used in all our web projects.
@@ -1,48 +1,3 @@
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 };
1
+ import { t as SecurityOptions } from "../types-CY5GDm3Z.mjs";
2
+ import { t as buildCspHeader } from "../csp-DM1NVenC.mjs";
3
+ export { SecurityOptions, buildCspHeader };
@@ -1,109 +1,2 @@
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 };
1
+ import { t as buildCspHeader } from "../csp-DATUs3cP.mjs";
2
+ export { buildCspHeader };
@@ -0,0 +1,63 @@
1
+ //#region src/csp.ts
2
+ /**
3
+ * Builds the Content-Security-Policy header string dynamically for a given request nonce.
4
+ */
5
+ function buildCspHeader(options = {}, nonce) {
6
+ const domain = options.domain || "";
7
+ const defaultDirectives = {
8
+ "default-src": ["'none'"],
9
+ "img-src": Array.from(/* @__PURE__ */ new Set([
10
+ "'self'",
11
+ "data:",
12
+ ...domain ? [`https://cdn.${domain}`] : [],
13
+ "https://i3.ytimg.com",
14
+ "https://www.youtube.com",
15
+ "https://www.youtube-nocookie.com",
16
+ ...options.imgSrc || []
17
+ ])),
18
+ "font-src": ["'self'"],
19
+ "connect-src": Array.from(/* @__PURE__ */ new Set([
20
+ "'self'",
21
+ "https://cloudflareinsights.com",
22
+ ...options.connectSrc || []
23
+ ])),
24
+ "frame-ancestors": ["'none'"],
25
+ "frame-src": Array.from(/* @__PURE__ */ new Set([
26
+ "https://www.youtube.com",
27
+ "https://www.youtube-nocookie.com",
28
+ ...options.frameSrc || []
29
+ ])),
30
+ "script-src": Array.from(/* @__PURE__ */ new Set([
31
+ "'self'",
32
+ ...nonce ? [`'nonce-${nonce}'`] : [],
33
+ "https://static.cloudflareinsights.com",
34
+ "https://betterlytics.io/analytics.js",
35
+ "'inline-speculation-rules'",
36
+ "'sha256-ZuMSxilKU+4KIM8LWna4lqBEKzd6WaiAjz6gamhm7zM='",
37
+ ...options.scriptResources || []
38
+ ])),
39
+ "style-src": Array.from(/* @__PURE__ */ new Set([
40
+ "'self'",
41
+ "'sha256-SKuaOGnks7NAUq37nvw1PfGEE0lOAs5ERbiYRbGFIbw='",
42
+ ...options.styleResources || []
43
+ ])),
44
+ "base-uri": ["'self'"],
45
+ "form-action": ["'self'"],
46
+ "object-src": ["'none'"],
47
+ "manifest-src": ["'self'"],
48
+ "upgrade-insecure-requests": []
49
+ };
50
+ if (options.trustedTypes) defaultDirectives["require-trusted-types-for"] = ["'script'"];
51
+ if (options.directives) {
52
+ for (const [key, value] of Object.entries(options.directives)) if (value === false) delete defaultDirectives[key];
53
+ else if (value === true) defaultDirectives[key] = [];
54
+ else if (Array.isArray(value)) defaultDirectives[key] = value;
55
+ else if (typeof value === "string") defaultDirectives[key] = value.split(" ").filter(Boolean);
56
+ }
57
+ return Object.entries(defaultDirectives).map(([directive, values]) => {
58
+ if (!values || values.length === 0) return directive;
59
+ return `${directive} ${values.join(" ")}`;
60
+ }).join("; ");
61
+ }
62
+ //#endregion
63
+ export { buildCspHeader as t };
@@ -0,0 +1,8 @@
1
+ import { t as SecurityOptions } from "./types-CY5GDm3Z.mjs";
2
+ //#region src/csp.d.ts
3
+ /**
4
+ * Builds the Content-Security-Policy header string dynamically for a given request nonce.
5
+ */
6
+ declare function buildCspHeader(options?: SecurityOptions, nonce?: string): string;
7
+ //#endregion
8
+ export { buildCspHeader as t };
@@ -0,0 +1,28 @@
1
+ import { t as SecurityOptions } from "./types-CY5GDm3Z.mjs";
2
+ import { t as buildCspHeader } from "./csp-DM1NVenC.mjs";
3
+ //#region src/vite.d.ts
4
+ interface RimelightSecurityPlugin {
5
+ name: string;
6
+ enforce?: "pre" | "post";
7
+ resolveId?: (id: string) => string | null | undefined;
8
+ load?: (id: string) => string | null | undefined;
9
+ buildStart?: () => Promise<void> | void;
10
+ generateBundle?: (options: any, bundle: Record<string, any>) => Promise<void> | void;
11
+ [key: string]: any;
12
+ }
13
+ interface SecurityPluginOptions extends SecurityOptions {
14
+ /**
15
+ * Additional external script/style URLs to fetch and hash for SRI at build time.
16
+ */
17
+ resources?: string[];
18
+ }
19
+ /**
20
+ * Pure Vite Security & SRI Plugin.
21
+ * 1. Accepts framework-agnostic security options (domain, imgSrc, etc.) and exposes virtual:rimelight-security-config.
22
+ * 2. Fetches and hashes external 3rd-party scripts/styles at build time.
23
+ * 3. Emits SRI hashes for locally bundled JS/CSS chunks (Nuxt-security style).
24
+ * 4. Exposes virtual:sri-manifest with a unified URL -> hash mapping.
25
+ */
26
+ declare function security(options?: SecurityPluginOptions): RimelightSecurityPlugin;
27
+ //#endregion
28
+ export { RimelightSecurityPlugin, SecurityOptions, SecurityPluginOptions, buildCspHeader, security, security as sri };
package/dist/index.mjs ADDED
@@ -0,0 +1,67 @@
1
+ import { t as buildCspHeader } from "./csp-DATUs3cP.mjs";
2
+ import { createHash } from "node:crypto";
3
+ //#region src/vite.ts
4
+ const DEFAULT_RESOURCES = ["https://betterlytics.io/analytics.js"];
5
+ function hashContent(buffer) {
6
+ return `sha384-${createHash("sha384").update(buffer).digest("base64")}`;
7
+ }
8
+ /**
9
+ * Pure Vite Security & SRI Plugin.
10
+ * 1. Accepts framework-agnostic security options (domain, imgSrc, etc.) and exposes virtual:rimelight-security-config.
11
+ * 2. Fetches and hashes external 3rd-party scripts/styles at build time.
12
+ * 3. Emits SRI hashes for locally bundled JS/CSS chunks (Nuxt-security style).
13
+ * 4. Exposes virtual:sri-manifest with a unified URL -> hash mapping.
14
+ */
15
+ function security(options = {}) {
16
+ const sriManifest = {};
17
+ const externalUrls = Array.from(/* @__PURE__ */ new Set([...DEFAULT_RESOURCES, ...options.resources || []]));
18
+ const sriVirtualId = "virtual:sri-manifest";
19
+ const resolvedSriVirtualId = "\0" + sriVirtualId;
20
+ const configVirtualId = "virtual:rimelight-security-config";
21
+ const resolvedConfigVirtualId = "\0" + configVirtualId;
22
+ return {
23
+ name: "vite-plugin-rimelight-security",
24
+ enforce: "post",
25
+ async buildStart() {
26
+ if (process.env.NODE_ENV === "development") return;
27
+ await Promise.all(externalUrls.map(async (url) => {
28
+ if (!url.startsWith("https://")) return;
29
+ try {
30
+ const parsed = new URL(url);
31
+ if (parsed.hostname.includes("*")) return;
32
+ const pathname = parsed.pathname;
33
+ if (pathname === "/" || pathname === "" || pathname.endsWith("/")) return;
34
+ const response = await fetch(url);
35
+ if (!response.ok) return;
36
+ const buffer = await response.arrayBuffer();
37
+ sriManifest[url] = hashContent(buffer);
38
+ } catch {}
39
+ }));
40
+ },
41
+ generateBundle(_outputOptions, bundle) {
42
+ for (const [fileName, chunk] of Object.entries(bundle)) {
43
+ if (!fileName.endsWith(".js") && !fileName.endsWith(".css")) continue;
44
+ let content;
45
+ if (chunk.type === "chunk") content = chunk.code;
46
+ else if (chunk.type === "asset") content = chunk.source;
47
+ if (content) {
48
+ const hash = hashContent(content);
49
+ sriManifest[`/${fileName.replace(/^\/+/, "")}`] = hash;
50
+ sriManifest[fileName] = hash;
51
+ }
52
+ }
53
+ },
54
+ resolveId(id) {
55
+ if (id === sriVirtualId) return resolvedSriVirtualId;
56
+ if (id === configVirtualId) return resolvedConfigVirtualId;
57
+ return null;
58
+ },
59
+ load(id) {
60
+ if (id === resolvedSriVirtualId) return `export const manifest = ${JSON.stringify(sriManifest)};`;
61
+ if (id === resolvedConfigVirtualId) return `export const config = ${JSON.stringify(options)};`;
62
+ return null;
63
+ }
64
+ };
65
+ }
66
+ //#endregion
67
+ export { buildCspHeader, security, security as sri };
@@ -1,8 +1,10 @@
1
+ import { t as SecurityOptions } from "../types-CY5GDm3Z.mjs";
1
2
  //#region src/middleware/security.d.ts
2
3
  /**
3
- * Security Middleware: Injects SRI integrity attributes and security headers.
4
+ * Universal Security Middleware for Hono and Web Standards (Fetch API).
5
+ * Injects security headers, CSP with per-request nonce, and SRI attributes.
4
6
  */
5
- declare const security: import("astro").MiddlewareHandler;
7
+ declare function security(options?: SecurityOptions): (c: any, next: any) => Promise<Response | void>;
6
8
  //#endregion
7
9
  //#region src/middleware/dev-only.d.ts
8
10
  /**
@@ -1,134 +1,131 @@
1
- import { defineMiddleware } from "astro:middleware";
1
+ import { t as buildCspHeader } from "../csp-DATUs3cP.mjs";
2
2
  import { manifest } from "virtual:sri-manifest";
3
+ import { config } from "virtual:rimelight-security-config";
3
4
  //#region src/middleware/security.ts
4
5
  function isManifestRecord(obj) {
5
6
  return typeof obj === "object" && obj !== null && !Array.isArray(obj);
6
7
  }
7
- const manifest$1 = isManifestRecord(manifest) ? manifest : {};
8
- function assertManifest(obj) {
9
- if (!obj || typeof obj !== "object") throw new Error("Expected manifest");
8
+ function isConfigRecord(obj) {
9
+ return typeof obj === "object" && obj !== null && !Array.isArray(obj);
10
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
- }
11
+ const manifest$1 = isManifestRecord(manifest) ? manifest : {};
12
+ const defaultPluginConfig = isConfigRecord(config) ? config : {};
13
+ function applySecurityHeaders(response, options, origin, nonce) {
14
+ const cspHeader = buildCspHeader(options, nonce);
15
+ const headers = new Headers(response.headers);
16
+ headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
17
+ headers.set("X-Content-Type-Options", "nosniff");
18
+ headers.set("X-Frame-Options", "DENY");
19
+ headers.set("Cross-Origin-Resource-Policy", "same-origin");
20
+ headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
21
+ headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
22
+ headers.set("No-Vary-Search", "except=(\"q\" \"search\" \"locale\"), params=?1");
23
+ if (cspHeader) headers.set("Content-Security-Policy", cspHeader);
24
+ if (origin) headers.set("Link", [
25
+ `<${origin}/sitemap-index.xml>; rel="sitemap"`,
26
+ `<${origin}/llms.txt>; rel="llms"`,
27
+ `<${origin}/.well-known/api-catalog>; rel="api-catalog"`,
28
+ `<${origin}/rss.xml>; rel="alternate"; type="application/rss+xml"`
29
+ ].join(", "));
30
+ if (options.headers) for (const [key, value] of Object.entries(options.headers)) headers.set(key, value);
31
+ return new Response(response.body, {
32
+ status: response.status,
33
+ statusText: response.statusText,
34
+ headers
35
+ });
54
36
  }
55
37
  /**
56
- * Security Middleware: Injects SRI integrity attributes and security headers.
38
+ * Universal Security Middleware for Hono and Web Standards (Fetch API).
39
+ * Injects security headers, CSP with per-request nonce, and SRI attributes.
57
40
  */
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");
41
+ function security(options = {}) {
42
+ const mergedOptions = {
43
+ ...defaultPluginConfig,
44
+ ...options,
45
+ allowedDomains: [...defaultPluginConfig.allowedDomains || [], ...options.allowedDomains || []],
46
+ imgSrc: [...defaultPluginConfig.imgSrc || [], ...options.imgSrc || []],
47
+ connectSrc: [...defaultPluginConfig.connectSrc || [], ...options.connectSrc || []],
48
+ frameSrc: [...defaultPluginConfig.frameSrc || [], ...options.frameSrc || []],
49
+ scriptResources: [...defaultPluginConfig.scriptResources || [], ...options.scriptResources || []],
50
+ styleResources: [...defaultPluginConfig.styleResources || [], ...options.styleResources || []],
51
+ directives: {
52
+ ...defaultPluginConfig.directives || {},
53
+ ...options.directives || {}
54
+ },
55
+ headers: {
56
+ ...defaultPluginConfig.headers || {},
57
+ ...options.headers || {}
58
+ }
59
+ };
60
+ return async (c, next) => {
61
+ const isProd = import.meta.env?.PROD ?? process.env.NODE_ENV === "production";
62
+ const isDev = import.meta.env?.DEV ?? process.env.NODE_ENV === "development";
63
+ const req = c.req?.raw || c.request || c.req;
64
+ const proto = req?.headers?.get("x-forwarded-proto");
65
+ if (isProd && proto === "http" && req?.url) {
66
+ const httpsUrl = req.url.replace(/^http:/, "https:");
67
+ return Response.redirect(httpsUrl, 301);
68
+ }
69
+ await next();
70
+ const response = c.res;
71
+ if (!response) return;
72
+ const contentType = response.headers.get("content-type") || "";
73
+ const origin = req?.url ? new URL(req.url).origin : void 0;
74
+ if (!contentType.includes("text/html")) {
75
+ c.res = applySecurityHeaders(response, mergedOptions, origin);
76
+ return c.res;
77
+ }
78
+ if (isDev) {
79
+ c.res = applySecurityHeaders(response, {
80
+ ...mergedOptions,
81
+ directives: {
82
+ ...mergedOptions.directives,
83
+ "default-src": false
84
+ }
85
+ }, origin);
86
+ return c.res;
87
+ }
88
+ const nonce = crypto.randomUUID().replace(/-/g, "");
89
+ if (typeof HTMLRewriter !== "undefined") {
90
+ let rewriter = new HTMLRewriter();
91
+ rewriter = rewriter.on("script[src]", { element(el) {
92
+ const src = el.getAttribute("src");
93
+ if (src && manifest$1[src]) {
94
+ el.setAttribute("integrity", manifest$1[src]);
95
+ el.setAttribute("crossorigin", "anonymous");
96
+ }
97
+ } }).on("link[rel=\"stylesheet\"][href]", { element(el) {
98
+ const href = el.getAttribute("href");
99
+ if (href && manifest$1[href]) {
100
+ el.setAttribute("integrity", manifest$1[href]);
101
+ el.setAttribute("crossorigin", "anonymous");
102
+ }
103
+ } }).on("script", { element(el) {
104
+ el.setAttribute("nonce", nonce);
105
+ } }).on("head", { element(el) {
106
+ el.append(`<meta name="csp-nonce" content="${nonce}">`, { html: true });
107
+ } });
108
+ c.res = applySecurityHeaders(rewriter.transform(response), mergedOptions, origin, nonce);
109
+ return c.res;
110
+ }
71
111
  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(/&#0*39;/g, "'").replace(/&#x0*27;/gi, "'").replace(/&quot;/g, "\"").replace(/&amp;/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
+ modifiedHtml = modifiedHtml.replace(/(<head(?:\s[^>]*)?>)/i, `$1<meta name="csp-nonce" content="${nonce}">`);
113
+ modifiedHtml = modifiedHtml.replace(/<script(\s[^>]*)?>/gi, (_match, attrs = "") => `<script${attrs} nonce="${nonce}">`);
114
+ for (const [url, hash] of Object.entries(manifest$1)) {
115
+ if (!hash) continue;
116
+ const scriptRegex = new RegExp(`(<script[^>]+src=["']${url}["'][^>]*)(/?>)`, "g");
117
+ const linkRegex = new RegExp(`(<link[^>]+href=["']${url}["'][^>]*)(/?>)`, "g");
118
+ modifiedHtml = modifiedHtml.replace(scriptRegex, `$1 integrity="${hash}" crossorigin="anonymous"$2`);
119
+ modifiedHtml = modifiedHtml.replace(linkRegex, `$1 integrity="${hash}" crossorigin="anonymous"$2`);
120
+ }
121
+ c.res = applySecurityHeaders(new Response(modifiedHtml, {
112
122
  status: response.status,
113
123
  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
- });
124
+ headers: response.headers
125
+ }), mergedOptions, origin, nonce);
126
+ return c.res;
127
+ };
128
+ }
132
129
  //#endregion
133
130
  //#region src/middleware/dev-only.ts
134
131
  /**
@@ -0,0 +1,47 @@
1
+ //#region src/types.d.ts
2
+ interface SecurityOptions {
3
+ /**
4
+ * The base domain name for this project (e.g. "idantity.me") used to autoconfigure
5
+ * standard allowed domains and img-src CDN targets.
6
+ */
7
+ domain?: string;
8
+ /**
9
+ * Additional Allowed Domains for origin checking or cross-domain rules
10
+ */
11
+ allowedDomains?: string[];
12
+ /**
13
+ * Additional sources to allow for img-src directive
14
+ */
15
+ imgSrc?: string[];
16
+ /**
17
+ * Additional sources to allow for connect-src directive
18
+ */
19
+ connectSrc?: string[];
20
+ /**
21
+ * Additional sources to allow for frame-src directive
22
+ */
23
+ frameSrc?: string[];
24
+ /**
25
+ * Additional script resources / origins (e.g. "https://betterlytics.io")
26
+ */
27
+ scriptResources?: string[];
28
+ /**
29
+ * Additional style resources / origins
30
+ */
31
+ styleResources?: string[];
32
+ /**
33
+ * Enables the 'require-trusted-types-for' directive
34
+ */
35
+ trustedTypes?: boolean;
36
+ /**
37
+ * Granular CSP directive overrides. Values are arrays of directive tokens, e.g.:
38
+ * { "frame-ancestors": ["'self'"] }
39
+ */
40
+ directives?: Record<string, string[] | string | boolean>;
41
+ /**
42
+ * Custom headers to set on every response or overrides
43
+ */
44
+ headers?: Record<string, string>;
45
+ }
46
+ //#endregion
47
+ export { SecurityOptions as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimelight/security",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "private": false,
5
5
  "description": "Rimelight Entertainment's Security Package",
6
6
  "homepage": "https://rimelight.com/docs",
@@ -22,8 +22,8 @@
22
22
  "type": "module",
23
23
  "exports": {
24
24
  ".": {
25
- "types": "./dist/integrations/index.d.mts",
26
- "import": "./dist/integrations/index.mjs"
25
+ "types": "./dist/index.d.mts",
26
+ "import": "./dist/index.mjs"
27
27
  },
28
28
  "./middleware": {
29
29
  "types": "./dist/middleware/index.d.mts",
@@ -1,9 +0,0 @@
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 };
@@ -1,81 +0,0 @@
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 };