@rimelight/security 0.0.19 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/index.d.mts +1 -1
- package/dist/csp.d.mts +1 -1
- package/dist/csp.mjs +14 -9
- package/dist/index.d.mts +4 -2
- package/dist/index.mjs +3 -1
- package/dist/integration.d.mts +26 -0
- package/dist/integration.mjs +20 -0
- package/dist/middleware/construction.d.mts +1 -1
- package/dist/middleware/ratelimit.d.mts +2 -2
- package/dist/middleware/security.d.mts +1 -1
- package/dist/turnstile.d.mts +53 -0
- package/dist/turnstile.mjs +92 -0
- package/dist/{types-Dcvwj-af.d.mts → types-CL5HooHU.d.mts} +5 -0
- package/dist/types.d.mts +1 -1
- package/dist/vite.d.mts +1 -1
- package/package.json +16 -3
- package/src/components/ConstructionPage.astro +166 -0
- package/src/components/ConstructionSignIn.astro +9 -9
- package/src/components/Turnstile.astro +94 -0
- package/src/config/index.ts +2 -0
- package/src/csp.ts +106 -0
- package/src/index.ts +6 -0
- package/src/integration.ts +51 -0
- package/src/layouts/ConstructionLayout.astro +50 -0
- package/src/middleware/construction.ts +315 -0
- package/src/middleware/dev-only.ts +16 -0
- package/src/middleware/index.ts +4 -0
- package/src/middleware/ratelimit.ts +120 -0
- package/src/middleware/security.ts +288 -0
- package/src/pages/construction.astro +5 -0
- package/src/turnstile.ts +172 -0
- package/src/types.ts +213 -0
- package/src/virtual.d.ts +16 -0
- package/src/vite.ts +114 -0
|
@@ -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
|
+
}
|
package/src/turnstile.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
export interface TurnstileVerifyOptions {
|
|
2
|
+
/**
|
|
3
|
+
* The response token provided by the Turnstile client-side widget (e.g. `cf-turnstile-response`).
|
|
4
|
+
*/
|
|
5
|
+
token?: string | null | undefined
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The secret key for Turnstile verification. Can be passed directly or read from `env`.
|
|
9
|
+
*/
|
|
10
|
+
secretKey?: string | undefined
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The Cloudflare environment or process environment object containing TURNSTILE_SECRET_KEY /
|
|
14
|
+
* TURNSTILE_SITE_KEY.
|
|
15
|
+
*/
|
|
16
|
+
env?: any
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The client's IP address (e.g. from `CF-Connecting-IP` or `Astro.clientAddress`).
|
|
20
|
+
*/
|
|
21
|
+
remoteIp?: string | undefined
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether to fail open (succeed) if keys are not configured or during network errors. Defaults to
|
|
25
|
+
* `true` (safe for local development).
|
|
26
|
+
*/
|
|
27
|
+
failOpen?: boolean | undefined
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Custom verify endpoint URL. Defaults to
|
|
31
|
+
* `https://challenges.cloudflare.com/turnstile/v0/siteverify`.
|
|
32
|
+
*/
|
|
33
|
+
endpoint?: string | undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TurnstileVerifyResult {
|
|
37
|
+
success: boolean
|
|
38
|
+
error?: string | undefined
|
|
39
|
+
errorCodes?: string[] | undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CloudflareVerifyResponse {
|
|
43
|
+
"success": boolean
|
|
44
|
+
"error-codes"?: string[]
|
|
45
|
+
"challenge_ts"?: string
|
|
46
|
+
"hostname"?: string
|
|
47
|
+
"action"?: string
|
|
48
|
+
"cdata"?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Checks if Turnstile keys are present in the provided environment or `process.env`.
|
|
53
|
+
*/
|
|
54
|
+
export function isTurnstileEnabled(env?: any): boolean {
|
|
55
|
+
const siteKey =
|
|
56
|
+
env?.TURNSTILE_SITE_KEY ||
|
|
57
|
+
(typeof process !== "undefined" ? process.env?.["TURNSTILE_SITE_KEY"] : undefined)
|
|
58
|
+
|
|
59
|
+
const secretKey =
|
|
60
|
+
env?.TURNSTILE_SECRET_KEY ||
|
|
61
|
+
(typeof process !== "undefined" ? process.env?.["TURNSTILE_SECRET_KEY"] : undefined)
|
|
62
|
+
|
|
63
|
+
return Boolean(siteKey && secretKey)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolves the Turnstile secret key from options or environment.
|
|
68
|
+
*/
|
|
69
|
+
export function getTurnstileSecretKey(options: TurnstileVerifyOptions): string | undefined {
|
|
70
|
+
if (options.secretKey) return options.secretKey
|
|
71
|
+
const env = options.env
|
|
72
|
+
return (
|
|
73
|
+
env?.TURNSTILE_SECRET_KEY ||
|
|
74
|
+
(typeof process !== "undefined" ? process.env?.["TURNSTILE_SECRET_KEY"] : undefined)
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolves the Turnstile site key from environment or process.env.
|
|
80
|
+
*/
|
|
81
|
+
export function getTurnstileSiteKey(env?: any): string | undefined {
|
|
82
|
+
return (
|
|
83
|
+
env?.TURNSTILE_SITE_KEY ||
|
|
84
|
+
(typeof process !== "undefined" ? process.env?.["TURNSTILE_SITE_KEY"] : undefined)
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Verifies a Cloudflare Turnstile token on the server. Supports both options object and classic
|
|
90
|
+
* signature `(token, env, remoteIp)`.
|
|
91
|
+
*/
|
|
92
|
+
export async function verifyTurnstile(
|
|
93
|
+
tokenOrOptions: string | null | undefined | TurnstileVerifyOptions,
|
|
94
|
+
legacyEnv?: any,
|
|
95
|
+
legacyRemoteIp?: string
|
|
96
|
+
): Promise<TurnstileVerifyResult> {
|
|
97
|
+
const options: TurnstileVerifyOptions =
|
|
98
|
+
typeof tokenOrOptions === "object" && tokenOrOptions !== null
|
|
99
|
+
? tokenOrOptions
|
|
100
|
+
: {
|
|
101
|
+
token: tokenOrOptions,
|
|
102
|
+
env: legacyEnv,
|
|
103
|
+
remoteIp: legacyRemoteIp
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const failOpen = options.failOpen ?? true
|
|
107
|
+
const token = options.token
|
|
108
|
+
|
|
109
|
+
// If Turnstile is not enabled (missing keys in env and no explicit secretKey), fail open for local dev
|
|
110
|
+
const secretKey = getTurnstileSecretKey(options)
|
|
111
|
+
if (!secretKey) {
|
|
112
|
+
if (failOpen) {
|
|
113
|
+
console.log(
|
|
114
|
+
"[Turnstile] Keys missing or not configured. Skipping verification (Failing Open for local dev)."
|
|
115
|
+
)
|
|
116
|
+
return { success: true }
|
|
117
|
+
}
|
|
118
|
+
return { success: false, error: "Turnstile secret key is not configured." }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!token) {
|
|
122
|
+
return { success: false, error: "Security check token is missing." }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const endpoint = options.endpoint || "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
|
127
|
+
const response = await fetch(endpoint, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "Content-Type": "application/json" },
|
|
130
|
+
body: JSON.stringify({
|
|
131
|
+
secret: secretKey,
|
|
132
|
+
response: token,
|
|
133
|
+
remoteip: options.remoteIp
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
if (failOpen) {
|
|
139
|
+
console.error(`[Turnstile] API returned status ${response.status}. Failing open.`)
|
|
140
|
+
return { success: true }
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
success: false,
|
|
144
|
+
error: `Cloudflare Turnstile API returned status ${response.status}`
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const data: CloudflareVerifyResponse = await response.json()
|
|
149
|
+
if (data.success) {
|
|
150
|
+
return { success: true }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const codes = data["error-codes"] || []
|
|
154
|
+
let msg = "Bot check verification failed."
|
|
155
|
+
if (codes.includes("timeout-or-duplicate")) {
|
|
156
|
+
msg = "Verification token expired or already used. Please refresh the security check."
|
|
157
|
+
} else if (codes.includes("invalid-input-response")) {
|
|
158
|
+
msg = "Invalid verification response. Please try again."
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { success: false, error: msg, errorCodes: codes }
|
|
162
|
+
} catch (err: any) {
|
|
163
|
+
console.error("[Turnstile Exception]", err)
|
|
164
|
+
if (failOpen) {
|
|
165
|
+
return { success: true }
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
success: false,
|
|
169
|
+
error: err?.message || "Turnstile verification encountered an exception."
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
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 Cloudflare Turnstile CSP allowances (challenges.cloudflare.com in script-src,
|
|
40
|
+
* frame-src, connect-src). Defaults to true.
|
|
41
|
+
*/
|
|
42
|
+
turnstile?: boolean
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Enables the 'require-trusted-types-for' directive
|
|
46
|
+
*/
|
|
47
|
+
trustedTypes?: boolean
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Cross-Origin-Embedder-Policy header value. Allowed values: "require-corp", "credentialless",
|
|
51
|
+
* "unsafe-none" or false to disable. Defaults to "credentialless".
|
|
52
|
+
*/
|
|
53
|
+
crossOriginEmbedderPolicy?:
|
|
54
|
+
| "require-corp"
|
|
55
|
+
| "credentialless"
|
|
56
|
+
| "unsafe-none"
|
|
57
|
+
| (string & {})
|
|
58
|
+
| false
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Cross-Origin-Opener-Policy header value. Allowed values: "same-origin",
|
|
62
|
+
* "same-origin-allow-popups", "noopener-allow-popups", "unsafe-none" or false to disable.
|
|
63
|
+
* Defaults to "same-origin".
|
|
64
|
+
*/
|
|
65
|
+
crossOriginOpenerPolicy?:
|
|
66
|
+
| "same-origin"
|
|
67
|
+
| "same-origin-allow-popups"
|
|
68
|
+
| "noopener-allow-popups"
|
|
69
|
+
| "unsafe-none"
|
|
70
|
+
| (string & {})
|
|
71
|
+
| false
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Cross-Origin-Resource-Policy header value. Allowed values: "same-origin", "same-site",
|
|
75
|
+
* "cross-origin" or false to disable. Defaults to "same-origin".
|
|
76
|
+
*/
|
|
77
|
+
crossOriginResourcePolicy?: "same-origin" | "same-site" | "cross-origin" | (string & {}) | false
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Granular CSP directive overrides. Values are arrays of directive tokens, e.g.: {
|
|
81
|
+
* "frame-ancestors": ["'self'"] }
|
|
82
|
+
*/
|
|
83
|
+
directives?: Record<string, string[] | string | boolean>
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Custom headers to set on every response or overrides
|
|
87
|
+
*/
|
|
88
|
+
headers?: Record<string, string>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ConstructionOptions {
|
|
92
|
+
/**
|
|
93
|
+
* Environment variable key for construction mode flag ("true" | "false"). Defaults to
|
|
94
|
+
* "CONSTRUCTION_MODE".
|
|
95
|
+
*/
|
|
96
|
+
modeEnvKey?: string
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Environment variable key for the passphrase secret. Defaults to "CONSTRUCTION_PASSPHRASE".
|
|
100
|
+
*/
|
|
101
|
+
passphraseEnvKey?: string
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Explicit passphrase override (if not reading from env).
|
|
105
|
+
*/
|
|
106
|
+
passphrase?: string
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Cookie name used for guest authorization token. Defaults to "rimelight-construction-guest".
|
|
110
|
+
*/
|
|
111
|
+
cookieName?: string
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Cookie max age in seconds when "remember me" is enabled. Defaults to 7 days (604,800 seconds).
|
|
115
|
+
*/
|
|
116
|
+
cookieMaxAge?: number
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* List of supported locales or custom resolver.
|
|
120
|
+
*/
|
|
121
|
+
locales?: string[]
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Custom locale extractor from request path.
|
|
125
|
+
*/
|
|
126
|
+
getLocale?: (path: string) => string
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Custom path for the construction page. Defaults to "/construction".
|
|
130
|
+
*/
|
|
131
|
+
constructionPath?: string
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* API endpoint to automatically handle guest sign-in. Set to `false` to disable automatic API
|
|
135
|
+
* interception. Defaults to "/api/construction-guest".
|
|
136
|
+
*/
|
|
137
|
+
apiPath?: string | false
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Additional routes / prefixes to whitelist and skip gating.
|
|
141
|
+
*/
|
|
142
|
+
whitelist?: (string | RegExp)[] | ((path: string) => boolean)
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Custom authorization predicate. If returns true, request is allowed through. Defaults to
|
|
146
|
+
* checking `c.get("session") || await isConstructionGuest(c, options)`.
|
|
147
|
+
*/
|
|
148
|
+
isAuthorized?: (c: any) => boolean | Promise<boolean>
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Custom handler when unauthorized. Defaults to redirecting to
|
|
152
|
+
* `/${locale}/construction?redirect=${encodeURIComponent(path)}`.
|
|
153
|
+
*/
|
|
154
|
+
onUnauthorized?: (
|
|
155
|
+
c: any,
|
|
156
|
+
meta: { locale: string; path: string; redirectUrl: string }
|
|
157
|
+
) => Response | Promise<Response>
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface RateLimiterBinding {
|
|
161
|
+
limit(options: { key: string }): Promise<{ success: boolean }>
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface RateLimitOptions {
|
|
165
|
+
/**
|
|
166
|
+
* Name of the Cloudflare Rate Limiter binding on `c.env` / `env`.
|
|
167
|
+
*
|
|
168
|
+
* @default "MY_RATE_LIMITER"
|
|
169
|
+
*/
|
|
170
|
+
bindingName?: string
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Direct rate limiter instance or binding resolver.
|
|
174
|
+
*/
|
|
175
|
+
limiter?: (c: any) => RateLimiterBinding | undefined
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Routes / path prefixes or a custom predicate to check if the request should be rate limited.
|
|
179
|
+
* Can be an array of strings/RegExp or a matcher function `(c: any) => boolean`.
|
|
180
|
+
*
|
|
181
|
+
* @default ["/auth/sign-in", "/auth/sign-up", "/api/upload", "/api/chat"]
|
|
182
|
+
*/
|
|
183
|
+
routes?: (string | RegExp)[] | ((c: any) => boolean)
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Function to extract the rate limit key from the context (e.g., client IP, user ID, API key).
|
|
187
|
+
*
|
|
188
|
+
* @default c.req.header("CF-Connecting-IP") || c.req.header("x-forwarded-for") || "unknown"
|
|
189
|
+
*/
|
|
190
|
+
keyGenerator?: (c: any) => string | Promise<string>
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Retry-After header value in seconds.
|
|
194
|
+
*
|
|
195
|
+
* @default 60
|
|
196
|
+
*/
|
|
197
|
+
retryAfter?: number
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Behavior when no rate limiter binding exists (e.g., in local development without mock binding).
|
|
201
|
+
*
|
|
202
|
+
* - "pass": Fail open (call `next()`)
|
|
203
|
+
* - "block": Fail closed (return 429/500)
|
|
204
|
+
*
|
|
205
|
+
* @default "pass"
|
|
206
|
+
*/
|
|
207
|
+
fallback?: "pass" | "block"
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Custom handler when the rate limit is exceeded.
|
|
211
|
+
*/
|
|
212
|
+
onRateLimited?: (c: any, retryAfter: number) => Response | Promise<Response>
|
|
213
|
+
}
|
package/src/virtual.d.ts
ADDED
|
@@ -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
|
+
}
|