@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,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,4 @@
1
+ export * from "./security"
2
+ export * from "./dev-only"
3
+ export * from "./construction"
4
+ export * from "./ratelimit"
@@ -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> | any): Promise<Response | 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
+ }