@rimelight/security 0.0.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rimelight Entertainment
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@rimelight/security",
3
+ "version": "0.0.2",
4
+ "private": false,
5
+ "description": "Rimelight Entertainment Security Middleware and Utilities.",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/integrations/index.ts",
9
+ "./middleware": "./src/middleware/index.ts",
10
+ "./config": "./src/config/index.ts",
11
+ "./*": "./src/*"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "devDependencies": {
17
+ "astro": "7.1.0",
18
+ "typescript": "6.0.3"
19
+ },
20
+ "peerDependencies": {
21
+ "astro": ">=7.0.1"
22
+ },
23
+ "packageManager": "pnpm@11.9.0"
24
+ }
@@ -0,0 +1 @@
1
+ export * from "./security"
@@ -0,0 +1,263 @@
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
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /// <reference types="astro/client" />
2
+
3
+ declare module "virtual:sri-manifest" {
4
+ export const manifest: Record<string, string>
5
+ }
@@ -0,0 +1 @@
1
+ export * from "./sri"
@@ -0,0 +1,137 @@
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
+ }
@@ -0,0 +1 @@
1
+ export * from "./security"
@@ -0,0 +1,255 @@
1
+ import { defineMiddleware } from "astro:middleware"
2
+ // @ts-ignore - virtual module generated by the SRI integration
3
+ import { manifest } from "virtual:sri-manifest"
4
+
5
+ declare const HTMLRewriter: any
6
+
7
+ function assertManifest(obj: unknown): asserts obj is Record<string, string> {
8
+ if (!obj || typeof obj !== "object") throw new Error("Expected manifest")
9
+ }
10
+
11
+ // Assert and pre-compile regular expressions once at startup
12
+ assertManifest(manifest)
13
+
14
+ const manifestRegexes = Object.entries(manifest).map(([url, hash]) => ({
15
+ hash,
16
+ scriptRegex: new RegExp(`(<script[^>]+src=["']${url}["'][^>]*)(/?>)`, "g"),
17
+ linkRegex: new RegExp(`(<link[^>]+href=["']${url}["'][^>]*)(/?>)`, "g")
18
+ }))
19
+
20
+ function addSecurityHeaders(response: Response, origin?: string): Response {
21
+ try {
22
+ response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin")
23
+ response.headers.set("X-Content-Type-Options", "nosniff")
24
+ response.headers.set("X-Frame-Options", "DENY")
25
+ response.headers.set("Cross-Origin-Resource-Policy", "same-origin")
26
+ response.headers.set(
27
+ "Strict-Transport-Security",
28
+ "max-age=31536000; includeSubDomains; preload"
29
+ )
30
+ response.headers.set(
31
+ "Permissions-Policy",
32
+ "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
33
+ )
34
+ response.headers.set("No-Vary-Search", 'except=("q" "search" "locale"), params=?1')
35
+ if (origin) {
36
+ response.headers.set(
37
+ "Link",
38
+ [
39
+ `<${origin}/sitemap-index.xml>; rel="sitemap"`,
40
+ `<${origin}/llms.txt>; rel="llms"`,
41
+ `<${origin}/.well-known/api-catalog>; rel="api-catalog"`,
42
+ `<${origin}/rss.xml>; rel="alternate"; type="application/rss+xml"`
43
+ ].join(", ")
44
+ )
45
+ }
46
+ return response
47
+ } catch {
48
+ const headers = new Headers(response.headers)
49
+ headers.set("Referrer-Policy", "strict-origin-when-cross-origin")
50
+ headers.set("X-Content-Type-Options", "nosniff")
51
+ headers.set("X-Frame-Options", "DENY")
52
+ headers.set("Cross-Origin-Resource-Policy", "same-origin")
53
+ headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
54
+ headers.set(
55
+ "Permissions-Policy",
56
+ "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
57
+ )
58
+ headers.set("No-Vary-Search", 'except=("q" "search" "locale"), params=?1')
59
+ if (origin) {
60
+ headers.set(
61
+ "Link",
62
+ [
63
+ `<${origin}/sitemap-index.xml>; rel="sitemap"`,
64
+ `<${origin}/llms.txt>; rel="llms"`,
65
+ `<${origin}/.well-known/api-catalog>; rel="api-catalog"`,
66
+ `<${origin}/rss.xml>; rel="alternate"; type="application/rss+xml"`
67
+ ].join(", ")
68
+ )
69
+ }
70
+ return new Response(response.body, {
71
+ status: response.status,
72
+ statusText: response.statusText,
73
+ headers
74
+ })
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Security Middleware: Injects SRI integrity attributes and security headers.
80
+ */
81
+ export const security = defineMiddleware(async (context, next) => {
82
+ // HTTP to HTTPS redirect only in production
83
+ if (import.meta.env.PROD && context.request.url.startsWith("http://")) {
84
+ const httpsUrl = context.request.url.replace(/^http:/, "https:")
85
+ return Response.redirect(httpsUrl, 301)
86
+ }
87
+
88
+ const response = await next()
89
+ const contentType = response.headers.get("content-type") || ""
90
+
91
+ // Early return for non-HTML responses (avoid body parsing/corruption/overhead)
92
+ if (!contentType.includes("text/html")) {
93
+ return addSecurityHeaders(response, context.url.origin)
94
+ }
95
+
96
+ if (import.meta.env.DEV) {
97
+ const cleanHeaders = new Headers(response.headers)
98
+ cleanHeaders.delete("content-security-policy")
99
+ let modifiedHtml = await response.text()
100
+ modifiedHtml = modifiedHtml.replace(
101
+ /<meta\s[^>]*http-equiv\s*=\s*["']?content-security-policy[^>]*>/gi,
102
+ ""
103
+ )
104
+ return addSecurityHeaders(
105
+ new Response(modifiedHtml, {
106
+ status: response.status,
107
+ statusText: response.statusText,
108
+ headers: cleanHeaders
109
+ }),
110
+ context.url.origin
111
+ )
112
+ }
113
+
114
+ let cspHeader = response.headers.get("content-security-policy")
115
+ const newHeaders = new Headers(response.headers)
116
+
117
+ if (cspHeader) {
118
+ // Unescape HTML entities (like &#39; or &#x27; to ') that Astro's native CSP generation mistakenly puts in the header
119
+ cspHeader = cspHeader
120
+ .replace(/&#0*39;/g, "'")
121
+ .replace(/&#x0*27;/gi, "'")
122
+ .replace(/&quot;/g, '"')
123
+ .replace(/&amp;/g, "&")
124
+ newHeaders.set("content-security-policy", cspHeader)
125
+ }
126
+
127
+ const nonceValue = cspHeader ? crypto.randomUUID().replace(/-/g, "") : null
128
+
129
+ if (cspHeader && nonceValue) {
130
+ let newCsp = cspHeader
131
+ if (newCsp.includes("script-src ")) {
132
+ newCsp = newCsp.replace("script-src ", `script-src 'nonce-${nonceValue}' `)
133
+ } else {
134
+ newCsp = newCsp + `; script-src 'nonce-${nonceValue}'`
135
+ }
136
+ if (newCsp.includes("style-src ")) {
137
+ newCsp = newCsp.replace("style-src ", `style-src 'nonce-${nonceValue}' `)
138
+ } else {
139
+ newCsp = newCsp + `; style-src 'nonce-${nonceValue}'`
140
+ }
141
+ newHeaders.set("content-security-policy", newCsp)
142
+ }
143
+
144
+ if (typeof HTMLRewriter !== "undefined") {
145
+ let rewriter = new HTMLRewriter()
146
+
147
+ // Only register handlers if there are items to hash
148
+ if (manifestRegexes.length > 0) {
149
+ rewriter = rewriter
150
+ .on("script[src]", {
151
+ element(el: any) {
152
+ const src = el.getAttribute("src")
153
+ if (src && manifest[src]) {
154
+ el.setAttribute("integrity", manifest[src])
155
+ el.setAttribute("crossorigin", "anonymous")
156
+ }
157
+ }
158
+ })
159
+ .on('link[rel="stylesheet"][href]', {
160
+ element(el: any) {
161
+ const href = el.getAttribute("href")
162
+ if (href && manifest[href]) {
163
+ el.setAttribute("integrity", manifest[href])
164
+ el.setAttribute("crossorigin", "anonymous")
165
+ }
166
+ }
167
+ })
168
+ }
169
+
170
+ rewriter = rewriter.on('meta[http-equiv="content-security-policy" i]', {
171
+ element(el: any) {
172
+ el.remove()
173
+ }
174
+ })
175
+
176
+ if (nonceValue) {
177
+ rewriter = rewriter
178
+ .on("head", {
179
+ element(el: any) {
180
+ el.append(`<meta name="csp-nonce" content="${nonceValue}">`, { html: true })
181
+ }
182
+ })
183
+ .on("script", {
184
+ element(el: any) {
185
+ if (!el.getAttribute("nonce")) {
186
+ el.setAttribute("nonce", nonceValue)
187
+ }
188
+ }
189
+ })
190
+ .on("style", {
191
+ element(el: any) {
192
+ el.setAttribute("nonce", nonceValue)
193
+ }
194
+ })
195
+ }
196
+
197
+ return addSecurityHeaders(
198
+ rewriter.transform(
199
+ new Response(response.body, {
200
+ status: response.status,
201
+ statusText: response.statusText,
202
+ headers: newHeaders
203
+ })
204
+ ),
205
+ context.url.origin
206
+ )
207
+ }
208
+
209
+ let modifiedHtml = await response.text()
210
+
211
+ // Remove Astro-injected CSP meta tag to prevent duplicate CSP headers and HTML escaping errors
212
+ modifiedHtml = modifiedHtml.replace(
213
+ /<meta\s[^>]*http-equiv\s*=\s*["']?content-security-policy[^>]*>/gi,
214
+ ""
215
+ )
216
+
217
+ // Use pre-compiled regexes
218
+ for (const { hash, scriptRegex, linkRegex } of manifestRegexes) {
219
+ if (!hash) continue
220
+
221
+ modifiedHtml = modifiedHtml.replace(
222
+ scriptRegex,
223
+ `$1 integrity="${hash}" crossorigin="anonymous"$2`
224
+ )
225
+
226
+ modifiedHtml = modifiedHtml.replace(
227
+ linkRegex,
228
+ `$1 integrity="${hash}" crossorigin="anonymous"$2`
229
+ )
230
+ }
231
+
232
+ if (nonceValue) {
233
+ modifiedHtml = modifiedHtml.replace(
234
+ /<head([^>]*)>/i,
235
+ `<head$1>\n<meta name="csp-nonce" content="${nonceValue}">`
236
+ )
237
+ modifiedHtml = modifiedHtml.replace(
238
+ /<script(?![^>]*nonce=)([^>]*)>/gi,
239
+ `<script$1 nonce="${nonceValue}">`
240
+ )
241
+ modifiedHtml = modifiedHtml.replace(
242
+ /<style(?![^>]*nonce=)([^>]*)>/gi,
243
+ `<style$1 nonce="${nonceValue}">`
244
+ )
245
+ }
246
+
247
+ return addSecurityHeaders(
248
+ new Response(modifiedHtml, {
249
+ status: response.status,
250
+ statusText: response.statusText,
251
+ headers: newHeaders
252
+ }),
253
+ context.url.origin
254
+ )
255
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "astro/tsconfigs/strictest",
3
+ "include": [".astro/types.d.ts", "**/*"],
4
+ "exclude": ["dist"],
5
+ "compilerOptions": {
6
+ "plugins": [
7
+ {
8
+ "name": "@astrojs/ts-plugin"
9
+ }
10
+ ],
11
+ "paths": {
12
+ "@/*": ["./src/*"]
13
+ },
14
+ "allowArbitraryExtensions": true,
15
+ "jsx": "preserve"
16
+ }
17
+ }