@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
package/src/csp.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { SecurityOptions } from "./types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds the Content-Security-Policy header string dynamically for a given request nonce.
|
|
5
|
+
*/
|
|
6
|
+
export function buildCspHeader(options: SecurityOptions = {}, nonce?: string): string {
|
|
7
|
+
const domain = options.domain || ""
|
|
8
|
+
|
|
9
|
+
const imgSources = Array.from(
|
|
10
|
+
new Set([
|
|
11
|
+
"'self'",
|
|
12
|
+
"data:",
|
|
13
|
+
...(domain ? [`https://cdn.${domain}`] : []),
|
|
14
|
+
"https://i3.ytimg.com",
|
|
15
|
+
"https://www.youtube.com",
|
|
16
|
+
"https://www.youtube-nocookie.com",
|
|
17
|
+
...(options.imgSrc || [])
|
|
18
|
+
])
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const allowTurnstile = options.turnstile !== false
|
|
22
|
+
|
|
23
|
+
const connectSources = Array.from(
|
|
24
|
+
new Set([
|
|
25
|
+
"'self'",
|
|
26
|
+
"https://cloudflareinsights.com",
|
|
27
|
+
...(allowTurnstile ? ["https://challenges.cloudflare.com"] : []),
|
|
28
|
+
...(options.connectSrc || [])
|
|
29
|
+
])
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const frameSources = Array.from(
|
|
33
|
+
new Set([
|
|
34
|
+
"https://www.youtube.com",
|
|
35
|
+
"https://www.youtube-nocookie.com",
|
|
36
|
+
...(allowTurnstile ? ["https://challenges.cloudflare.com"] : []),
|
|
37
|
+
...(options.frameSrc || [])
|
|
38
|
+
])
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const scriptSources = Array.from(
|
|
42
|
+
new Set([
|
|
43
|
+
"'self'",
|
|
44
|
+
...(nonce ? [`'nonce-${nonce}'`] : []),
|
|
45
|
+
"https://static.cloudflareinsights.com",
|
|
46
|
+
"https://betterlytics.io/analytics.js",
|
|
47
|
+
...(allowTurnstile ? ["https://challenges.cloudflare.com"] : []),
|
|
48
|
+
"'inline-speculation-rules'",
|
|
49
|
+
// Astro ClientRouter (View Transitions inline script sha)
|
|
50
|
+
"'sha256-ZuMSxilKU+4KIM8LWna4lqBEKzd6WaiAjz6gamhm7zM='",
|
|
51
|
+
...(options.scriptResources || [])
|
|
52
|
+
])
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const styleSources = Array.from(
|
|
56
|
+
new Set([
|
|
57
|
+
"'self'",
|
|
58
|
+
// Astro ClientRouter (View Transitions inline style sha)
|
|
59
|
+
"'sha256-SKuaOGnks7NAUq37nvw1PfGEE0lOAs5ERbiYRbGFIbw='",
|
|
60
|
+
...(options.styleResources || [])
|
|
61
|
+
])
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const defaultDirectives: Record<string, string[]> = {
|
|
65
|
+
"default-src": ["'none'"],
|
|
66
|
+
"img-src": imgSources,
|
|
67
|
+
"font-src": ["'self'"],
|
|
68
|
+
"connect-src": connectSources,
|
|
69
|
+
"frame-ancestors": ["'none'"],
|
|
70
|
+
"frame-src": frameSources,
|
|
71
|
+
"script-src": scriptSources,
|
|
72
|
+
"script-src-attr": ["'none'"],
|
|
73
|
+
"style-src": styleSources,
|
|
74
|
+
"base-uri": ["'self'"],
|
|
75
|
+
"form-action": ["'self'"],
|
|
76
|
+
"object-src": ["'none'"],
|
|
77
|
+
"manifest-src": ["'self'"],
|
|
78
|
+
"upgrade-insecure-requests": []
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (options.trustedTypes) {
|
|
82
|
+
defaultDirectives["require-trusted-types-for"] = ["'script'"]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Merge custom directive overrides
|
|
86
|
+
if (options.directives) {
|
|
87
|
+
for (const [key, value] of Object.entries(options.directives)) {
|
|
88
|
+
if (value === false) {
|
|
89
|
+
delete defaultDirectives[key]
|
|
90
|
+
} else if (value === true) {
|
|
91
|
+
defaultDirectives[key] = []
|
|
92
|
+
} else if (Array.isArray(value)) {
|
|
93
|
+
defaultDirectives[key] = value
|
|
94
|
+
} else if (typeof value === "string") {
|
|
95
|
+
defaultDirectives[key] = value.split(" ").filter(Boolean)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return Object.entries(defaultDirectives)
|
|
101
|
+
.map(([directive, values]) => {
|
|
102
|
+
if (!values || values.length === 0) return directive
|
|
103
|
+
return `${directive} ${values.join(" ")}`
|
|
104
|
+
})
|
|
105
|
+
.join("; ")
|
|
106
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AstroIntegration } from "astro"
|
|
2
|
+
import type { SecurityOptions, ConstructionOptions } from "./types"
|
|
3
|
+
import { security } from "./vite"
|
|
4
|
+
|
|
5
|
+
export interface RimelightSecurityOptions extends SecurityOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Construction mode options & route injection settings
|
|
8
|
+
*/
|
|
9
|
+
construction?: ConstructionOptions & {
|
|
10
|
+
/**
|
|
11
|
+
* Whether to automatically inject the construction page route. Defaults to true.
|
|
12
|
+
*/
|
|
13
|
+
injectRoute?: boolean
|
|
14
|
+
/**
|
|
15
|
+
* Pattern to inject for the construction route. Defaults to "/[locale]/construction" or
|
|
16
|
+
* "/construction".
|
|
17
|
+
*/
|
|
18
|
+
routePattern?: string
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Astro Integration for Rimelight Security. Integrates Vite security plugins (CSP, SRI) and
|
|
24
|
+
* automatically injects the construction mode page route.
|
|
25
|
+
*/
|
|
26
|
+
export function rimelightSecurity(options: RimelightSecurityOptions = {}): AstroIntegration {
|
|
27
|
+
return {
|
|
28
|
+
name: "@rimelight/security",
|
|
29
|
+
hooks: {
|
|
30
|
+
"astro:config:setup": ({ injectRoute, updateConfig }) => {
|
|
31
|
+
// 1. Inject Construction Page Route by default (unless explicitly disabled)
|
|
32
|
+
if (options.construction?.injectRoute !== false) {
|
|
33
|
+
const pattern = options.construction?.routePattern || "/[locale]/construction"
|
|
34
|
+
injectRoute({
|
|
35
|
+
pattern,
|
|
36
|
+
entrypoint: "@rimelight/security/pages/construction.astro"
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2. Add Vite security plugin
|
|
41
|
+
updateConfig({
|
|
42
|
+
vite: {
|
|
43
|
+
plugins: [security(options)]
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default rimelightSecurity
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
import SecurityHead from "../components/SecurityHead.astro";
|
|
3
|
+
import SEOHead from "@rimelight/seo/components/SEOHead.astro";
|
|
4
|
+
import UIHead from "@rimelight/ui/components/head/UIHead.astro";
|
|
5
|
+
import RLAMain from "@rimelight/ui/components/main/RLAMain.astro";
|
|
6
|
+
import { siteConfig } from "virtual:rimelight/seo";
|
|
7
|
+
import { Font } from "astro:assets";
|
|
8
|
+
|
|
9
|
+
export interface Props {
|
|
10
|
+
title?: string | undefined;
|
|
11
|
+
description?: string | undefined;
|
|
12
|
+
favicon?: string | undefined;
|
|
13
|
+
noindex?: boolean | undefined;
|
|
14
|
+
class?: string | undefined;
|
|
15
|
+
bodyClass?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
title = "Under Construction",
|
|
20
|
+
description = "This site is currently undergoing scheduled construction.",
|
|
21
|
+
favicon,
|
|
22
|
+
noindex = true,
|
|
23
|
+
class: className = "",
|
|
24
|
+
bodyClass = "isolate bg-black"
|
|
25
|
+
} = Astro.props;
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<!doctype html>
|
|
29
|
+
<html lang="en">
|
|
30
|
+
<head>
|
|
31
|
+
<SecurityHead />
|
|
32
|
+
<SEOHead
|
|
33
|
+
title={title}
|
|
34
|
+
description={description}
|
|
35
|
+
config={siteConfig}
|
|
36
|
+
favicon={favicon}
|
|
37
|
+
noindex={noindex}
|
|
38
|
+
/>
|
|
39
|
+
<UIHead />
|
|
40
|
+
<Font cssVariable="--font-sans" preload={true} />
|
|
41
|
+
<Font cssVariable="--font-serif" preload={true} />
|
|
42
|
+
<Font cssVariable="--font-mono" preload={true} />
|
|
43
|
+
<slot name="head" />
|
|
44
|
+
</head>
|
|
45
|
+
<body class={bodyClass}>
|
|
46
|
+
<RLAMain class={className}>
|
|
47
|
+
<slot />
|
|
48
|
+
</RLAMain>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { getCookie, setCookie } from "hono/cookie"
|
|
2
|
+
import type { ConstructionOptions } from "../types"
|
|
3
|
+
|
|
4
|
+
export const CONSTRUCTION_GUEST_COOKIE = "rimelight-construction-guest"
|
|
5
|
+
|
|
6
|
+
const DEFAULT_WHITELISTED_PATTERNS = [
|
|
7
|
+
"/construction",
|
|
8
|
+
"/api/auth",
|
|
9
|
+
"/api/construction-guest",
|
|
10
|
+
"/_astro/",
|
|
11
|
+
"/_image",
|
|
12
|
+
"/favicon.",
|
|
13
|
+
"/robots.txt",
|
|
14
|
+
"/sitemap",
|
|
15
|
+
"/.well-known/"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const getPassphrase = (c: any, options?: ConstructionOptions): string | undefined => {
|
|
19
|
+
if (options?.passphrase) return options.passphrase
|
|
20
|
+
const envKey = options?.passphraseEnvKey ?? "CONSTRUCTION_PASSPHRASE"
|
|
21
|
+
return (
|
|
22
|
+
c?.env?.[envKey] ??
|
|
23
|
+
(typeof process !== "undefined" ? process.env?.[envKey] : undefined) ??
|
|
24
|
+
(import.meta as any)?.env?.[envKey]
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const isModeEnabled = (c: any, options?: ConstructionOptions): boolean => {
|
|
29
|
+
const envKey = options?.modeEnvKey ?? "CONSTRUCTION_MODE"
|
|
30
|
+
const val =
|
|
31
|
+
c?.env?.[envKey] ??
|
|
32
|
+
(typeof process !== "undefined" ? process.env?.[envKey] : undefined) ??
|
|
33
|
+
(import.meta as any)?.env?.[envKey]
|
|
34
|
+
return val === "true" || val === true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const encodeBase64Url = (value: Uint8Array): string =>
|
|
38
|
+
btoa(String.fromCharCode(...value))
|
|
39
|
+
.replace(/\+/g, "-")
|
|
40
|
+
.replace(/\//g, "_")
|
|
41
|
+
.replace(/=+$/, "")
|
|
42
|
+
|
|
43
|
+
const decodeBase64Url = (value: string): Uint8Array => {
|
|
44
|
+
const padded = value
|
|
45
|
+
.replace(/-/g, "+")
|
|
46
|
+
.replace(/_/g, "/")
|
|
47
|
+
.padEnd(Math.ceil(value.length / 4) * 4, "=")
|
|
48
|
+
return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const signHmac = async (payload: string, secret: string) => {
|
|
52
|
+
const key = await crypto.subtle.importKey(
|
|
53
|
+
"raw",
|
|
54
|
+
new TextEncoder().encode(secret),
|
|
55
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
56
|
+
false,
|
|
57
|
+
["sign", "verify"]
|
|
58
|
+
)
|
|
59
|
+
return {
|
|
60
|
+
key,
|
|
61
|
+
signature: await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const getContextHelpers = (c: any) => {
|
|
66
|
+
const req = c.req?.raw || c.request || c.req
|
|
67
|
+
const urlObj =
|
|
68
|
+
c.url instanceof URL ? c.url : req?.url ? new URL(req.url) : new URL("http://localhost/")
|
|
69
|
+
const path: string = c.req?.path || urlObj.pathname || "/"
|
|
70
|
+
const method: string = (c.req?.method || req?.method || "GET").toUpperCase()
|
|
71
|
+
const search: string = c.req?.url ? new URL(c.req.url).search : urlObj.search || ""
|
|
72
|
+
|
|
73
|
+
const redirect = (target: string) => {
|
|
74
|
+
if (typeof c.redirect === "function") {
|
|
75
|
+
return c.redirect(target)
|
|
76
|
+
}
|
|
77
|
+
const fullTarget = target.startsWith("http")
|
|
78
|
+
? target
|
|
79
|
+
: new URL(target, urlObj.origin).toString()
|
|
80
|
+
return Response.redirect(fullTarget, 302)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const json = (data: any, status = 200) => {
|
|
84
|
+
if (typeof c.json === "function") {
|
|
85
|
+
return c.json(data, status)
|
|
86
|
+
}
|
|
87
|
+
return new Response(JSON.stringify(data), {
|
|
88
|
+
status,
|
|
89
|
+
headers: { "Content-Type": "application/json" }
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const getCookieValue = (name: string): string | undefined => {
|
|
94
|
+
try {
|
|
95
|
+
const fromHono = getCookie(c, name)
|
|
96
|
+
if (fromHono) return fromHono
|
|
97
|
+
} catch {}
|
|
98
|
+
|
|
99
|
+
if (c.cookies?.get) {
|
|
100
|
+
const val = c.cookies.get(name)
|
|
101
|
+
return typeof val === "string" ? val : val?.value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const cookieHeader = req?.headers?.get?.("cookie") || ""
|
|
105
|
+
const match = cookieHeader.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
|
|
106
|
+
return match ? decodeURIComponent(match[1]) : undefined
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { path, method, search, redirect, json, getCookieValue }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validates whether the incoming request carries a valid, signed construction guest cookie.
|
|
114
|
+
*/
|
|
115
|
+
export const isConstructionGuest = async (
|
|
116
|
+
c: any,
|
|
117
|
+
options?: ConstructionOptions
|
|
118
|
+
): Promise<boolean> => {
|
|
119
|
+
const cookieName = options?.cookieName ?? CONSTRUCTION_GUEST_COOKIE
|
|
120
|
+
const helpers = getContextHelpers(c)
|
|
121
|
+
const token = helpers.getCookieValue(cookieName)
|
|
122
|
+
const passphrase = getPassphrase(c, options)
|
|
123
|
+
if (!token || !passphrase) return false
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const [encodedPayload, encodedSignature] = token.split(".")
|
|
127
|
+
if (!encodedPayload || !encodedSignature) return false
|
|
128
|
+
|
|
129
|
+
const payload = new TextDecoder().decode(decodeBase64Url(encodedPayload))
|
|
130
|
+
const { key } = await signHmac(payload, passphrase)
|
|
131
|
+
const signatureBuffer = decodeBase64Url(encodedSignature)
|
|
132
|
+
const valid = await crypto.subtle.verify(
|
|
133
|
+
"HMAC",
|
|
134
|
+
key,
|
|
135
|
+
signatureBuffer as unknown as BufferSource,
|
|
136
|
+
new TextEncoder().encode(payload)
|
|
137
|
+
)
|
|
138
|
+
return valid && JSON.parse(payload).expiresAt > Date.now()
|
|
139
|
+
} catch {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Signs and sets the construction guest cookie on the Hono or Astro context.
|
|
146
|
+
*/
|
|
147
|
+
export const signInConstructionGuest = async (
|
|
148
|
+
c: any,
|
|
149
|
+
passphrase: string,
|
|
150
|
+
rememberMe = true,
|
|
151
|
+
options?: ConstructionOptions
|
|
152
|
+
): Promise<boolean> => {
|
|
153
|
+
const expectedPassphrase = getPassphrase(c, options)
|
|
154
|
+
if (!expectedPassphrase || !passphrase || passphrase !== expectedPassphrase) {
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const cookieName = options?.cookieName ?? CONSTRUCTION_GUEST_COOKIE
|
|
159
|
+
const maxAge = options?.cookieMaxAge ?? 60 * 60 * 24 * 7 // 7 days
|
|
160
|
+
|
|
161
|
+
const payload = JSON.stringify({
|
|
162
|
+
expiresAt: Date.now() + maxAge * 1000
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const { signature } = await signHmac(payload, expectedPassphrase)
|
|
166
|
+
const cookieValue =
|
|
167
|
+
encodeBase64Url(new TextEncoder().encode(payload)) +
|
|
168
|
+
"." +
|
|
169
|
+
encodeBase64Url(new Uint8Array(signature))
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
setCookie(c, cookieName, cookieValue, {
|
|
173
|
+
httpOnly: true,
|
|
174
|
+
secure: true,
|
|
175
|
+
sameSite: "Lax",
|
|
176
|
+
path: "/",
|
|
177
|
+
...(rememberMe ? { maxAge } : {})
|
|
178
|
+
})
|
|
179
|
+
} catch {
|
|
180
|
+
if (c.cookies?.set) {
|
|
181
|
+
c.cookies.set(cookieName, cookieValue, {
|
|
182
|
+
httpOnly: true,
|
|
183
|
+
secure: true,
|
|
184
|
+
sameSite: "lax",
|
|
185
|
+
path: "/",
|
|
186
|
+
...(rememberMe ? { maxAge } : {})
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const resolveLocale = (path: string, options?: ConstructionOptions): string => {
|
|
195
|
+
if (options?.getLocale) {
|
|
196
|
+
return options.getLocale(path)
|
|
197
|
+
}
|
|
198
|
+
if (options?.locales && options.locales.length > 0) {
|
|
199
|
+
const matched = options.locales.find((loc) => path === `/${loc}` || path.startsWith(`/${loc}/`))
|
|
200
|
+
if (matched) return matched
|
|
201
|
+
}
|
|
202
|
+
// Auto-detection using RFC-5646 language tag pattern at path start (e.g. /en, /en-US, /pt)
|
|
203
|
+
const match = path.match(/^\/([a-zA-Z]{2}(?:-[a-zA-Z]{2})?)(\/|$)/)
|
|
204
|
+
return match?.[1]?.toLowerCase() ?? "en"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Universal Construction Mode Middleware for Hono and Astro.
|
|
209
|
+
*
|
|
210
|
+
* Intercepts unauthenticated traffic when CONSTRUCTION_MODE is active, handles POST requests to the
|
|
211
|
+
* guest auth endpoint automatically, and passes authenticated requests or whitelisted assets
|
|
212
|
+
* cleanly.
|
|
213
|
+
*/
|
|
214
|
+
export function construction(options: ConstructionOptions = {}) {
|
|
215
|
+
const apiPath = options.apiPath !== undefined ? options.apiPath : "/api/construction-guest"
|
|
216
|
+
const constructionPath = options.constructionPath ?? "/construction"
|
|
217
|
+
|
|
218
|
+
return async (c: any, next: any) => {
|
|
219
|
+
const { path, method, search, redirect, json } = getContextHelpers(c)
|
|
220
|
+
|
|
221
|
+
// 1. Auto-handle API guest authentication endpoint
|
|
222
|
+
if (apiPath && path === apiPath && method === "POST") {
|
|
223
|
+
try {
|
|
224
|
+
let body: any = {}
|
|
225
|
+
if (typeof c.req?.json === "function") {
|
|
226
|
+
body = await c.req.json().catch(() => ({}))
|
|
227
|
+
} else if (c.request) {
|
|
228
|
+
body = await c.request.json().catch(() => ({}))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const signedIn = await signInConstructionGuest(
|
|
232
|
+
c,
|
|
233
|
+
body?.passphrase ?? "",
|
|
234
|
+
body?.rememberMe ?? true,
|
|
235
|
+
options
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if (!signedIn) {
|
|
239
|
+
return json({ error: "Invalid credentials" }, 401)
|
|
240
|
+
}
|
|
241
|
+
return json({ success: true })
|
|
242
|
+
} catch (err: any) {
|
|
243
|
+
return json({ error: err?.message || "Authentication failed" }, 400)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const enabled = isModeEnabled(c, options)
|
|
248
|
+
const locale = resolveLocale(path, options)
|
|
249
|
+
|
|
250
|
+
const isConstructionPage =
|
|
251
|
+
path === constructionPath ||
|
|
252
|
+
path === `/${locale}${constructionPath}` ||
|
|
253
|
+
path.endsWith(constructionPath)
|
|
254
|
+
|
|
255
|
+
// Helper to check user authorization
|
|
256
|
+
const isAuthorized = async (): Promise<boolean> => {
|
|
257
|
+
if (options.isAuthorized) {
|
|
258
|
+
const customAuth = await options.isAuthorized(c)
|
|
259
|
+
if (customAuth) return true
|
|
260
|
+
}
|
|
261
|
+
const session = c.get?.("session") || c.get?.("user") || c.locals?.session || c.locals?.user
|
|
262
|
+
if (session) return true
|
|
263
|
+
return await isConstructionGuest(c, options)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 2. If user visits the construction page directly:
|
|
267
|
+
if (isConstructionPage) {
|
|
268
|
+
if (!enabled || (await isAuthorized())) {
|
|
269
|
+
return redirect(`/${locale}`)
|
|
270
|
+
}
|
|
271
|
+
return next()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 3. If construction mode is off, proceed normally
|
|
275
|
+
if (!enabled) {
|
|
276
|
+
return next()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 4. Built-in and custom whitelist verification
|
|
280
|
+
const isWhitelisted = DEFAULT_WHITELISTED_PATTERNS.some((pattern) => path.includes(pattern))
|
|
281
|
+
if (isWhitelisted) {
|
|
282
|
+
return next()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (options.whitelist) {
|
|
286
|
+
if (typeof options.whitelist === "function") {
|
|
287
|
+
if (options.whitelist(path)) return next()
|
|
288
|
+
} else if (Array.isArray(options.whitelist)) {
|
|
289
|
+
const match = options.whitelist.some((pattern) =>
|
|
290
|
+
typeof pattern === "string" ? path.includes(pattern) : pattern.test(path)
|
|
291
|
+
)
|
|
292
|
+
if (match) return next()
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 5. Authorized users proceed
|
|
297
|
+
if (await isAuthorized()) {
|
|
298
|
+
return next()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 6. Redirect unauthorized users to construction page
|
|
302
|
+
const redirectTo = encodeURIComponent(path + search)
|
|
303
|
+
const targetUrl = `/${locale}${constructionPath}?redirect=${redirectTo}`
|
|
304
|
+
|
|
305
|
+
if (options.onUnauthorized) {
|
|
306
|
+
return options.onUnauthorized(c, {
|
|
307
|
+
locale,
|
|
308
|
+
path,
|
|
309
|
+
redirectUrl: targetUrl
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return redirect(targetUrl)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { RateLimitOptions, RateLimiterBinding } from "../types"
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SENSITIVE_ROUTES = ["/auth/sign-in", "/auth/sign-up", "/api/upload", "/api/chat"]
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Middleware to enforce rate limits on sensitive endpoints using Cloudflare Rate Limiting bindings.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* // fetch.ts
|
|
10
|
+
* import { ratelimit } from "@rimelight/security/middleware"
|
|
11
|
+
* app.use(ratelimit())
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // Custom configuration
|
|
15
|
+
* app.use(
|
|
16
|
+
* ratelimit({
|
|
17
|
+
* routes: ["/api/checkout", "/auth/login"],
|
|
18
|
+
* retryAfter: 30,
|
|
19
|
+
* bindingName: "MY_RATE_LIMITER"
|
|
20
|
+
* })
|
|
21
|
+
* )
|
|
22
|
+
*/
|
|
23
|
+
export const ratelimit = (options: RateLimitOptions = {}) => {
|
|
24
|
+
const {
|
|
25
|
+
bindingName = "MY_RATE_LIMITER",
|
|
26
|
+
routes = DEFAULT_SENSITIVE_ROUTES,
|
|
27
|
+
retryAfter = 60,
|
|
28
|
+
fallback = "pass",
|
|
29
|
+
keyGenerator = (c: any) =>
|
|
30
|
+
c.req.header("CF-Connecting-IP") || c.req.header("x-forwarded-for") || "unknown",
|
|
31
|
+
onRateLimited = (c: any, retry: number) => {
|
|
32
|
+
if (typeof c.json === "function") {
|
|
33
|
+
return c.json(
|
|
34
|
+
{
|
|
35
|
+
error: "Too Many Requests",
|
|
36
|
+
message: "Rate limit exceeded. Please try again later."
|
|
37
|
+
},
|
|
38
|
+
429,
|
|
39
|
+
{
|
|
40
|
+
"Retry-After": String(retry)
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return new Response(
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
error: "Too Many Requests",
|
|
48
|
+
message: "Rate limit exceeded. Please try again later."
|
|
49
|
+
}),
|
|
50
|
+
{
|
|
51
|
+
status: 429,
|
|
52
|
+
headers: {
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
"Retry-After": String(retry)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
} = options
|
|
60
|
+
|
|
61
|
+
return async (c: any, next: () => Promise<any>): Promise<any> => {
|
|
62
|
+
const path = c?.req?.path || ""
|
|
63
|
+
|
|
64
|
+
// 1. Check if the current request matches the rate-limited routes
|
|
65
|
+
const matchesRoute =
|
|
66
|
+
typeof routes === "function"
|
|
67
|
+
? routes(c)
|
|
68
|
+
: routes.some((route) =>
|
|
69
|
+
typeof route === "string" ? path.includes(route) : route.test(path)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if (!matchesRoute) {
|
|
73
|
+
return next()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2. Resolve the rate limiter binding
|
|
77
|
+
const bindings = c?.env
|
|
78
|
+
const limiter: RateLimiterBinding | undefined = options.limiter
|
|
79
|
+
? options.limiter(c)
|
|
80
|
+
: bindings?.[bindingName]
|
|
81
|
+
|
|
82
|
+
if (!limiter || typeof limiter.limit !== "function") {
|
|
83
|
+
// Falls back (fails open/closed according to fallback option)
|
|
84
|
+
if (fallback === "pass") {
|
|
85
|
+
return next()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof c.json === "function") {
|
|
89
|
+
return c.json({ error: "Rate limiter not configured" }, 500)
|
|
90
|
+
}
|
|
91
|
+
return new Response(JSON.stringify({ error: "Rate limiter not configured" }), {
|
|
92
|
+
status: 500,
|
|
93
|
+
headers: { "Content-Type": "application/json" }
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 3. Extract key and perform rate limit check
|
|
98
|
+
try {
|
|
99
|
+
const clientKey = await keyGenerator(c)
|
|
100
|
+
const { success } = await limiter.limit({ key: clientKey })
|
|
101
|
+
|
|
102
|
+
if (!success) {
|
|
103
|
+
return onRateLimited(c, retryAfter)
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error("[Rate Limit Error]", error)
|
|
107
|
+
if (fallback !== "pass") {
|
|
108
|
+
if (typeof c.json === "function") {
|
|
109
|
+
return c.json({ error: "Rate limit check failed" }, 500)
|
|
110
|
+
}
|
|
111
|
+
return new Response(JSON.stringify({ error: "Rate limit check failed" }), {
|
|
112
|
+
status: 500,
|
|
113
|
+
headers: { "Content-Type": "application/json" }
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return next()
|
|
119
|
+
}
|
|
120
|
+
}
|