@rimelight/security 0.0.5 → 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.
@@ -0,0 +1,3 @@
1
+ import { t as SecurityOptions } from "../types-CY5GDm3Z.mjs";
2
+ import { t as buildCspHeader } from "../csp-DM1NVenC.mjs";
3
+ export { SecurityOptions, buildCspHeader };
@@ -0,0 +1,2 @@
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 };
@@ -0,0 +1,22 @@
1
+ import { t as SecurityOptions } from "../types-CY5GDm3Z.mjs";
2
+ //#region src/middleware/security.d.ts
3
+ /**
4
+ * Universal Security Middleware for Hono and Web Standards (Fetch API).
5
+ * Injects security headers, CSP with per-request nonce, and SRI attributes.
6
+ */
7
+ declare function security(options?: SecurityOptions): (c: any, next: any) => Promise<Response | void>;
8
+ //#endregion
9
+ //#region src/middleware/dev-only.d.ts
10
+ /**
11
+ * Hono middleware that blocks any path containing `/dev/` in non-development environments. Mount
12
+ * this early in your Hono app (before API routes and Astro pages) so it covers both `/dev/` page
13
+ * routes and `/api/dev/` API routes in a single place.
14
+ *
15
+ * @example
16
+ * // fetch.ts
17
+ * import { devOnly } from "@rimelight/security/middleware"
18
+ * app.use(devOnly)
19
+ */
20
+ declare const devOnly: (c: any, next: any) => Promise<Response>;
21
+ //#endregion
22
+ export { devOnly, security };
@@ -0,0 +1,146 @@
1
+ import { t as buildCspHeader } from "../csp-DATUs3cP.mjs";
2
+ import { manifest } from "virtual:sri-manifest";
3
+ import { config } from "virtual:rimelight-security-config";
4
+ //#region src/middleware/security.ts
5
+ function isManifestRecord(obj) {
6
+ return typeof obj === "object" && obj !== null && !Array.isArray(obj);
7
+ }
8
+ function isConfigRecord(obj) {
9
+ return typeof obj === "object" && obj !== null && !Array.isArray(obj);
10
+ }
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
+ });
36
+ }
37
+ /**
38
+ * Universal Security Middleware for Hono and Web Standards (Fetch API).
39
+ * Injects security headers, CSP with per-request nonce, and SRI attributes.
40
+ */
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
+ }
111
+ let modifiedHtml = await response.text();
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, {
122
+ status: response.status,
123
+ statusText: response.statusText,
124
+ headers: response.headers
125
+ }), mergedOptions, origin, nonce);
126
+ return c.res;
127
+ };
128
+ }
129
+ //#endregion
130
+ //#region src/middleware/dev-only.ts
131
+ /**
132
+ * Hono middleware that blocks any path containing `/dev/` in non-development environments. Mount
133
+ * this early in your Hono app (before API routes and Astro pages) so it covers both `/dev/` page
134
+ * routes and `/api/dev/` API routes in a single place.
135
+ *
136
+ * @example
137
+ * // fetch.ts
138
+ * import { devOnly } from "@rimelight/security/middleware"
139
+ * app.use(devOnly)
140
+ */
141
+ const devOnly = async (c, next) => {
142
+ if (!import.meta.env.DEV && c.req.path.includes("/dev/")) return c.notFound();
143
+ return next();
144
+ };
145
+ //#endregion
146
+ export { devOnly, security };
@@ -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.5",
3
+ "version": "0.0.7",
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
- "src"
19
+ "dist",
20
+ "src/components"
20
21
  ],
21
22
  "type": "module",
22
23
  "exports": {
23
- ".": "./src/integrations/index.ts",
24
- "./middleware": "./src/middleware/index.ts",
25
- "./config": "./src/config/index.ts",
24
+ ".": {
25
+ "types": "./dist/index.d.mts",
26
+ "import": "./dist/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.3",
34
- "astro": "7.2.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
- w.trustedTypes.createPolicy('default', {
14
- createHTML: function(input: string) { return input; },
15
- createScript: function(input: string) { return input; },
16
- createScriptURL: function(input: string) { return input; }
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>
@@ -1 +0,0 @@
1
- export * from "./security"
@@ -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"
@@ -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
- }
@@ -1,16 +0,0 @@
1
- /**
2
- * Hono middleware that blocks any path containing `/dev/` in non-development environments. Mount
3
- * this early in your Hono app (before API routes and Astro pages) so it covers both `/dev/` page
4
- * routes and `/api/dev/` API routes in a single place.
5
- *
6
- * @example
7
- * // fetch.ts
8
- * import { devOnly } from "@rimelight/security/middleware"
9
- * app.use(devOnly)
10
- */
11
- export const devOnly = async (c: any, next: any): Promise<Response> => {
12
- if (!import.meta.env.DEV && c.req.path.includes("/dev/")) {
13
- return c.notFound()
14
- }
15
- return next()
16
- }
@@ -1,2 +0,0 @@
1
- export * from "./security"
2
- export * from "./dev-only"
@@ -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 &#39; or &#x27; to ') that Astro's native CSP generation mistakenly puts in the header
135
- cspHeader = cspHeader
136
- .replace(/&#0*39;/g, "'")
137
- .replace(/&#x0*27;/gi, "'")
138
- .replace(/&quot;/g, '"')
139
- .replace(/&amp;/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
- })