ingenium 0.0.1 → 0.0.2

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/index.cjs CHANGED
@@ -242,7 +242,10 @@ function parseMultipart(buffer$1, contentType, opts = {}) {
242
242
  const maxFields = opts.maxFields ?? DEFAULT_MAX_FIELDS;
243
243
  const allowed = opts.allowedMimePrefixes;
244
244
  const boundary = extractBoundary(contentType);
245
- const result = { fields: {}, files: {} };
245
+ const result = {
246
+ fields: /* @__PURE__ */ Object.create(null),
247
+ files: /* @__PURE__ */ Object.create(null)
248
+ };
246
249
  if (buffer$1.length === 0) return result;
247
250
  const dashBoundary = buffer.Buffer.concat([DASH_DASH, buffer.Buffer.from(boundary)]);
248
251
  let cursor = buffer$1.indexOf(dashBoundary);
@@ -669,6 +672,8 @@ function makeIngeniumCookies(ctx) {
669
672
  }
670
673
 
671
674
  // src/proxy/trust.ts
675
+ var IS_DEV = process.env.NODE_ENV !== "production";
676
+ var warnedTrustTrue = false;
672
677
  function resolveForwarded(trust, remoteAddress, headers, defaultProtocol = "http") {
673
678
  if (trust === false || trust === 0 || trust === void 0 || trust === null) {
674
679
  return {
@@ -683,6 +688,16 @@ function resolveForwarded(trust, remoteAddress, headers, defaultProtocol = "http
683
688
  const fullChain = [...xff, remoteAddress];
684
689
  let trustedIp = remoteAddress;
685
690
  if (typeof trust === "boolean" && trust === true) {
691
+ if (IS_DEV && !warnedTrustTrue) {
692
+ warnedTrustTrue = true;
693
+ try {
694
+ process.emitWarning(
695
+ "trustProxy: true fully trusts the client-supplied X-Forwarded-For header, so ctx.ip can be spoofed by any caller. This bypasses IP rate-limits/allowlists and poisons audit logs. For production, set trustProxy to a hop count (e.g. 1) or a CIDR/subnet trust list (e.g. ['loopback', '10.0.0.0/8']) so only your reverse proxy is trusted.",
696
+ { code: "INGENIUM_TRUST_PROXY_TRUE" }
697
+ );
698
+ } catch {
699
+ }
700
+ }
686
701
  trustedIp = fullChain[0] ?? remoteAddress;
687
702
  } else if (typeof trust === "number") {
688
703
  const idx = Math.max(0, fullChain.length - 1 - trust);
@@ -1105,7 +1120,7 @@ function respondJsonWithEtag(ctx, body, opts = {}) {
1105
1120
  }
1106
1121
 
1107
1122
  // src/context/context.ts
1108
- var IS_DEV = process.env.NODE_ENV !== "production";
1123
+ var IS_DEV2 = process.env.NODE_ENV !== "production";
1109
1124
  var _trustProxyWarned = false;
1110
1125
  var CRLF_RE = /[\r\n]/;
1111
1126
  function assertHeaderNameSafe(name) {
@@ -1314,7 +1329,7 @@ var IngeniumContext = class {
1314
1329
  this.headers,
1315
1330
  this.baseProtocol
1316
1331
  );
1317
- if (IS_DEV && !_trustProxyWarned && this._trustProxy === false) {
1332
+ if (IS_DEV2 && !_trustProxyWarned && this._trustProxy === false) {
1318
1333
  if (this.headers["x-forwarded-for"] !== void 0) {
1319
1334
  _trustProxyWarned = true;
1320
1335
  try {
@@ -1387,7 +1402,7 @@ var IngeniumContext = class {
1387
1402
  * eliminates the branch body behind the `IS_DEV` gate.
1388
1403
  */
1389
1404
  _warnDoubleWrite(method) {
1390
- if (!IS_DEV) return;
1405
+ if (!IS_DEV2) return;
1391
1406
  if (!this._written) return;
1392
1407
  try {
1393
1408
  process.emitWarning(
@@ -1597,7 +1612,7 @@ var IngeniumContextPool = class {
1597
1612
  return this.pool.length;
1598
1613
  }
1599
1614
  };
1600
- var IS_DEV2 = process.env.NODE_ENV !== "production";
1615
+ var IS_DEV3 = process.env.NODE_ENV !== "production";
1601
1616
  var _responseObjectWarned = false;
1602
1617
  function reflectReturn(ctx, value) {
1603
1618
  if (ctx._written) return;
@@ -1605,7 +1620,7 @@ function reflectReturn(ctx, value) {
1605
1620
  ctx.status(204);
1606
1621
  return;
1607
1622
  }
1608
- if (IS_DEV2 && typeof Response !== "undefined" && value instanceof Response) {
1623
+ if (IS_DEV3 && typeof Response !== "undefined" && value instanceof Response) {
1609
1624
  if (!_responseObjectWarned) {
1610
1625
  _responseObjectWarned = true;
1611
1626
  try {
@@ -3892,8 +3907,9 @@ var IngeniumApp = class {
3892
3907
  throw miss;
3893
3908
  }
3894
3909
  const applicable = [...flat.globalMiddleware];
3910
+ const normalizedPath = ctx.path.includes("//") ? ctx.path.replace(/\/{2,}/g, "/") : ctx.path;
3895
3911
  for (const scoped of flat.scopedMiddleware) {
3896
- if (pathStartsWith(ctx.path, scoped.prefix)) applicable.push(scoped.mw);
3912
+ if (pathStartsWith(normalizedPath, scoped.prefix)) applicable.push(scoped.mw);
3897
3913
  }
3898
3914
  if (applicable.length === 0) {
3899
3915
  throw miss;
@@ -4331,6 +4347,14 @@ function mimeFor(file) {
4331
4347
  const ext = path__namespace.extname(file).slice(1).toLowerCase();
4332
4348
  return MIME_TYPES[ext] ?? "application/octet-stream";
4333
4349
  }
4350
+ function isUnderRoot(absRoot, target) {
4351
+ return target === absRoot || target.startsWith(absRoot + path__namespace.sep);
4352
+ }
4353
+ function hasDotfileSegment(absRoot, target) {
4354
+ const rel = path__namespace.relative(absRoot, target);
4355
+ if (rel.length === 0) return false;
4356
+ return rel.split(/[/\\]/).some((s) => s.length > 0 && s.startsWith("."));
4357
+ }
4334
4358
  function makeEtag(stats) {
4335
4359
  return `W/"${stats.size.toString(16)}-${Math.floor(stats.mtimeMs).toString(16)}"`;
4336
4360
  }
@@ -4380,15 +4404,11 @@ function staticMiddleware(root, opts = {}) {
4380
4404
  }
4381
4405
  const joined = path__namespace.join(absRoot, urlPath);
4382
4406
  const resolved = path__namespace.resolve(joined);
4383
- const isUnderRoot = resolved === absRoot || resolved.startsWith(absRoot + path__namespace.sep);
4384
- if (!isUnderRoot) {
4407
+ if (!isUnderRoot(absRoot, resolved)) {
4385
4408
  ctx.status(403).text("Forbidden");
4386
4409
  return;
4387
4410
  }
4388
- const rel = path__namespace.relative(absRoot, resolved);
4389
- const segments = rel.length === 0 ? [] : rel.split(/[/\\]/);
4390
- const hasDot = segments.some((s) => s.length > 0 && s.startsWith("."));
4391
- if (hasDot) {
4411
+ if (hasDotfileSegment(absRoot, resolved)) {
4392
4412
  if (dotfiles === "deny") {
4393
4413
  ctx.status(403).text("Forbidden");
4394
4414
  return;
@@ -4436,6 +4456,19 @@ function staticMiddleware(root, opts = {}) {
4436
4456
  if (!stats || !stats.isFile()) {
4437
4457
  return next();
4438
4458
  }
4459
+ if (!isUnderRoot(absRoot, target)) {
4460
+ ctx.status(403).text("Forbidden");
4461
+ return;
4462
+ }
4463
+ if (hasDotfileSegment(absRoot, target)) {
4464
+ if (dotfiles === "deny") {
4465
+ ctx.status(403).text("Forbidden");
4466
+ return;
4467
+ }
4468
+ if (dotfiles === "ignore") {
4469
+ return next();
4470
+ }
4471
+ }
4439
4472
  const etag = makeEtag(stats);
4440
4473
  const lastModified = new Date(stats.mtimeMs).toUTCString();
4441
4474
  ctx.set("etag", etag);
@@ -4494,6 +4527,7 @@ function staticMiddleware(root, opts = {}) {
4494
4527
  }
4495
4528
 
4496
4529
  // src/cors/middleware.ts
4530
+ var IS_DEV4 = process.env.NODE_ENV !== "production";
4497
4531
  var DEFAULT_METHODS = [
4498
4532
  "GET",
4499
4533
  "HEAD",
@@ -4519,7 +4553,9 @@ async function resolveOrigin(spec, reqOrigin, ctx) {
4519
4553
  if (typeof reqOrigin !== "string" || reqOrigin.length === 0) {
4520
4554
  return { value: null, reflected: false };
4521
4555
  }
4522
- if (spec === true) return { value: reqOrigin, reflected: true };
4556
+ if (spec === true) {
4557
+ return reqOrigin === "null" ? { value: null, reflected: true } : { value: reqOrigin, reflected: true };
4558
+ }
4523
4559
  if (typeof spec === "string") {
4524
4560
  return spec === reqOrigin ? { value: reqOrigin, reflected: true } : { value: null, reflected: true };
4525
4561
  }
@@ -4549,10 +4585,29 @@ function corsMiddleware(opts = {}) {
4549
4585
  const maxAge = opts.maxAge;
4550
4586
  const optionsSuccessStatus = opts.optionsSuccessStatus ?? 204;
4551
4587
  if (credentials && origin === "*") {
4552
- throw new Error(
4588
+ throw new IngeniumError(
4589
+ 500,
4590
+ "CORS_CREDENTIALS_WILDCARD",
4553
4591
  "ingenium.cors: `credentials: true` is incompatible with `origin: '*'`. Specify an explicit origin (string, array, regex, or function) instead."
4554
4592
  );
4555
4593
  }
4594
+ if (credentials && origin === true) {
4595
+ throw new IngeniumError(
4596
+ 500,
4597
+ "CORS_CREDENTIALS_WILDCARD",
4598
+ "ingenium.cors: `credentials: true` is incompatible with `origin: true` (reflecting any Origin with credentials lets any site read authenticated responses). Specify an explicit allowlist (string, array, regex, or function)."
4599
+ );
4600
+ }
4601
+ if (IS_DEV4 && credentials && (typeof origin === "function" || origin instanceof RegExp)) {
4602
+ try {
4603
+ process.emitWarning(
4604
+ "ingenium.cors: `credentials: true` with a function/RegExp origin reflects the request Origin when the predicate matches. Ensure it never matches untrusted origins, or you expose authenticated responses to them.",
4605
+ { code: "INGENIUM_CORS_CREDENTIALS_REFLECT" }
4606
+ );
4607
+ } catch {
4608
+ }
4609
+ }
4610
+ const originIsFunction = typeof origin === "function";
4556
4611
  const methodsHeader = methods.join(",");
4557
4612
  const exposedHeader = exposedHeaders && exposedHeaders.length > 0 ? exposedHeaders.join(",") : void 0;
4558
4613
  const allowedHeader = allowedHeaders && allowedHeaders.length > 0 ? allowedHeaders.join(",") : void 0;
@@ -4565,7 +4620,7 @@ function corsMiddleware(opts = {}) {
4565
4620
  reqOriginStr,
4566
4621
  ctx
4567
4622
  );
4568
- if (reflected) appendVary(ctx, "Origin");
4623
+ if (reflected || originIsFunction) appendVary(ctx, "Origin");
4569
4624
  if (allowOrigin !== null) {
4570
4625
  ctx.set("access-control-allow-origin", allowOrigin);
4571
4626
  if (credentials) {
@@ -4778,6 +4833,8 @@ var IngeniumCsrfError = class extends IngeniumError {
4778
4833
  super(403, "CSRF_FAILED", message);
4779
4834
  }
4780
4835
  };
4836
+ var IS_DEV5 = process.env.NODE_ENV !== "production";
4837
+ var CRLF_RE2 = /[\r\n]/;
4781
4838
  var TOKEN_BYTES = 18;
4782
4839
  var SAFE_METHODS_DEFAULT = ["GET", "HEAD", "OPTIONS", "TRACE"];
4783
4840
  var COOKIE_NAME_DEFAULT = "ingenium.csrf";
@@ -4792,10 +4849,20 @@ function csrfMiddleware(opts = {}) {
4792
4849
  await next();
4793
4850
  return;
4794
4851
  }
4795
- let expected = readExpectedToken(ctx, resolved);
4852
+ const binding = resolved.storage === "cookie" && resolved.sessionBinding ? resolved.sessionBinding(ctx) : void 0;
4853
+ if (IS_DEV5 && resolved.storage === "cookie" && resolved.secrets.length > 0 && binding === void 0) {
4854
+ try {
4855
+ process.emitWarning(
4856
+ "csrfMiddleware: cookie-mode token has no session binding. Double-submit without binding assumes no XSS and a strict SameSite cookie \u2014 any token the server mints is globally valid. Provide `sessionBinding` to tie the token to a session/user.",
4857
+ { type: "IngeniumCsrfUnboundTokenWarning" }
4858
+ );
4859
+ } catch {
4860
+ }
4861
+ }
4862
+ let expected = readExpectedToken(ctx, resolved, binding);
4796
4863
  let mintedThisRequest = false;
4797
4864
  if (!expected) {
4798
- expected = mintToken(resolved);
4865
+ expected = mintToken(resolved, binding);
4799
4866
  mintedThisRequest = true;
4800
4867
  }
4801
4868
  ctx.state.csrfToken = expected;
@@ -4815,14 +4882,15 @@ function csrfMiddleware(opts = {}) {
4815
4882
  }
4816
4883
  };
4817
4884
  }
4818
- function mintToken(opts) {
4885
+ function mintToken(opts, binding) {
4819
4886
  const raw = crypto.randomBytes(TOKEN_BYTES).toString("base64url");
4820
4887
  if (opts.storage === "session" || opts.secrets.length === 0) return raw;
4821
- const sig = signToken(raw, opts.secrets[0]);
4888
+ const sig = signToken(raw, opts.secrets[0], binding);
4822
4889
  return `${raw}.${sig}`;
4823
4890
  }
4824
- function signToken(raw, secret) {
4825
- return crypto.createHmac("sha256", secret).update(raw).digest("base64url");
4891
+ function signToken(raw, secret, binding) {
4892
+ const message = binding === void 0 ? raw : `${raw}.${binding}`;
4893
+ return crypto.createHmac("sha256", secret).update(message).digest("base64url");
4826
4894
  }
4827
4895
  function tokenMatches(submitted, expected) {
4828
4896
  const a = buffer.Buffer.from(submitted);
@@ -4830,24 +4898,24 @@ function tokenMatches(submitted, expected) {
4830
4898
  if (a.length !== b.length) return false;
4831
4899
  return crypto.timingSafeEqual(a, b);
4832
4900
  }
4833
- function verifySignedToken(token, secrets) {
4901
+ function verifySignedToken(token, secrets, binding) {
4834
4902
  const dot = token.lastIndexOf(".");
4835
4903
  if (dot <= 0) return false;
4836
4904
  const raw = token.slice(0, dot);
4837
4905
  const sig = token.slice(dot + 1);
4838
4906
  for (const secret of secrets) {
4839
- const expected = signToken(raw, secret);
4907
+ const expected = signToken(raw, secret, binding);
4840
4908
  if (expected.length !== sig.length) continue;
4841
4909
  if (crypto.timingSafeEqual(buffer.Buffer.from(expected), buffer.Buffer.from(sig))) return true;
4842
4910
  }
4843
4911
  return false;
4844
4912
  }
4845
- function readExpectedToken(ctx, opts) {
4913
+ function readExpectedToken(ctx, opts, binding) {
4846
4914
  if (opts.storage === "cookie") {
4847
4915
  const cookies = parseCookies(ctx.headers["cookie"]);
4848
4916
  const token2 = cookies[opts.cookie.name];
4849
4917
  if (!token2) return null;
4850
- if (opts.secrets.length > 0 && !verifySignedToken(token2, opts.secrets)) return null;
4918
+ if (opts.secrets.length > 0 && !verifySignedToken(token2, opts.secrets, binding)) return null;
4851
4919
  return token2;
4852
4920
  }
4853
4921
  const session = ctx.session;
@@ -4873,6 +4941,11 @@ function writeSession(ctx, token) {
4873
4941
  session.set("csrfToken", token);
4874
4942
  }
4875
4943
  function appendSetCookie2(ctx, value) {
4944
+ if (CRLF_RE2.test(value)) {
4945
+ throw new IngeniumHeaderInjectionError(
4946
+ "csrfMiddleware: Set-Cookie value contains CR/LF (possible header injection)"
4947
+ );
4948
+ }
4876
4949
  const existing = ctx._headers["set-cookie"];
4877
4950
  if (!existing) {
4878
4951
  ctx._headers["set-cookie"] = [value];
@@ -4908,13 +4981,37 @@ function resolveOptions(opts) {
4908
4981
  path: opts.cookie?.path ?? "/",
4909
4982
  domain: opts.cookie?.domain ?? "",
4910
4983
  sameSite: opts.cookie?.sameSite ?? "lax",
4911
- secure: opts.cookie?.secure ?? false,
4984
+ // Secure-by-default: a plaintext CSRF cookie is readable by a network
4985
+ // attacker, who can then forge the double-submit header.
4986
+ secure: opts.cookie?.secure ?? true,
4912
4987
  httpOnly: opts.cookie?.httpOnly ?? false,
4913
4988
  maxAgeSeconds: opts.cookie?.maxAgeSeconds ?? 7 * 24 * 60 * 60
4914
4989
  };
4990
+ if (CRLF_RE2.test(cookie.path) || CRLF_RE2.test(cookie.domain) || CRLF_RE2.test(cookie.name)) {
4991
+ throw new IngeniumHeaderInjectionError(
4992
+ "csrfMiddleware: cookie name/path/domain contains CR/LF (possible header injection)"
4993
+ );
4994
+ }
4995
+ if (storage === "cookie" && cookie.secure === false && IS_DEV5) {
4996
+ try {
4997
+ process.emitWarning(
4998
+ "csrfMiddleware: CSRF cookie issued with Secure=false \u2014 the token is sent over plaintext HTTP and can be read by a network attacker. Only disable Secure for local HTTP development.",
4999
+ { type: "IngeniumCsrfInsecureCookieWarning" }
5000
+ );
5001
+ } catch {
5002
+ }
5003
+ }
4915
5004
  const ignoreMethods = new Set((opts.ignoreMethods ?? SAFE_METHODS_DEFAULT).map((m) => m.toUpperCase()));
4916
5005
  const value = opts.value ?? defaultValueReader;
4917
- return { secrets, storage, cookie, ignoreMethods, value, skip: opts.skip ?? null };
5006
+ return {
5007
+ secrets,
5008
+ storage,
5009
+ cookie,
5010
+ ignoreMethods,
5011
+ value,
5012
+ skip: opts.skip ?? null,
5013
+ sessionBinding: opts.sessionBinding ?? null
5014
+ };
4918
5015
  }
4919
5016
  var defaultValueReader = (ctx) => {
4920
5017
  for (const name of HEADER_NAMES_DEFAULT) {
@@ -4926,6 +5023,7 @@ var defaultValueReader = (ctx) => {
4926
5023
  };
4927
5024
 
4928
5025
  // src/problem/serialize.ts
5026
+ var IS_DEV6 = process.env.NODE_ENV !== "production";
4929
5027
  var TITLES = Object.freeze({
4930
5028
  NOT_FOUND: "Not Found",
4931
5029
  UNAUTHORIZED: "Unauthorized",
@@ -4983,12 +5081,14 @@ function toProblemDetails(err, opts, ctx) {
4983
5081
  }
4984
5082
  return problem2;
4985
5083
  }
5084
+ const exposeMessage = IS_DEV6 || opts.includeStack;
4986
5085
  const message = err?.message;
5086
+ const detail = exposeMessage && typeof message === "string" && message.length > 0 ? message : "Internal Server Error";
4987
5087
  const problem = {
4988
5088
  type: "about:blank",
4989
5089
  title: STATUS_REASON[500],
4990
5090
  status: 500,
4991
- detail: typeof message === "string" && message.length > 0 ? message : "Internal Server Error"
5091
+ detail
4992
5092
  };
4993
5093
  const instance = opts.instance(ctx);
4994
5094
  if (instance !== void 0) problem.instance = instance;
@@ -5071,14 +5171,23 @@ var IdempotencyMemoryStore = class {
5071
5171
  };
5072
5172
 
5073
5173
  // src/idempotency/middleware.ts
5174
+ var IS_DEV7 = process.env.NODE_ENV !== "production";
5074
5175
  var DEFAULT_METHODS2 = ["POST", "PATCH", "DELETE"];
5075
5176
  var DEFAULT_CACHEABLE = (status) => status >= 200 && status < 500;
5177
+ var ANON_SCOPE = /* @__PURE__ */ Symbol("ingenium.idempotency.anon");
5076
5178
  function defaultScope(ctx) {
5077
5179
  const auth = ctx.headers["authorization"];
5078
5180
  if (typeof auth === "string" && auth.length > 0) return auth;
5079
5181
  if (Array.isArray(auth) && auth.length > 0 && typeof auth[0] === "string") return auth[0];
5080
- return "anon";
5081
- }
5182
+ return ANON_SCOPE;
5183
+ }
5184
+ var SENSITIVE_REPLAY_HEADERS = [
5185
+ "set-cookie",
5186
+ "authorization",
5187
+ "proxy-authorization",
5188
+ "www-authenticate",
5189
+ "proxy-authenticate"
5190
+ ];
5082
5191
  function readHeader2(ctx, lowerName) {
5083
5192
  const v = ctx.headers[lowerName];
5084
5193
  if (typeof v === "string") return v;
@@ -5116,6 +5225,7 @@ function replay(ctx, cached) {
5116
5225
  for (const k of Object.keys(cached.headers)) {
5117
5226
  const v = cached.headers[k];
5118
5227
  if (v === void 0) continue;
5228
+ if (SENSITIVE_REPLAY_HEADERS.includes(k.toLowerCase())) continue;
5119
5229
  ctx._headers[k] = Array.isArray(v) ? [...v] : v;
5120
5230
  }
5121
5231
  ctx._headers["idempotent-replayed"] = "true";
@@ -5131,17 +5241,20 @@ function replay(ctx, cached) {
5131
5241
  ctx._written = true;
5132
5242
  }
5133
5243
  function idempotencyMiddleware(opts = {}) {
5244
+ const usingDefaultScope = opts.scope === void 0;
5245
+ const scopeFn = opts.scope ?? defaultScope;
5134
5246
  const resolved = {
5135
5247
  header: (opts.header ?? "Idempotency-Key").toLowerCase(),
5136
5248
  store: opts.store ?? new IdempotencyMemoryStore(),
5137
5249
  ttlMs: (opts.ttlSeconds ?? 86400) * 1e3,
5138
- scope: opts.scope ?? defaultScope,
5250
+ scope: scopeFn,
5139
5251
  methodSet: new Set(opts.methods ?? DEFAULT_METHODS2),
5140
5252
  cacheable: opts.cacheable ?? DEFAULT_CACHEABLE
5141
5253
  };
5142
5254
  if (resolved.ttlMs <= 0) {
5143
5255
  throw new Error("idempotency: ttlSeconds must be > 0");
5144
5256
  }
5257
+ let anonBypassWarned = false;
5145
5258
  const inflight = /* @__PURE__ */ new Map();
5146
5259
  return async (ctx, next) => {
5147
5260
  if (!resolved.methodSet.has(ctx.method)) {
@@ -5151,7 +5264,20 @@ function idempotencyMiddleware(opts = {}) {
5151
5264
  if (!headerValue || headerValue.length === 0) {
5152
5265
  return next();
5153
5266
  }
5154
- const scope = resolved.scope(ctx);
5267
+ const scope = scopeFn(ctx);
5268
+ if (scope === ANON_SCOPE) {
5269
+ if (IS_DEV7 && usingDefaultScope && !anonBypassWarned) {
5270
+ anonBypassWarned = true;
5271
+ try {
5272
+ process.emitWarning(
5273
+ "idempotency: request to a cacheable route has no Authorization header, so the default scope cannot isolate clients. Idempotency caching was bypassed for this request to avoid serving one client the cached response of another. Supply an explicit `scope` function for unauthenticated endpoints.",
5274
+ { type: "IngeniumIdempotencyWarning" }
5275
+ );
5276
+ } catch {
5277
+ }
5278
+ }
5279
+ return next();
5280
+ }
5155
5281
  const cacheKey = `${scope}:${ctx.method}:${ctx.path}:${headerValue}`;
5156
5282
  const existing = await resolved.store.get(cacheKey);
5157
5283
  if (existing) {
@@ -5216,11 +5342,23 @@ function decodeJsonSegment(segment) {
5216
5342
  return null;
5217
5343
  }
5218
5344
  }
5345
+ function isSymmetricKey(key) {
5346
+ if (typeof key === "string") return !looksLikePem(key);
5347
+ if (buffer.Buffer.isBuffer(key)) return !looksLikePem(key.toString("latin1"));
5348
+ return key.type === "secret";
5349
+ }
5350
+ function looksLikePem(s) {
5351
+ return s.trimStart().startsWith("-----BEGIN");
5352
+ }
5219
5353
  function hmacVerifies(digest, secret, signingInput, sig) {
5220
- const secretInput = typeof secret === "string" || buffer.Buffer.isBuffer(secret) ? secret : secret.export({ format: "buffer" });
5221
- const expected = crypto.createHmac(digest, secretInput).update(signingInput).digest();
5222
- if (sig.length !== expected.length) return false;
5223
- return crypto.timingSafeEqual(sig, expected);
5354
+ try {
5355
+ const secretInput = typeof secret === "string" || buffer.Buffer.isBuffer(secret) ? secret : secret.export({ format: "buffer" });
5356
+ const expected = crypto.createHmac(digest, secretInput).update(signingInput).digest();
5357
+ if (sig.length !== expected.length) return false;
5358
+ return crypto.timingSafeEqual(sig, expected);
5359
+ } catch {
5360
+ return false;
5361
+ }
5224
5362
  }
5225
5363
  function asymmetricVerifies(spec, keyMaterial, signingInput, sig) {
5226
5364
  let key;
@@ -5258,6 +5396,9 @@ function asymmetricVerifies(spec, keyMaterial, signingInput, sig) {
5258
5396
  return false;
5259
5397
  }
5260
5398
  }
5399
+ function isFiniteNumber(v) {
5400
+ return typeof v === "number" && Number.isFinite(v);
5401
+ }
5261
5402
  function audienceMatches(claim, expected) {
5262
5403
  const wanted = typeof expected === "string" ? [expected] : expected;
5263
5404
  if (typeof claim === "string") return wanted.includes(claim);
@@ -5309,12 +5450,15 @@ function verifyJwt(token, keys, opts) {
5309
5450
  }
5310
5451
  let signatureOk = false;
5311
5452
  for (const candidate of candidates) {
5453
+ const candidateIsSymmetric = isSymmetricKey(candidate);
5312
5454
  if (spec.family === "hmac") {
5455
+ if (!candidateIsSymmetric) continue;
5313
5456
  if (hmacVerifies(spec.digest, candidate, signingInput, sig)) {
5314
5457
  signatureOk = true;
5315
5458
  break;
5316
5459
  }
5317
5460
  } else {
5461
+ if (candidateIsSymmetric) continue;
5318
5462
  if (asymmetricVerifies(spec, candidate, signingInput, sig)) {
5319
5463
  signatureOk = true;
5320
5464
  break;
@@ -5325,14 +5469,20 @@ function verifyJwt(token, keys, opts) {
5325
5469
  const now = (opts.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
5326
5470
  const skew = opts.clockSkewSeconds ?? 5;
5327
5471
  const claims = payload;
5328
- if (typeof claims.exp === "number") {
5472
+ if ("exp" in claims && !isFiniteNumber(claims.exp)) return { error: "malformed" };
5473
+ if ("nbf" in claims && !isFiniteNumber(claims.nbf)) return { error: "malformed" };
5474
+ if ("iat" in claims && !isFiniteNumber(claims.iat)) return { error: "malformed" };
5475
+ const requireExp = opts.requireExp ?? true;
5476
+ if (isFiniteNumber(claims.exp)) {
5329
5477
  if (claims.exp <= now - skew) return { error: "expired" };
5478
+ } else if (requireExp) {
5479
+ return { error: "missing_exp" };
5330
5480
  }
5331
- if (typeof claims.nbf === "number") {
5481
+ if (isFiniteNumber(claims.nbf)) {
5332
5482
  if (claims.nbf > now + skew) return { error: "not_yet_valid" };
5333
5483
  }
5334
5484
  if (typeof opts.maxAgeSeconds === "number") {
5335
- if (typeof claims.iat !== "number") return { error: "too_old" };
5485
+ if (!isFiniteNumber(claims.iat)) return { error: "too_old" };
5336
5486
  if (claims.iat + opts.maxAgeSeconds <= now - skew) return { error: "too_old" };
5337
5487
  }
5338
5488
  if (opts.audience !== void 0) {
@@ -5440,6 +5590,7 @@ async function doFetch(url) {
5440
5590
 
5441
5591
  // src/jwt/middleware.ts
5442
5592
  var DEFAULT_ALGORITHMS = ["HS256"];
5593
+ var DEFAULT_ASYMMETRIC_ALGORITHMS = ["RS256"];
5443
5594
  var DEFAULT_JWKS_TTL_MS = 10 * 60 * 1e3;
5444
5595
  var SUPPORTED = /* @__PURE__ */ new Set([
5445
5596
  "HS256",
@@ -5455,6 +5606,14 @@ var SUPPORTED = /* @__PURE__ */ new Set([
5455
5606
  "ES384",
5456
5607
  "ES512"
5457
5608
  ]);
5609
+ var IngeniumJwtKeyAlgMismatchError = class extends IngeniumError {
5610
+ constructor(message) {
5611
+ super(500, "JWT_KEY_ALG_MISMATCH", message);
5612
+ }
5613
+ };
5614
+ function isHmacAlg(alg) {
5615
+ return alg === "HS256" || alg === "HS384" || alg === "HS512";
5616
+ }
5458
5617
  var defaultGetToken = (ctx) => {
5459
5618
  const raw = ctx.headers["authorization"];
5460
5619
  if (!raw) return void 0;
@@ -5477,7 +5636,8 @@ function jwtMiddleware(opts) {
5477
5636
  throw new Error("jwtMiddleware: `secret` (or `jwksUrl`) is required");
5478
5637
  }
5479
5638
  }
5480
- const algorithms = (opts.algorithms ?? DEFAULT_ALGORITHMS).slice();
5639
+ const defaultAlgorithms = hasJwks ? DEFAULT_ASYMMETRIC_ALGORITHMS : DEFAULT_ALGORITHMS;
5640
+ const algorithms = (opts.algorithms ?? defaultAlgorithms).slice();
5481
5641
  if (algorithms.length === 0) {
5482
5642
  throw new Error("jwtMiddleware: `algorithms` must contain at least one algorithm");
5483
5643
  }
@@ -5491,6 +5651,7 @@ function jwtMiddleware(opts) {
5491
5651
  }
5492
5652
  const required = opts.required ?? true;
5493
5653
  const clockSkewSeconds = opts.clockSkewSeconds ?? 5;
5654
+ const requireExp = opts.requireExp ?? true;
5494
5655
  const jwksCacheMs = opts.jwksCacheMs ?? DEFAULT_JWKS_TTL_MS;
5495
5656
  const jwksUrl = hasJwks ? opts.jwksUrl : null;
5496
5657
  const getToken = opts.getToken ?? defaultGetToken;
@@ -5499,6 +5660,15 @@ function jwtMiddleware(opts) {
5499
5660
  });
5500
5661
  const staticKeys = opts.secret != null && typeof opts.secret !== "function" ? normaliseStaticKeys(opts.secret) : [];
5501
5662
  const keyResolver = typeof opts.secret === "function" ? opts.secret : null;
5663
+ if (algorithms.some(isHmacAlg)) {
5664
+ for (const k of staticKeys) {
5665
+ if (staticKeyIsAsymmetric(k)) {
5666
+ throw new IngeniumJwtKeyAlgMismatchError(
5667
+ "jwtMiddleware: an HMAC algorithm (HS256/384/512) was paired with an asymmetric key (PEM or public/private KeyObject). This enables an algorithm-confusion forgery \u2014 use an asymmetric algorithm (RS*/PS*/ES*) for asymmetric keys, or a raw shared secret for HMAC."
5668
+ );
5669
+ }
5670
+ }
5671
+ }
5502
5672
  return async (ctx, next) => {
5503
5673
  const token = await getToken(ctx);
5504
5674
  if (!token) {
@@ -5528,7 +5698,7 @@ function jwtMiddleware(opts) {
5528
5698
  logger({ reason: "no_keys_available" });
5529
5699
  throw new IngeniumUnauthorizedError("Invalid token");
5530
5700
  }
5531
- const verifyOpts = { algorithms, clockSkewSeconds };
5701
+ const verifyOpts = { algorithms, clockSkewSeconds, requireExp };
5532
5702
  if (opts.audience !== void 0) verifyOpts.audience = opts.audience;
5533
5703
  if (opts.issuer !== void 0) verifyOpts.issuer = opts.issuer;
5534
5704
  if (opts.maxAgeSeconds !== void 0) verifyOpts.maxAgeSeconds = opts.maxAgeSeconds;
@@ -5574,6 +5744,15 @@ function coerceJwtKey(k) {
5574
5744
  }
5575
5745
  throw new Error("jwtMiddleware: invalid `secret` entry \u2014 expected string, Buffer, KeyObject, or { kid, key }");
5576
5746
  }
5747
+ function staticKeyIsAsymmetric(entry) {
5748
+ const key = typeof entry === "object" && entry !== null && !buffer.Buffer.isBuffer(entry) && "key" in entry ? entry.key : entry;
5749
+ if (typeof key === "string") return looksLikePem2(key);
5750
+ if (buffer.Buffer.isBuffer(key)) return looksLikePem2(key.toString("latin1"));
5751
+ return key.type === "public" || key.type === "private";
5752
+ }
5753
+ function looksLikePem2(s) {
5754
+ return s.trimStart().startsWith("-----BEGIN");
5755
+ }
5577
5756
  function isKeyObject(v) {
5578
5757
  if (typeof v !== "object" || v === null) return false;
5579
5758
  const t = v.type;
@@ -6403,6 +6582,7 @@ var MemoryStore2 = class {
6403
6582
  };
6404
6583
 
6405
6584
  // src/session/middleware.ts
6585
+ var IS_DEV8 = process.env.NODE_ENV !== "production";
6406
6586
  var DEFAULT_COOKIE_NAME = "ingenium.sid";
6407
6587
  var DEFAULT_MAX_AGE_SECONDS = 60 * 60 * 24 * 7;
6408
6588
  var ID_BYTES = 18;
@@ -6538,8 +6718,19 @@ function sessionMiddleware(opts) {
6538
6718
  const cookieName = opts.cookieName ?? DEFAULT_COOKIE_NAME;
6539
6719
  const maxAgeSeconds = opts.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
6540
6720
  const rolling = opts.rolling ?? false;
6541
- const cookieOpts = opts.cookie ?? {};
6721
+ const baseCookieOpts = opts.cookie ?? {};
6542
6722
  const store = opts.store ?? new MemoryStore2();
6723
+ const resolvedSecure = baseCookieOpts.secure ?? !IS_DEV8;
6724
+ const cookieOpts = { ...baseCookieOpts, secure: resolvedSecure };
6725
+ if (!IS_DEV8 && resolvedSecure === false) {
6726
+ try {
6727
+ process.emitWarning(
6728
+ "ingenium: sessionMiddleware is issuing a session cookie WITHOUT the Secure attribute in production (cookie.secure === false). The cookie can be sent over plaintext HTTP and intercepted. Remove `cookie.secure: false` to use the safe production default, or terminate TLS in front of the app.",
6729
+ { type: "IngeniumSessionInsecureCookieWarning" }
6730
+ );
6731
+ } catch {
6732
+ }
6733
+ }
6543
6734
  return async (ctx, next) => {
6544
6735
  const cookies = parseCookieHeader2(ctx.headers.cookie);
6545
6736
  const raw = cookies[cookieName];
@@ -6618,6 +6809,7 @@ async function commit(ctx, session, signingSecret, cookieName, maxAgeSeconds, ro
6618
6809
  }
6619
6810
 
6620
6811
  // src/ws/middleware.ts
6812
+ var IS_DEV9 = process.env.NODE_ENV !== "production";
6621
6813
  async function peerHasWs() {
6622
6814
  try {
6623
6815
  await import('ws');
@@ -6636,6 +6828,14 @@ function createWebSocketRegistrar() {
6636
6828
  if (routes.has(path2)) {
6637
6829
  throw new Error(`ingenium.ws: path "${path2}" already has a WebSocket handler`);
6638
6830
  }
6831
+ if (IS_DEV9 && options2.origin === void 0) {
6832
+ try {
6833
+ process.emitWarning(
6834
+ `ingenium.ws: WebSocket route "${path2}" registered without an \`origin\` option. WS handlers run outside the middleware pipeline and the browser sends the user's cookies on cross-origin upgrades \u2014 restrict the Origin (e.g. \`{ origin: true }\` for same-origin) or authenticate inside the handler to prevent Cross-Site WebSocket Hijacking (CSWSH).`
6835
+ );
6836
+ } catch {
6837
+ }
6838
+ }
6639
6839
  routes.set(path2, { path: path2, handler, options: options2 });
6640
6840
  }
6641
6841
  function attach(httpServer) {
@@ -6653,6 +6853,14 @@ function createWebSocketRegistrar() {
6653
6853
  socket.destroy();
6654
6854
  return;
6655
6855
  }
6856
+ if (route.options.origin !== void 0) {
6857
+ const origin = req.headers.origin;
6858
+ if (!isOriginAllowed(route.options.origin, origin, req)) {
6859
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
6860
+ socket.destroy();
6861
+ return;
6862
+ }
6863
+ }
6656
6864
  void (async () => {
6657
6865
  try {
6658
6866
  if (wsModule === null) wsModule = await import('ws');
@@ -6724,6 +6932,22 @@ function createWebSocketRegistrar() {
6724
6932
  }
6725
6933
  return { add, attach, close };
6726
6934
  }
6935
+ function isOriginAllowed(policy, origin, req) {
6936
+ if (typeof policy === "function") return policy(origin, req);
6937
+ if (policy === false) return true;
6938
+ if (policy === true) {
6939
+ if (origin === void 0) return false;
6940
+ let originHost;
6941
+ try {
6942
+ originHost = new URL(origin).host;
6943
+ } catch {
6944
+ return false;
6945
+ }
6946
+ return originHost === req.headers.host;
6947
+ }
6948
+ if (origin === void 0) return false;
6949
+ return Array.isArray(policy) ? policy.includes(origin) : policy === origin;
6950
+ }
6727
6951
  function buildMinimalContext(req, path2) {
6728
6952
  const ctx = new IngeniumContext();
6729
6953
  ctx.method = req.method ?? "GET";
@@ -6999,6 +7223,7 @@ exports.IngeniumCsrfError = IngeniumCsrfError;
6999
7223
  exports.IngeniumError = IngeniumError;
7000
7224
  exports.IngeniumHaltError = IngeniumHaltError;
7001
7225
  exports.IngeniumHeaderInjectionError = IngeniumHeaderInjectionError;
7226
+ exports.IngeniumJwtKeyAlgMismatchError = IngeniumJwtKeyAlgMismatchError;
7002
7227
  exports.IngeniumMethodNotAllowedError = IngeniumMethodNotAllowedError;
7003
7228
  exports.IngeniumNotFoundError = IngeniumNotFoundError;
7004
7229
  exports.IngeniumPayloadTooLargeError = IngeniumPayloadTooLargeError;