Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 | 2x 2x 2x 2x 2x 2x 14x 14x 24x 14x 10x 33x 10x 3x 14x 3x 3x 1x 1x 1x 2x 14x 10x 10x 14x 10x 2x 8x 8x 8x 1x 1x 1x 7x 7x 5x 5x 5x 2x 14x 14x 3x 14x 14x 10x | import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { APIResponse, SecuritySettings, timingSafeEqual } from '@bayudwiyansatria/core'
import { events } from '@/constants/Events'
import { lazySettings } from '@/utils/lazySettings'
import { CloudflareRequestMetadata } from '@/core/services/CloudflareRequestMetadata'
import type { RequestMetadata } from '@bayudwiyansatria/core'
/**
* The security middleware stack applied to the Hono app: CORS, an origin
* allowlist, client-IP capture, and API-key authentication.
*
* @remarks
* Which routes it protects, which stay public, and which origins may reach it
* from a browser all come from the configuration layer, so `src/app.ts`
* composes the app without restating any of it. Override in
* `config/SecurityConfig.ts`.
*
* Authentication fails closed: a missing server-side key yields `500`, a
* mismatched client key `401`, and neither reaches the route. Because the
* rejection short-circuits, anything that must observe rejected requests —
* request logging, telemetry — has to be registered ahead of
* {@link SecurityMiddleware.apply}.
*
* The blanket check is default-deny within its pattern: a route added later is
* protected until someone exempts it. Endpoints that cannot be addressed by a
* key — a third-party webhook, or a browser endpoint where an embedded key
* would be public anyway — are named in `excludedRoutes` and authenticate
* themselves instead. An empty `urlPattern` disables the blanket check
* altogether, leaving CORS and the origin allowlist as all this registers.
*
* Not to be confused with an application's `config/SecurityConfig`, which is the
* override that feeds this rather than a second implementation of it.
*
* @class
*
* @author Bayu Dwiyan Satria
* @version 1.0.0
* @since 1.0.0
*/
export class SecurityMiddleware {
/**
* This middleware's settings, resolved on first read.
*/
private readonly settings: () => SecuritySettings
/**
* Reader for the facts the runtime attaches to a request.
*/
private readonly metadata: RequestMetadata<Request>
/**
* Constructs a SecurityMiddleware instance.
*
* @remarks
* Settings are looked up on first use rather than here. An application
* composes its app at module scope — `const security = new
* SecurityMiddleware()` beside the routes — which runs while imports are
* still being hoisted, before `configure()` can. Resolving in this
* constructor would make importing the app throw.
*
* @param settings Settings to use. Omit to take them from the configuration
* surface, which is what callers normally do.
* @param metadata Reader for the runtime's account of a request. Defaults to
* the Cloudflare one, which is the only thing here that knows a platform —
* and the only argument that would change to run this middleware elsewhere.
*/
constructor(settings?: SecuritySettings, metadata: RequestMetadata<Request> = new CloudflareRequestMetadata()) {
this.settings = lazySettings<SecuritySettings>('security', settings)
this.metadata = metadata
}
/**
* The URL pattern where security middlewares will be applied.
*/
private get urlPattern(): string {
return this.settings().urlPattern
}
/**
* Routes registered ahead of authentication, and so reachable without a key.
*/
private get publicRoutes(): string[] {
return this.settings().publicRoutes || []
}
/**
* Paths the key check skips, for routes that authenticate themselves.
*/
private get excludedRoutes(): string[] {
return this.settings().excludedRoutes || []
}
/**
* Origins allowed to call this Worker from a browser. Empty allows any.
*/
private get allowedOrigins(): string[] {
return this.settings().allowedOrigins || []
}
/**
* Whether a path is exempt from the API-key check.
*
* A trailing `/*` exempts a subtree, anything else exempts that one path —
* the same convention `urlPattern` and `publicRoutes` already use. Exact is
* the default because the two are genuinely different intents: a health check
* mounted at the pattern's own prefix must stay open without opening
* everything beneath it.
*
* @param path The request path.
* @returns True when the blanket check must not run.
*/
private isExcluded(path: string): boolean {
return this.excludedRoutes.some(route =>
route.endsWith('/*') ? path.startsWith(route.slice(0, -1)) : path === route
)
}
/**
* Rejects a browser request from an origin this Worker does not serve.
*
* @remarks
* Runs ahead of everything else and, unlike CORS, actually refuses the
* request. CORS is enforced by the browser *after* the response is produced,
* so on its own it would let another site's page reach an endpoint and spend
* this Worker's upstream quota while merely being unable to read what came
* back.
*
* A missing `Origin` is allowed through: server-to-server callers send none,
* and they are the ones the API key exists for.
*
* @param ctx The Hono context.
* @param next Passes control to the next middleware.
* @returns A `403` when the origin is not allowed, nothing otherwise.
*/
private originGuard = async (ctx: any, next: any) => {
const origin = ctx.req.header('origin')
if (origin && !this.allowedOrigins.includes(origin)) {
const response: APIResponse = {
message: 'Forbidden origin',
success: false,
data: null
}
ctx.get('logger')?.warn(events.AUTHORIZATION_DENIED, { reason: 'origin_not_allowed', origin })
return ctx.json(response, 403)
}
await next()
}
/**
* Captures the client's IP address onto the context as `clientIp`.
*
* @remarks
* Which header may be believed is a platform question, so this does not
* answer it — [`CloudflareRequestMetadata`](../core/services/CloudflareRequestMetadata.ts)
* does, and documents why `CF-Connecting-IP` can be trusted where
* `X-Forwarded-For` cannot. This middleware only puts the answer somewhere
* handlers can reach it.
*
* The address is put on the context rather than only logged, so a handler
* that needs it does not have to re-derive it and risk picking the wrong
* header:
*
* ```ts
* const ip = ctx.get('clientIp')
* ```
*
* It is not logged here. Every request already carries its IP in the
* `request.completed` line [`logToAnalytics`](./logger.ts) emits, and a
* second line saying the same thing at `debug` was one more record to sample,
* pay for, and read past — for a value the next line already holds.
*
* @param ctx The Hono context.
* @param next Passes control to the next middleware.
*/
private clientIpCapture = async (ctx: any, next: any) => {
ctx.set('clientIp', this.metadata.read(ctx.req.raw).clientIp)
await next()
}
/**
* Validates the API key, comparing the configured header against the
* expected value from the environment.
*
* @remarks
* The comparison goes through
* `timingSafeEqual` rather than `!==`, so the time
* taken does not reveal how many leading bytes of the key were correct. See
* that function for why the distinction matters.
*
* @param ctx The Hono context.
* @param next Passes control to the next middleware.
* @returns A `500` response when the Worker has no key configured, a `401`
* when the client's key does not match, and nothing when the request is
* allowed through.
*/
private apiKeyAuth = async (ctx: any, next: any) => {
let response: APIResponse
// Routes that authenticate themselves — see `excludedRoutes`.
if (this.isExcluded(ctx.req.path)) {
return await next()
}
const headerKey = ctx.env.API_AUTH_TOKEN_HEADER
const expectedValue = ctx.env.API_AUTH_TOKEN_VALUE
if (!headerKey || !expectedValue) {
response = {
message: 'Server misconfiguration',
success: false,
data: null
}
/*
* A fault, not a rejection: the Worker is deployed without the key it
* needs, and every caller will fail identically until someone fixes the
* deployment. `reason` names which half is missing without naming either
* value.
*/
ctx.get('logger')?.error(events.REQUEST_FAILED, {
reason: headerKey ? 'auth_token_value_missing' : 'auth_token_header_missing'
})
return ctx.json(response, 500)
}
const actualValue = ctx.req.header(headerKey) || ''
if (!timingSafeEqual(actualValue, expectedValue)) {
response = {
message: 'Unauthorized',
success: false,
data: null
}
/*
* The `request.completed` line beside this one says a `401` happened;
* this says why, which is the difference between a wrong key and no key
* at all — the first question anyone asks of a spike in rejections.
*
* Neither key appears in the payload, and neither may: the presented
* value is a credential even when it is the wrong one.
*/
ctx.get('logger')?.warn(events.AUTHENTICATION_FAILED, {
reason: actualValue ? 'api_key_mismatch' : 'api_key_absent'
})
return ctx.json(response, 401)
}
await next()
}
/**
* Registers CORS, the origin allowlist, the public routes, and the
* authenticated pattern on an app.
*
* @remarks
* Order matters: public routes are registered before the authentication
* middleware, which is what keeps them reachable without a key.
*
* An empty `urlPattern` skips the authenticated pattern altogether.
*
* @param app The Hono app to apply the middleware to.
*/
public apply(app: Hono): void {
/*
* CORS (applies globally), narrowed to the configured origins when there
* are any. The guard beside it is what actually refuses a foreign origin;
* this half only tells a browser what it may read.
*/
app.use('/*', cors(this.allowedOrigins.length > 0 ? { origin: this.allowedOrigins } : undefined))
if (this.allowedOrigins.length > 0) {
app.use('/*', this.originGuard)
}
// Public routes (no auth), registered ahead of the auth middleware
this.publicRoutes.forEach(route => app.get(route, c => c.text(`${route} (public)`)))
// Protected routes, when a pattern is configured.
if (this.urlPattern) {
app.use(this.urlPattern, this.clientIpCapture, this.apiKeyAuth)
}
}
}
|