@rimelight/security 0.0.18 → 0.0.20

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.
@@ -0,0 +1,288 @@
1
+ import type { SecurityOptions } from "../types"
2
+ import { buildCspHeader } from "../csp"
3
+
4
+ import { manifest as sriManifest } from "virtual:sri-manifest"
5
+ import { config as pluginConfig } from "virtual:rimelight-security-config"
6
+
7
+ function isManifestRecord(obj: unknown): obj is Record<string, string> {
8
+ return typeof obj === "object" && obj !== null && !Array.isArray(obj)
9
+ }
10
+
11
+ function isConfigRecord(obj: unknown): obj is SecurityOptions {
12
+ return typeof obj === "object" && obj !== null && !Array.isArray(obj)
13
+ }
14
+
15
+ const manifest: Record<string, string> = isManifestRecord(sriManifest) ? sriManifest : {}
16
+ const defaultPluginConfig: SecurityOptions = isConfigRecord(pluginConfig) ? pluginConfig : {}
17
+
18
+ declare const HTMLRewriter: any
19
+
20
+ function applySecurityHeaders(
21
+ response: Response,
22
+ options: SecurityOptions,
23
+ origin?: string,
24
+ nonce?: string,
25
+ reportOnlyOptions?: SecurityOptions
26
+ ): Response {
27
+ const cspHeader = buildCspHeader(options, nonce)
28
+ const reportOnlyHeader = reportOnlyOptions
29
+ ? buildCspHeader(
30
+ {
31
+ ...reportOnlyOptions,
32
+ directives: { ...reportOnlyOptions.directives, "upgrade-insecure-requests": false }
33
+ },
34
+ nonce
35
+ )
36
+ : undefined
37
+
38
+ const headers = new Headers(response.headers)
39
+ headers.set("Referrer-Policy", "strict-origin-when-cross-origin")
40
+ headers.set("X-Content-Type-Options", "nosniff")
41
+ headers.set("X-Frame-Options", "DENY")
42
+
43
+ if (options.crossOriginResourcePolicy !== false) {
44
+ headers.set(
45
+ "Cross-Origin-Resource-Policy",
46
+ typeof options.crossOriginResourcePolicy === "string"
47
+ ? options.crossOriginResourcePolicy
48
+ : "same-origin"
49
+ )
50
+ }
51
+
52
+ if (options.crossOriginOpenerPolicy !== false) {
53
+ headers.set(
54
+ "Cross-Origin-Opener-Policy",
55
+ typeof options.crossOriginOpenerPolicy === "string"
56
+ ? options.crossOriginOpenerPolicy
57
+ : "same-origin"
58
+ )
59
+ }
60
+
61
+ if (options.crossOriginEmbedderPolicy !== false) {
62
+ headers.set(
63
+ "Cross-Origin-Embedder-Policy",
64
+ typeof options.crossOriginEmbedderPolicy === "string"
65
+ ? options.crossOriginEmbedderPolicy
66
+ : "credentialless"
67
+ )
68
+ }
69
+
70
+ headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
71
+ headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
72
+ headers.set("No-Vary-Search", 'except=("q" "search" "locale"), params=?1')
73
+
74
+ if (cspHeader) {
75
+ headers.set("Content-Security-Policy", cspHeader)
76
+ }
77
+
78
+ if (reportOnlyHeader) {
79
+ headers.set("Content-Security-Policy-Report-Only", reportOnlyHeader)
80
+ }
81
+
82
+ if (origin) {
83
+ headers.set(
84
+ "Link",
85
+ [
86
+ `<${origin}/sitemap-index.xml>; rel="sitemap"`,
87
+ `<${origin}/llms.txt>; rel="llms"`,
88
+ `<${origin}/.well-known/api-catalog>; rel="api-catalog"`,
89
+ `<${origin}/rss.xml>; rel="alternate"; type="application/rss+xml"`
90
+ ].join(", ")
91
+ )
92
+ }
93
+
94
+ if (options.headers) {
95
+ for (const [key, value] of Object.entries(options.headers)) {
96
+ headers.set(key, value)
97
+ }
98
+ }
99
+
100
+ return new Response(response.body, {
101
+ status: response.status,
102
+ statusText: response.statusText,
103
+ headers
104
+ })
105
+ }
106
+
107
+ /**
108
+ * Universal Security Middleware for Hono and Web Standards (Fetch API). Injects security headers,
109
+ * CSP with per-request nonce, and SRI attributes.
110
+ */
111
+ export function security(options: SecurityOptions = {}) {
112
+ const mergedOptions: SecurityOptions = {
113
+ ...defaultPluginConfig,
114
+ ...options,
115
+ allowedDomains: [
116
+ ...(defaultPluginConfig.allowedDomains || []),
117
+ ...(options.allowedDomains || [])
118
+ ],
119
+ imgSrc: [...(defaultPluginConfig.imgSrc || []), ...(options.imgSrc || [])],
120
+ connectSrc: [...(defaultPluginConfig.connectSrc || []), ...(options.connectSrc || [])],
121
+ frameSrc: [...(defaultPluginConfig.frameSrc || []), ...(options.frameSrc || [])],
122
+ scriptResources: [
123
+ ...(defaultPluginConfig.scriptResources || []),
124
+ ...(options.scriptResources || [])
125
+ ],
126
+ styleResources: [
127
+ ...(defaultPluginConfig.styleResources || []),
128
+ ...(options.styleResources || [])
129
+ ],
130
+ directives: {
131
+ ...defaultPluginConfig.directives,
132
+ ...options.directives
133
+ },
134
+ headers: {
135
+ ...defaultPluginConfig.headers,
136
+ ...options.headers
137
+ }
138
+ }
139
+
140
+ return async (c: any, next: any): Promise<Response | void> => {
141
+ const isProd = import.meta.env?.PROD ?? process.env["NODE_ENV"] === "production"
142
+ const isDev = import.meta.env?.DEV ?? process.env["NODE_ENV"] === "development"
143
+
144
+ const req = c.req?.raw || c.request || c.req
145
+ const proto = req?.headers?.get("x-forwarded-proto")
146
+ if (isProd && proto === "http" && req?.url) {
147
+ const httpsUrl = req.url.replace(/^http:/, "https:")
148
+ return Response.redirect(httpsUrl, 301)
149
+ }
150
+
151
+ await next()
152
+
153
+ const response: Response = c.res
154
+ if (!response) return
155
+
156
+ const contentType = response.headers.get("content-type") || ""
157
+ const origin = req?.url ? new URL(req.url).origin : undefined
158
+
159
+ // For non-HTML responses (e.g. JSON API, static images), apply headers only
160
+ if (!contentType.includes("text/html")) {
161
+ c.res = applySecurityHeaders(response, mergedOptions, origin)
162
+ return c.res
163
+ }
164
+
165
+ // In dev mode, keep active CSP relaxed for Vite/Toolbar.
166
+ // In Report-Only, enforce strict prod external origins (img-src, connect-src, frame-src)
167
+ // while permitting dev-internal inline styles/scripts so the browser doesn't spam for Vite HMR.
168
+ if (isDev) {
169
+ const devDirectives: Record<string, string[] | boolean> = {
170
+ ...mergedOptions.directives,
171
+ "default-src": false,
172
+ "script-src": [
173
+ "'self'",
174
+ "'unsafe-inline'",
175
+ "'unsafe-eval'",
176
+ "https:",
177
+ "http:",
178
+ "ws:",
179
+ "wss:"
180
+ ],
181
+ "script-src-attr": ["'unsafe-inline'"],
182
+ "style-src": ["'self'", "'unsafe-inline'", "https:", "http:"],
183
+ "connect-src": ["'self'", "ws:", "wss:", "http:", "https:"],
184
+ "img-src": ["'self'", "data:", "blob:", "https:", "http:"]
185
+ }
186
+
187
+ // Prod simulation: check strict domain / asset permissions without tripping on Vite HMR inline CSS
188
+ const reportOnlySimulation: SecurityOptions = {
189
+ ...mergedOptions,
190
+ directives: {
191
+ ...mergedOptions.directives,
192
+ "script-src-attr": ["'none'"],
193
+ "style-src": ["'self'", "'unsafe-inline'"],
194
+ "script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"]
195
+ }
196
+ }
197
+
198
+ c.res = applySecurityHeaders(
199
+ response,
200
+ { ...mergedOptions, directives: devDirectives },
201
+ origin,
202
+ undefined,
203
+ reportOnlySimulation
204
+ )
205
+ return c.res
206
+ }
207
+
208
+ // Generate dynamic per-request nonce
209
+ const nonce = crypto.randomUUID().replace(/-/g, "")
210
+
211
+ // Use HTMLRewriter when available (Cloudflare Workers / Edge runtime)
212
+ if (typeof HTMLRewriter !== "undefined") {
213
+ let rewriter = new HTMLRewriter()
214
+
215
+ rewriter = rewriter
216
+ .on("script[src]", {
217
+ element(el: any) {
218
+ const src = el.getAttribute("src")
219
+ if (src && manifest[src]) {
220
+ el.setAttribute("integrity", manifest[src])
221
+ el.setAttribute("crossorigin", "anonymous")
222
+ }
223
+ }
224
+ })
225
+ .on('link[rel="stylesheet"][href]', {
226
+ element(el: any) {
227
+ const href = el.getAttribute("href")
228
+ if (href && manifest[href]) {
229
+ el.setAttribute("integrity", manifest[href])
230
+ el.setAttribute("crossorigin", "anonymous")
231
+ }
232
+ }
233
+ })
234
+ .on("script", {
235
+ element(el: any) {
236
+ el.setAttribute("nonce", nonce)
237
+ }
238
+ })
239
+ .on("head", {
240
+ element(el: any) {
241
+ el.append(`<meta name="csp-nonce" content="${nonce}">`, { html: true })
242
+ }
243
+ })
244
+
245
+ c.res = applySecurityHeaders(rewriter.transform(response), mergedOptions, origin, nonce)
246
+ return c.res
247
+ }
248
+
249
+ // Fallback path (Node / standard streams)
250
+ let modifiedHtml = await response.text()
251
+
252
+ modifiedHtml = modifiedHtml.replace(
253
+ /(<head(?:\s[^>]*)?>)/i,
254
+ `$1<meta name="csp-nonce" content="${nonce}">`
255
+ )
256
+
257
+ modifiedHtml = modifiedHtml.replace(
258
+ /<script(\s[^>]*)?>/gi,
259
+ (_match, attrs = "") => `<script${attrs} nonce="${nonce}">`
260
+ )
261
+
262
+ for (const [url, hash] of Object.entries(manifest)) {
263
+ if (!hash) continue
264
+ const scriptRegex = new RegExp(`(<script[^>]+src=["']${url}["'][^>]*)(/?>)`, "g")
265
+ const linkRegex = new RegExp(`(<link[^>]+href=["']${url}["'][^>]*)(/?>)`, "g")
266
+ modifiedHtml = modifiedHtml.replace(
267
+ scriptRegex,
268
+ `$1 integrity="${hash}" crossorigin="anonymous"$2`
269
+ )
270
+ modifiedHtml = modifiedHtml.replace(
271
+ linkRegex,
272
+ `$1 integrity="${hash}" crossorigin="anonymous"$2`
273
+ )
274
+ }
275
+
276
+ c.res = applySecurityHeaders(
277
+ new Response(modifiedHtml, {
278
+ status: response.status,
279
+ statusText: response.statusText,
280
+ headers: response.headers
281
+ }),
282
+ mergedOptions,
283
+ origin,
284
+ nonce
285
+ )
286
+ return c.res
287
+ }
288
+ }
@@ -0,0 +1,5 @@
1
+ ---
2
+ import ConstructionPage from "../components/ConstructionPage.astro";
3
+ ---
4
+
5
+ <ConstructionPage />
package/src/types.ts ADDED
@@ -0,0 +1,207 @@
1
+ export interface SecurityOptions {
2
+ /**
3
+ * The base domain name for this project (e.g. "idantity.me") used to autoconfigure standard
4
+ * allowed domains and img-src CDN targets.
5
+ */
6
+ domain?: string
7
+
8
+ /**
9
+ * Additional Allowed Domains for origin checking or cross-domain rules
10
+ */
11
+ allowedDomains?: string[]
12
+
13
+ /**
14
+ * Additional sources to allow for img-src directive
15
+ */
16
+ imgSrc?: string[]
17
+
18
+ /**
19
+ * Additional sources to allow for connect-src directive
20
+ */
21
+ connectSrc?: string[]
22
+
23
+ /**
24
+ * Additional sources to allow for frame-src directive
25
+ */
26
+ frameSrc?: string[]
27
+
28
+ /**
29
+ * Additional script resources / origins (e.g. "https://betterlytics.io")
30
+ */
31
+ scriptResources?: string[]
32
+
33
+ /**
34
+ * Additional style resources / origins
35
+ */
36
+ styleResources?: string[]
37
+
38
+ /**
39
+ * Enables the 'require-trusted-types-for' directive
40
+ */
41
+ trustedTypes?: boolean
42
+
43
+ /**
44
+ * Cross-Origin-Embedder-Policy header value. Allowed values: "require-corp", "credentialless",
45
+ * "unsafe-none" or false to disable. Defaults to "credentialless".
46
+ */
47
+ crossOriginEmbedderPolicy?:
48
+ | "require-corp"
49
+ | "credentialless"
50
+ | "unsafe-none"
51
+ | (string & {})
52
+ | false
53
+
54
+ /**
55
+ * Cross-Origin-Opener-Policy header value. Allowed values: "same-origin",
56
+ * "same-origin-allow-popups", "noopener-allow-popups", "unsafe-none" or false to disable.
57
+ * Defaults to "same-origin".
58
+ */
59
+ crossOriginOpenerPolicy?:
60
+ | "same-origin"
61
+ | "same-origin-allow-popups"
62
+ | "noopener-allow-popups"
63
+ | "unsafe-none"
64
+ | (string & {})
65
+ | false
66
+
67
+ /**
68
+ * Cross-Origin-Resource-Policy header value. Allowed values: "same-origin", "same-site",
69
+ * "cross-origin" or false to disable. Defaults to "same-origin".
70
+ */
71
+ crossOriginResourcePolicy?: "same-origin" | "same-site" | "cross-origin" | (string & {}) | false
72
+
73
+ /**
74
+ * Granular CSP directive overrides. Values are arrays of directive tokens, e.g.: {
75
+ * "frame-ancestors": ["'self'"] }
76
+ */
77
+ directives?: Record<string, string[] | string | boolean>
78
+
79
+ /**
80
+ * Custom headers to set on every response or overrides
81
+ */
82
+ headers?: Record<string, string>
83
+ }
84
+
85
+ export interface ConstructionOptions {
86
+ /**
87
+ * Environment variable key for construction mode flag ("true" | "false"). Defaults to
88
+ * "CONSTRUCTION_MODE".
89
+ */
90
+ modeEnvKey?: string
91
+
92
+ /**
93
+ * Environment variable key for the passphrase secret. Defaults to "CONSTRUCTION_PASSPHRASE".
94
+ */
95
+ passphraseEnvKey?: string
96
+
97
+ /**
98
+ * Explicit passphrase override (if not reading from env).
99
+ */
100
+ passphrase?: string
101
+
102
+ /**
103
+ * Cookie name used for guest authorization token. Defaults to "rimelight-construction-guest".
104
+ */
105
+ cookieName?: string
106
+
107
+ /**
108
+ * Cookie max age in seconds when "remember me" is enabled. Defaults to 7 days (604,800 seconds).
109
+ */
110
+ cookieMaxAge?: number
111
+
112
+ /**
113
+ * List of supported locales or custom resolver.
114
+ */
115
+ locales?: string[]
116
+
117
+ /**
118
+ * Custom locale extractor from request path.
119
+ */
120
+ getLocale?: (path: string) => string
121
+
122
+ /**
123
+ * Custom path for the construction page. Defaults to "/construction".
124
+ */
125
+ constructionPath?: string
126
+
127
+ /**
128
+ * API endpoint to automatically handle guest sign-in. Set to `false` to disable automatic API
129
+ * interception. Defaults to "/api/construction-guest".
130
+ */
131
+ apiPath?: string | false
132
+
133
+ /**
134
+ * Additional routes / prefixes to whitelist and skip gating.
135
+ */
136
+ whitelist?: (string | RegExp)[] | ((path: string) => boolean)
137
+
138
+ /**
139
+ * Custom authorization predicate. If returns true, request is allowed through. Defaults to
140
+ * checking `c.get("session") || await isConstructionGuest(c, options)`.
141
+ */
142
+ isAuthorized?: (c: any) => boolean | Promise<boolean>
143
+
144
+ /**
145
+ * Custom handler when unauthorized. Defaults to redirecting to
146
+ * `/${locale}/construction?redirect=${encodeURIComponent(path)}`.
147
+ */
148
+ onUnauthorized?: (
149
+ c: any,
150
+ meta: { locale: string; path: string; redirectUrl: string }
151
+ ) => Response | Promise<Response>
152
+ }
153
+
154
+ export interface RateLimiterBinding {
155
+ limit(options: { key: string }): Promise<{ success: boolean }>
156
+ }
157
+
158
+ export interface RateLimitOptions {
159
+ /**
160
+ * Name of the Cloudflare Rate Limiter binding on `c.env` / `env`.
161
+ *
162
+ * @default "MY_RATE_LIMITER"
163
+ */
164
+ bindingName?: string
165
+
166
+ /**
167
+ * Direct rate limiter instance or binding resolver.
168
+ */
169
+ limiter?: (c: any) => RateLimiterBinding | undefined
170
+
171
+ /**
172
+ * Routes / path prefixes or a custom predicate to check if the request should be rate limited.
173
+ * Can be an array of strings/RegExp or a matcher function `(c: any) => boolean`.
174
+ *
175
+ * @default ["/auth/sign-in", "/auth/sign-up", "/api/upload", "/api/chat"]
176
+ */
177
+ routes?: (string | RegExp)[] | ((c: any) => boolean)
178
+
179
+ /**
180
+ * Function to extract the rate limit key from the context (e.g., client IP, user ID, API key).
181
+ *
182
+ * @default c.req.header("CF-Connecting-IP") || c.req.header("x-forwarded-for") || "unknown"
183
+ */
184
+ keyGenerator?: (c: any) => string | Promise<string>
185
+
186
+ /**
187
+ * Retry-After header value in seconds.
188
+ *
189
+ * @default 60
190
+ */
191
+ retryAfter?: number
192
+
193
+ /**
194
+ * Behavior when no rate limiter binding exists (e.g., in local development without mock binding).
195
+ *
196
+ * - "pass": Fail open (call `next()`)
197
+ * - "block": Fail closed (return 429/500)
198
+ *
199
+ * @default "pass"
200
+ */
201
+ fallback?: "pass" | "block"
202
+
203
+ /**
204
+ * Custom handler when the rate limit is exceeded.
205
+ */
206
+ onRateLimited?: (c: any, retryAfter: number) => Response | Promise<Response>
207
+ }
@@ -0,0 +1,16 @@
1
+ declare module "virtual:rimelight-security-config" {
2
+ export const config: import("./types").SecurityOptions
3
+ }
4
+
5
+ declare module "virtual:sri-manifest" {
6
+ export const manifest: Record<string, string>
7
+ }
8
+
9
+ declare module "virtual:rimelight/seo" {
10
+ export const siteConfig: Record<string, any>
11
+ export const SEO_LOCALES: Array<{ code: string; label?: string; [key: string]: any }> | undefined
12
+ export const PRIVATE_PATH_PREFIXES: string[] | undefined
13
+ export const seoConfig: Record<string, any>
14
+ const defaultExport: Record<string, any>
15
+ export default defaultExport
16
+ }
package/src/vite.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { createHash } from "node:crypto"
2
+
3
+ import type { SecurityOptions } from "./types"
4
+
5
+ export interface RimelightSecurityPlugin {
6
+ name: string
7
+ enforce?: "pre" | "post"
8
+ resolveId?: (id: string) => string | null | undefined
9
+ load?: (id: string) => string | null | undefined
10
+ buildStart?: () => Promise<void> | void
11
+ generateBundle?: (options: any, bundle: Record<string, any>) => Promise<void> | void
12
+ [key: string]: any
13
+ }
14
+
15
+ export interface SecurityPluginOptions extends SecurityOptions {
16
+ /**
17
+ * Additional external script/style URLs to fetch and hash for SRI at build time.
18
+ */
19
+ resources?: string[]
20
+ }
21
+
22
+ const DEFAULT_RESOURCES = ["https://betterlytics.io/analytics.js"]
23
+
24
+ function hashContent(buffer: Buffer | ArrayBuffer | Uint8Array | string): string {
25
+ const hash = createHash("sha384")
26
+ .update(buffer as any)
27
+ .digest("base64")
28
+ return `sha384-${hash}`
29
+ }
30
+
31
+ /**
32
+ * Pure Vite Security & SRI Plugin. 1. Accepts framework-agnostic security options (domain, imgSrc,
33
+ * etc.) and exposes virtual:rimelight-security-config. 2. Fetches and hashes external 3rd-party
34
+ * scripts/styles at build time. 3. Emits SRI hashes for locally bundled JS/CSS chunks
35
+ * (Nuxt-security style). 4. Exposes virtual:sri-manifest with a unified URL -> hash mapping.
36
+ */
37
+ export function security(options: SecurityPluginOptions = {}): RimelightSecurityPlugin {
38
+ const sriManifest: Record<string, string> = {}
39
+ const externalUrls = Array.from(new Set([...DEFAULT_RESOURCES, ...(options.resources || [])]))
40
+
41
+ const sriVirtualId = "virtual:sri-manifest"
42
+ const resolvedSriVirtualId = "\0" + sriVirtualId
43
+
44
+ const configVirtualId = "virtual:rimelight-security-config"
45
+ const resolvedConfigVirtualId = "\0" + configVirtualId
46
+
47
+ return {
48
+ name: "vite-plugin-rimelight-security",
49
+ enforce: "post",
50
+
51
+ async buildStart() {
52
+ // 1. Fetch & Hash External Resources (3rd-party SRI)
53
+ if (process.env["NODE_ENV"] === "development") return
54
+
55
+ await Promise.all(
56
+ externalUrls.map(async (url) => {
57
+ if (!url.startsWith("https://")) return
58
+ try {
59
+ const parsed = new URL(url)
60
+ if (parsed.hostname.includes("*")) return
61
+ const pathname = parsed.pathname
62
+ if (pathname === "/" || pathname === "" || pathname.endsWith("/")) return
63
+
64
+ const response = await fetch(url)
65
+ if (!response.ok) return
66
+ const buffer = await response.arrayBuffer()
67
+ sriManifest[url] = hashContent(buffer)
68
+ } catch {
69
+ // Silently ignore unreachable external resources during build
70
+ }
71
+ })
72
+ )
73
+ },
74
+
75
+ generateBundle(_outputOptions, bundle) {
76
+ // 2. Hash Emitted Bundle Chunks & Assets (1st-party SRI)
77
+ for (const [fileName, chunk] of Object.entries(bundle)) {
78
+ if (!fileName.endsWith(".js") && !fileName.endsWith(".css")) continue
79
+
80
+ let content: string | Uint8Array | undefined
81
+ if (chunk.type === "chunk") {
82
+ content = chunk.code
83
+ } else if (chunk.type === "asset") {
84
+ content = chunk.source
85
+ }
86
+
87
+ if (content) {
88
+ const hash = hashContent(content)
89
+ // Store both absolute leading slash path and relative file name
90
+ sriManifest[`/${fileName.replace(/^\/+/, "")}`] = hash
91
+ sriManifest[fileName] = hash
92
+ }
93
+ }
94
+ },
95
+
96
+ resolveId(id: string) {
97
+ if (id === sriVirtualId) return resolvedSriVirtualId
98
+ if (id === configVirtualId) return resolvedConfigVirtualId
99
+ return null
100
+ },
101
+
102
+ load(id: string) {
103
+ if (id === resolvedSriVirtualId) {
104
+ return `export const manifest = ${JSON.stringify(sriManifest)};`
105
+ }
106
+ if (id === resolvedConfigVirtualId) {
107
+ return `export const config = ${JSON.stringify(options)};`
108
+ }
109
+ return null
110
+ }
111
+ }
112
+ }
113
+
114
+ export { security as sri }