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.js CHANGED
@@ -218,7 +218,10 @@ function parseMultipart(buffer, contentType, opts = {}) {
218
218
  const maxFields = opts.maxFields ?? DEFAULT_MAX_FIELDS;
219
219
  const allowed = opts.allowedMimePrefixes;
220
220
  const boundary = extractBoundary(contentType);
221
- const result = { fields: {}, files: {} };
221
+ const result = {
222
+ fields: /* @__PURE__ */ Object.create(null),
223
+ files: /* @__PURE__ */ Object.create(null)
224
+ };
222
225
  if (buffer.length === 0) return result;
223
226
  const dashBoundary = Buffer$1.concat([DASH_DASH, Buffer$1.from(boundary)]);
224
227
  let cursor = buffer.indexOf(dashBoundary);
@@ -645,6 +648,8 @@ function makeIngeniumCookies(ctx) {
645
648
  }
646
649
 
647
650
  // src/proxy/trust.ts
651
+ var IS_DEV = process.env.NODE_ENV !== "production";
652
+ var warnedTrustTrue = false;
648
653
  function resolveForwarded(trust, remoteAddress, headers, defaultProtocol = "http") {
649
654
  if (trust === false || trust === 0 || trust === void 0 || trust === null) {
650
655
  return {
@@ -659,6 +664,16 @@ function resolveForwarded(trust, remoteAddress, headers, defaultProtocol = "http
659
664
  const fullChain = [...xff, remoteAddress];
660
665
  let trustedIp = remoteAddress;
661
666
  if (typeof trust === "boolean" && trust === true) {
667
+ if (IS_DEV && !warnedTrustTrue) {
668
+ warnedTrustTrue = true;
669
+ try {
670
+ process.emitWarning(
671
+ "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.",
672
+ { code: "INGENIUM_TRUST_PROXY_TRUE" }
673
+ );
674
+ } catch {
675
+ }
676
+ }
662
677
  trustedIp = fullChain[0] ?? remoteAddress;
663
678
  } else if (typeof trust === "number") {
664
679
  const idx = Math.max(0, fullChain.length - 1 - trust);
@@ -1081,7 +1096,7 @@ function respondJsonWithEtag(ctx, body, opts = {}) {
1081
1096
  }
1082
1097
 
1083
1098
  // src/context/context.ts
1084
- var IS_DEV = process.env.NODE_ENV !== "production";
1099
+ var IS_DEV2 = process.env.NODE_ENV !== "production";
1085
1100
  var _trustProxyWarned = false;
1086
1101
  var CRLF_RE = /[\r\n]/;
1087
1102
  function assertHeaderNameSafe(name) {
@@ -1290,7 +1305,7 @@ var IngeniumContext = class {
1290
1305
  this.headers,
1291
1306
  this.baseProtocol
1292
1307
  );
1293
- if (IS_DEV && !_trustProxyWarned && this._trustProxy === false) {
1308
+ if (IS_DEV2 && !_trustProxyWarned && this._trustProxy === false) {
1294
1309
  if (this.headers["x-forwarded-for"] !== void 0) {
1295
1310
  _trustProxyWarned = true;
1296
1311
  try {
@@ -1363,7 +1378,7 @@ var IngeniumContext = class {
1363
1378
  * eliminates the branch body behind the `IS_DEV` gate.
1364
1379
  */
1365
1380
  _warnDoubleWrite(method) {
1366
- if (!IS_DEV) return;
1381
+ if (!IS_DEV2) return;
1367
1382
  if (!this._written) return;
1368
1383
  try {
1369
1384
  process.emitWarning(
@@ -1573,7 +1588,7 @@ var IngeniumContextPool = class {
1573
1588
  return this.pool.length;
1574
1589
  }
1575
1590
  };
1576
- var IS_DEV2 = process.env.NODE_ENV !== "production";
1591
+ var IS_DEV3 = process.env.NODE_ENV !== "production";
1577
1592
  var _responseObjectWarned = false;
1578
1593
  function reflectReturn(ctx, value) {
1579
1594
  if (ctx._written) return;
@@ -1581,7 +1596,7 @@ function reflectReturn(ctx, value) {
1581
1596
  ctx.status(204);
1582
1597
  return;
1583
1598
  }
1584
- if (IS_DEV2 && typeof Response !== "undefined" && value instanceof Response) {
1599
+ if (IS_DEV3 && typeof Response !== "undefined" && value instanceof Response) {
1585
1600
  if (!_responseObjectWarned) {
1586
1601
  _responseObjectWarned = true;
1587
1602
  try {
@@ -3868,8 +3883,9 @@ var IngeniumApp = class {
3868
3883
  throw miss;
3869
3884
  }
3870
3885
  const applicable = [...flat.globalMiddleware];
3886
+ const normalizedPath = ctx.path.includes("//") ? ctx.path.replace(/\/{2,}/g, "/") : ctx.path;
3871
3887
  for (const scoped of flat.scopedMiddleware) {
3872
- if (pathStartsWith(ctx.path, scoped.prefix)) applicable.push(scoped.mw);
3888
+ if (pathStartsWith(normalizedPath, scoped.prefix)) applicable.push(scoped.mw);
3873
3889
  }
3874
3890
  if (applicable.length === 0) {
3875
3891
  throw miss;
@@ -4307,6 +4323,14 @@ function mimeFor(file) {
4307
4323
  const ext = path.extname(file).slice(1).toLowerCase();
4308
4324
  return MIME_TYPES[ext] ?? "application/octet-stream";
4309
4325
  }
4326
+ function isUnderRoot(absRoot, target) {
4327
+ return target === absRoot || target.startsWith(absRoot + path.sep);
4328
+ }
4329
+ function hasDotfileSegment(absRoot, target) {
4330
+ const rel = path.relative(absRoot, target);
4331
+ if (rel.length === 0) return false;
4332
+ return rel.split(/[/\\]/).some((s) => s.length > 0 && s.startsWith("."));
4333
+ }
4310
4334
  function makeEtag(stats) {
4311
4335
  return `W/"${stats.size.toString(16)}-${Math.floor(stats.mtimeMs).toString(16)}"`;
4312
4336
  }
@@ -4356,15 +4380,11 @@ function staticMiddleware(root, opts = {}) {
4356
4380
  }
4357
4381
  const joined = path.join(absRoot, urlPath);
4358
4382
  const resolved = path.resolve(joined);
4359
- const isUnderRoot = resolved === absRoot || resolved.startsWith(absRoot + path.sep);
4360
- if (!isUnderRoot) {
4383
+ if (!isUnderRoot(absRoot, resolved)) {
4361
4384
  ctx.status(403).text("Forbidden");
4362
4385
  return;
4363
4386
  }
4364
- const rel = path.relative(absRoot, resolved);
4365
- const segments = rel.length === 0 ? [] : rel.split(/[/\\]/);
4366
- const hasDot = segments.some((s) => s.length > 0 && s.startsWith("."));
4367
- if (hasDot) {
4387
+ if (hasDotfileSegment(absRoot, resolved)) {
4368
4388
  if (dotfiles === "deny") {
4369
4389
  ctx.status(403).text("Forbidden");
4370
4390
  return;
@@ -4412,6 +4432,19 @@ function staticMiddleware(root, opts = {}) {
4412
4432
  if (!stats || !stats.isFile()) {
4413
4433
  return next();
4414
4434
  }
4435
+ if (!isUnderRoot(absRoot, target)) {
4436
+ ctx.status(403).text("Forbidden");
4437
+ return;
4438
+ }
4439
+ if (hasDotfileSegment(absRoot, target)) {
4440
+ if (dotfiles === "deny") {
4441
+ ctx.status(403).text("Forbidden");
4442
+ return;
4443
+ }
4444
+ if (dotfiles === "ignore") {
4445
+ return next();
4446
+ }
4447
+ }
4415
4448
  const etag = makeEtag(stats);
4416
4449
  const lastModified = new Date(stats.mtimeMs).toUTCString();
4417
4450
  ctx.set("etag", etag);
@@ -4470,6 +4503,7 @@ function staticMiddleware(root, opts = {}) {
4470
4503
  }
4471
4504
 
4472
4505
  // src/cors/middleware.ts
4506
+ var IS_DEV4 = process.env.NODE_ENV !== "production";
4473
4507
  var DEFAULT_METHODS = [
4474
4508
  "GET",
4475
4509
  "HEAD",
@@ -4495,7 +4529,9 @@ async function resolveOrigin(spec, reqOrigin, ctx) {
4495
4529
  if (typeof reqOrigin !== "string" || reqOrigin.length === 0) {
4496
4530
  return { value: null, reflected: false };
4497
4531
  }
4498
- if (spec === true) return { value: reqOrigin, reflected: true };
4532
+ if (spec === true) {
4533
+ return reqOrigin === "null" ? { value: null, reflected: true } : { value: reqOrigin, reflected: true };
4534
+ }
4499
4535
  if (typeof spec === "string") {
4500
4536
  return spec === reqOrigin ? { value: reqOrigin, reflected: true } : { value: null, reflected: true };
4501
4537
  }
@@ -4525,10 +4561,29 @@ function corsMiddleware(opts = {}) {
4525
4561
  const maxAge = opts.maxAge;
4526
4562
  const optionsSuccessStatus = opts.optionsSuccessStatus ?? 204;
4527
4563
  if (credentials && origin === "*") {
4528
- throw new Error(
4564
+ throw new IngeniumError(
4565
+ 500,
4566
+ "CORS_CREDENTIALS_WILDCARD",
4529
4567
  "ingenium.cors: `credentials: true` is incompatible with `origin: '*'`. Specify an explicit origin (string, array, regex, or function) instead."
4530
4568
  );
4531
4569
  }
4570
+ if (credentials && origin === true) {
4571
+ throw new IngeniumError(
4572
+ 500,
4573
+ "CORS_CREDENTIALS_WILDCARD",
4574
+ "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)."
4575
+ );
4576
+ }
4577
+ if (IS_DEV4 && credentials && (typeof origin === "function" || origin instanceof RegExp)) {
4578
+ try {
4579
+ process.emitWarning(
4580
+ "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.",
4581
+ { code: "INGENIUM_CORS_CREDENTIALS_REFLECT" }
4582
+ );
4583
+ } catch {
4584
+ }
4585
+ }
4586
+ const originIsFunction = typeof origin === "function";
4532
4587
  const methodsHeader = methods.join(",");
4533
4588
  const exposedHeader = exposedHeaders && exposedHeaders.length > 0 ? exposedHeaders.join(",") : void 0;
4534
4589
  const allowedHeader = allowedHeaders && allowedHeaders.length > 0 ? allowedHeaders.join(",") : void 0;
@@ -4541,7 +4596,7 @@ function corsMiddleware(opts = {}) {
4541
4596
  reqOriginStr,
4542
4597
  ctx
4543
4598
  );
4544
- if (reflected) appendVary(ctx, "Origin");
4599
+ if (reflected || originIsFunction) appendVary(ctx, "Origin");
4545
4600
  if (allowOrigin !== null) {
4546
4601
  ctx.set("access-control-allow-origin", allowOrigin);
4547
4602
  if (credentials) {
@@ -4754,6 +4809,8 @@ var IngeniumCsrfError = class extends IngeniumError {
4754
4809
  super(403, "CSRF_FAILED", message);
4755
4810
  }
4756
4811
  };
4812
+ var IS_DEV5 = process.env.NODE_ENV !== "production";
4813
+ var CRLF_RE2 = /[\r\n]/;
4757
4814
  var TOKEN_BYTES = 18;
4758
4815
  var SAFE_METHODS_DEFAULT = ["GET", "HEAD", "OPTIONS", "TRACE"];
4759
4816
  var COOKIE_NAME_DEFAULT = "ingenium.csrf";
@@ -4768,10 +4825,20 @@ function csrfMiddleware(opts = {}) {
4768
4825
  await next();
4769
4826
  return;
4770
4827
  }
4771
- let expected = readExpectedToken(ctx, resolved);
4828
+ const binding = resolved.storage === "cookie" && resolved.sessionBinding ? resolved.sessionBinding(ctx) : void 0;
4829
+ if (IS_DEV5 && resolved.storage === "cookie" && resolved.secrets.length > 0 && binding === void 0) {
4830
+ try {
4831
+ process.emitWarning(
4832
+ "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.",
4833
+ { type: "IngeniumCsrfUnboundTokenWarning" }
4834
+ );
4835
+ } catch {
4836
+ }
4837
+ }
4838
+ let expected = readExpectedToken(ctx, resolved, binding);
4772
4839
  let mintedThisRequest = false;
4773
4840
  if (!expected) {
4774
- expected = mintToken(resolved);
4841
+ expected = mintToken(resolved, binding);
4775
4842
  mintedThisRequest = true;
4776
4843
  }
4777
4844
  ctx.state.csrfToken = expected;
@@ -4791,14 +4858,15 @@ function csrfMiddleware(opts = {}) {
4791
4858
  }
4792
4859
  };
4793
4860
  }
4794
- function mintToken(opts) {
4861
+ function mintToken(opts, binding) {
4795
4862
  const raw = randomBytes(TOKEN_BYTES).toString("base64url");
4796
4863
  if (opts.storage === "session" || opts.secrets.length === 0) return raw;
4797
- const sig = signToken(raw, opts.secrets[0]);
4864
+ const sig = signToken(raw, opts.secrets[0], binding);
4798
4865
  return `${raw}.${sig}`;
4799
4866
  }
4800
- function signToken(raw, secret) {
4801
- return createHmac("sha256", secret).update(raw).digest("base64url");
4867
+ function signToken(raw, secret, binding) {
4868
+ const message = binding === void 0 ? raw : `${raw}.${binding}`;
4869
+ return createHmac("sha256", secret).update(message).digest("base64url");
4802
4870
  }
4803
4871
  function tokenMatches(submitted, expected) {
4804
4872
  const a = Buffer$1.from(submitted);
@@ -4806,24 +4874,24 @@ function tokenMatches(submitted, expected) {
4806
4874
  if (a.length !== b.length) return false;
4807
4875
  return timingSafeEqual(a, b);
4808
4876
  }
4809
- function verifySignedToken(token, secrets) {
4877
+ function verifySignedToken(token, secrets, binding) {
4810
4878
  const dot = token.lastIndexOf(".");
4811
4879
  if (dot <= 0) return false;
4812
4880
  const raw = token.slice(0, dot);
4813
4881
  const sig = token.slice(dot + 1);
4814
4882
  for (const secret of secrets) {
4815
- const expected = signToken(raw, secret);
4883
+ const expected = signToken(raw, secret, binding);
4816
4884
  if (expected.length !== sig.length) continue;
4817
4885
  if (timingSafeEqual(Buffer$1.from(expected), Buffer$1.from(sig))) return true;
4818
4886
  }
4819
4887
  return false;
4820
4888
  }
4821
- function readExpectedToken(ctx, opts) {
4889
+ function readExpectedToken(ctx, opts, binding) {
4822
4890
  if (opts.storage === "cookie") {
4823
4891
  const cookies = parseCookies(ctx.headers["cookie"]);
4824
4892
  const token2 = cookies[opts.cookie.name];
4825
4893
  if (!token2) return null;
4826
- if (opts.secrets.length > 0 && !verifySignedToken(token2, opts.secrets)) return null;
4894
+ if (opts.secrets.length > 0 && !verifySignedToken(token2, opts.secrets, binding)) return null;
4827
4895
  return token2;
4828
4896
  }
4829
4897
  const session = ctx.session;
@@ -4849,6 +4917,11 @@ function writeSession(ctx, token) {
4849
4917
  session.set("csrfToken", token);
4850
4918
  }
4851
4919
  function appendSetCookie2(ctx, value) {
4920
+ if (CRLF_RE2.test(value)) {
4921
+ throw new IngeniumHeaderInjectionError(
4922
+ "csrfMiddleware: Set-Cookie value contains CR/LF (possible header injection)"
4923
+ );
4924
+ }
4852
4925
  const existing = ctx._headers["set-cookie"];
4853
4926
  if (!existing) {
4854
4927
  ctx._headers["set-cookie"] = [value];
@@ -4884,13 +4957,37 @@ function resolveOptions(opts) {
4884
4957
  path: opts.cookie?.path ?? "/",
4885
4958
  domain: opts.cookie?.domain ?? "",
4886
4959
  sameSite: opts.cookie?.sameSite ?? "lax",
4887
- secure: opts.cookie?.secure ?? false,
4960
+ // Secure-by-default: a plaintext CSRF cookie is readable by a network
4961
+ // attacker, who can then forge the double-submit header.
4962
+ secure: opts.cookie?.secure ?? true,
4888
4963
  httpOnly: opts.cookie?.httpOnly ?? false,
4889
4964
  maxAgeSeconds: opts.cookie?.maxAgeSeconds ?? 7 * 24 * 60 * 60
4890
4965
  };
4966
+ if (CRLF_RE2.test(cookie.path) || CRLF_RE2.test(cookie.domain) || CRLF_RE2.test(cookie.name)) {
4967
+ throw new IngeniumHeaderInjectionError(
4968
+ "csrfMiddleware: cookie name/path/domain contains CR/LF (possible header injection)"
4969
+ );
4970
+ }
4971
+ if (storage === "cookie" && cookie.secure === false && IS_DEV5) {
4972
+ try {
4973
+ process.emitWarning(
4974
+ "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.",
4975
+ { type: "IngeniumCsrfInsecureCookieWarning" }
4976
+ );
4977
+ } catch {
4978
+ }
4979
+ }
4891
4980
  const ignoreMethods = new Set((opts.ignoreMethods ?? SAFE_METHODS_DEFAULT).map((m) => m.toUpperCase()));
4892
4981
  const value = opts.value ?? defaultValueReader;
4893
- return { secrets, storage, cookie, ignoreMethods, value, skip: opts.skip ?? null };
4982
+ return {
4983
+ secrets,
4984
+ storage,
4985
+ cookie,
4986
+ ignoreMethods,
4987
+ value,
4988
+ skip: opts.skip ?? null,
4989
+ sessionBinding: opts.sessionBinding ?? null
4990
+ };
4894
4991
  }
4895
4992
  var defaultValueReader = (ctx) => {
4896
4993
  for (const name of HEADER_NAMES_DEFAULT) {
@@ -4902,6 +4999,7 @@ var defaultValueReader = (ctx) => {
4902
4999
  };
4903
5000
 
4904
5001
  // src/problem/serialize.ts
5002
+ var IS_DEV6 = process.env.NODE_ENV !== "production";
4905
5003
  var TITLES = Object.freeze({
4906
5004
  NOT_FOUND: "Not Found",
4907
5005
  UNAUTHORIZED: "Unauthorized",
@@ -4959,12 +5057,14 @@ function toProblemDetails(err, opts, ctx) {
4959
5057
  }
4960
5058
  return problem2;
4961
5059
  }
5060
+ const exposeMessage = IS_DEV6 || opts.includeStack;
4962
5061
  const message = err?.message;
5062
+ const detail = exposeMessage && typeof message === "string" && message.length > 0 ? message : "Internal Server Error";
4963
5063
  const problem = {
4964
5064
  type: "about:blank",
4965
5065
  title: STATUS_REASON[500],
4966
5066
  status: 500,
4967
- detail: typeof message === "string" && message.length > 0 ? message : "Internal Server Error"
5067
+ detail
4968
5068
  };
4969
5069
  const instance = opts.instance(ctx);
4970
5070
  if (instance !== void 0) problem.instance = instance;
@@ -5047,14 +5147,23 @@ var IdempotencyMemoryStore = class {
5047
5147
  };
5048
5148
 
5049
5149
  // src/idempotency/middleware.ts
5150
+ var IS_DEV7 = process.env.NODE_ENV !== "production";
5050
5151
  var DEFAULT_METHODS2 = ["POST", "PATCH", "DELETE"];
5051
5152
  var DEFAULT_CACHEABLE = (status) => status >= 200 && status < 500;
5153
+ var ANON_SCOPE = /* @__PURE__ */ Symbol("ingenium.idempotency.anon");
5052
5154
  function defaultScope(ctx) {
5053
5155
  const auth = ctx.headers["authorization"];
5054
5156
  if (typeof auth === "string" && auth.length > 0) return auth;
5055
5157
  if (Array.isArray(auth) && auth.length > 0 && typeof auth[0] === "string") return auth[0];
5056
- return "anon";
5057
- }
5158
+ return ANON_SCOPE;
5159
+ }
5160
+ var SENSITIVE_REPLAY_HEADERS = [
5161
+ "set-cookie",
5162
+ "authorization",
5163
+ "proxy-authorization",
5164
+ "www-authenticate",
5165
+ "proxy-authenticate"
5166
+ ];
5058
5167
  function readHeader2(ctx, lowerName) {
5059
5168
  const v = ctx.headers[lowerName];
5060
5169
  if (typeof v === "string") return v;
@@ -5092,6 +5201,7 @@ function replay(ctx, cached) {
5092
5201
  for (const k of Object.keys(cached.headers)) {
5093
5202
  const v = cached.headers[k];
5094
5203
  if (v === void 0) continue;
5204
+ if (SENSITIVE_REPLAY_HEADERS.includes(k.toLowerCase())) continue;
5095
5205
  ctx._headers[k] = Array.isArray(v) ? [...v] : v;
5096
5206
  }
5097
5207
  ctx._headers["idempotent-replayed"] = "true";
@@ -5107,17 +5217,20 @@ function replay(ctx, cached) {
5107
5217
  ctx._written = true;
5108
5218
  }
5109
5219
  function idempotencyMiddleware(opts = {}) {
5220
+ const usingDefaultScope = opts.scope === void 0;
5221
+ const scopeFn = opts.scope ?? defaultScope;
5110
5222
  const resolved = {
5111
5223
  header: (opts.header ?? "Idempotency-Key").toLowerCase(),
5112
5224
  store: opts.store ?? new IdempotencyMemoryStore(),
5113
5225
  ttlMs: (opts.ttlSeconds ?? 86400) * 1e3,
5114
- scope: opts.scope ?? defaultScope,
5226
+ scope: scopeFn,
5115
5227
  methodSet: new Set(opts.methods ?? DEFAULT_METHODS2),
5116
5228
  cacheable: opts.cacheable ?? DEFAULT_CACHEABLE
5117
5229
  };
5118
5230
  if (resolved.ttlMs <= 0) {
5119
5231
  throw new Error("idempotency: ttlSeconds must be > 0");
5120
5232
  }
5233
+ let anonBypassWarned = false;
5121
5234
  const inflight = /* @__PURE__ */ new Map();
5122
5235
  return async (ctx, next) => {
5123
5236
  if (!resolved.methodSet.has(ctx.method)) {
@@ -5127,7 +5240,20 @@ function idempotencyMiddleware(opts = {}) {
5127
5240
  if (!headerValue || headerValue.length === 0) {
5128
5241
  return next();
5129
5242
  }
5130
- const scope = resolved.scope(ctx);
5243
+ const scope = scopeFn(ctx);
5244
+ if (scope === ANON_SCOPE) {
5245
+ if (IS_DEV7 && usingDefaultScope && !anonBypassWarned) {
5246
+ anonBypassWarned = true;
5247
+ try {
5248
+ process.emitWarning(
5249
+ "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.",
5250
+ { type: "IngeniumIdempotencyWarning" }
5251
+ );
5252
+ } catch {
5253
+ }
5254
+ }
5255
+ return next();
5256
+ }
5131
5257
  const cacheKey = `${scope}:${ctx.method}:${ctx.path}:${headerValue}`;
5132
5258
  const existing = await resolved.store.get(cacheKey);
5133
5259
  if (existing) {
@@ -5192,11 +5318,23 @@ function decodeJsonSegment(segment) {
5192
5318
  return null;
5193
5319
  }
5194
5320
  }
5321
+ function isSymmetricKey(key) {
5322
+ if (typeof key === "string") return !looksLikePem(key);
5323
+ if (Buffer$1.isBuffer(key)) return !looksLikePem(key.toString("latin1"));
5324
+ return key.type === "secret";
5325
+ }
5326
+ function looksLikePem(s) {
5327
+ return s.trimStart().startsWith("-----BEGIN");
5328
+ }
5195
5329
  function hmacVerifies(digest, secret, signingInput, sig) {
5196
- const secretInput = typeof secret === "string" || Buffer$1.isBuffer(secret) ? secret : secret.export({ format: "buffer" });
5197
- const expected = createHmac(digest, secretInput).update(signingInput).digest();
5198
- if (sig.length !== expected.length) return false;
5199
- return timingSafeEqual(sig, expected);
5330
+ try {
5331
+ const secretInput = typeof secret === "string" || Buffer$1.isBuffer(secret) ? secret : secret.export({ format: "buffer" });
5332
+ const expected = createHmac(digest, secretInput).update(signingInput).digest();
5333
+ if (sig.length !== expected.length) return false;
5334
+ return timingSafeEqual(sig, expected);
5335
+ } catch {
5336
+ return false;
5337
+ }
5200
5338
  }
5201
5339
  function asymmetricVerifies(spec, keyMaterial, signingInput, sig) {
5202
5340
  let key;
@@ -5234,6 +5372,9 @@ function asymmetricVerifies(spec, keyMaterial, signingInput, sig) {
5234
5372
  return false;
5235
5373
  }
5236
5374
  }
5375
+ function isFiniteNumber(v) {
5376
+ return typeof v === "number" && Number.isFinite(v);
5377
+ }
5237
5378
  function audienceMatches(claim, expected) {
5238
5379
  const wanted = typeof expected === "string" ? [expected] : expected;
5239
5380
  if (typeof claim === "string") return wanted.includes(claim);
@@ -5285,12 +5426,15 @@ function verifyJwt(token, keys, opts) {
5285
5426
  }
5286
5427
  let signatureOk = false;
5287
5428
  for (const candidate of candidates) {
5429
+ const candidateIsSymmetric = isSymmetricKey(candidate);
5288
5430
  if (spec.family === "hmac") {
5431
+ if (!candidateIsSymmetric) continue;
5289
5432
  if (hmacVerifies(spec.digest, candidate, signingInput, sig)) {
5290
5433
  signatureOk = true;
5291
5434
  break;
5292
5435
  }
5293
5436
  } else {
5437
+ if (candidateIsSymmetric) continue;
5294
5438
  if (asymmetricVerifies(spec, candidate, signingInput, sig)) {
5295
5439
  signatureOk = true;
5296
5440
  break;
@@ -5301,14 +5445,20 @@ function verifyJwt(token, keys, opts) {
5301
5445
  const now = (opts.nowSeconds ?? (() => Math.floor(Date.now() / 1e3)))();
5302
5446
  const skew = opts.clockSkewSeconds ?? 5;
5303
5447
  const claims = payload;
5304
- if (typeof claims.exp === "number") {
5448
+ if ("exp" in claims && !isFiniteNumber(claims.exp)) return { error: "malformed" };
5449
+ if ("nbf" in claims && !isFiniteNumber(claims.nbf)) return { error: "malformed" };
5450
+ if ("iat" in claims && !isFiniteNumber(claims.iat)) return { error: "malformed" };
5451
+ const requireExp = opts.requireExp ?? true;
5452
+ if (isFiniteNumber(claims.exp)) {
5305
5453
  if (claims.exp <= now - skew) return { error: "expired" };
5454
+ } else if (requireExp) {
5455
+ return { error: "missing_exp" };
5306
5456
  }
5307
- if (typeof claims.nbf === "number") {
5457
+ if (isFiniteNumber(claims.nbf)) {
5308
5458
  if (claims.nbf > now + skew) return { error: "not_yet_valid" };
5309
5459
  }
5310
5460
  if (typeof opts.maxAgeSeconds === "number") {
5311
- if (typeof claims.iat !== "number") return { error: "too_old" };
5461
+ if (!isFiniteNumber(claims.iat)) return { error: "too_old" };
5312
5462
  if (claims.iat + opts.maxAgeSeconds <= now - skew) return { error: "too_old" };
5313
5463
  }
5314
5464
  if (opts.audience !== void 0) {
@@ -5416,6 +5566,7 @@ async function doFetch(url) {
5416
5566
 
5417
5567
  // src/jwt/middleware.ts
5418
5568
  var DEFAULT_ALGORITHMS = ["HS256"];
5569
+ var DEFAULT_ASYMMETRIC_ALGORITHMS = ["RS256"];
5419
5570
  var DEFAULT_JWKS_TTL_MS = 10 * 60 * 1e3;
5420
5571
  var SUPPORTED = /* @__PURE__ */ new Set([
5421
5572
  "HS256",
@@ -5431,6 +5582,14 @@ var SUPPORTED = /* @__PURE__ */ new Set([
5431
5582
  "ES384",
5432
5583
  "ES512"
5433
5584
  ]);
5585
+ var IngeniumJwtKeyAlgMismatchError = class extends IngeniumError {
5586
+ constructor(message) {
5587
+ super(500, "JWT_KEY_ALG_MISMATCH", message);
5588
+ }
5589
+ };
5590
+ function isHmacAlg(alg) {
5591
+ return alg === "HS256" || alg === "HS384" || alg === "HS512";
5592
+ }
5434
5593
  var defaultGetToken = (ctx) => {
5435
5594
  const raw = ctx.headers["authorization"];
5436
5595
  if (!raw) return void 0;
@@ -5453,7 +5612,8 @@ function jwtMiddleware(opts) {
5453
5612
  throw new Error("jwtMiddleware: `secret` (or `jwksUrl`) is required");
5454
5613
  }
5455
5614
  }
5456
- const algorithms = (opts.algorithms ?? DEFAULT_ALGORITHMS).slice();
5615
+ const defaultAlgorithms = hasJwks ? DEFAULT_ASYMMETRIC_ALGORITHMS : DEFAULT_ALGORITHMS;
5616
+ const algorithms = (opts.algorithms ?? defaultAlgorithms).slice();
5457
5617
  if (algorithms.length === 0) {
5458
5618
  throw new Error("jwtMiddleware: `algorithms` must contain at least one algorithm");
5459
5619
  }
@@ -5467,6 +5627,7 @@ function jwtMiddleware(opts) {
5467
5627
  }
5468
5628
  const required = opts.required ?? true;
5469
5629
  const clockSkewSeconds = opts.clockSkewSeconds ?? 5;
5630
+ const requireExp = opts.requireExp ?? true;
5470
5631
  const jwksCacheMs = opts.jwksCacheMs ?? DEFAULT_JWKS_TTL_MS;
5471
5632
  const jwksUrl = hasJwks ? opts.jwksUrl : null;
5472
5633
  const getToken = opts.getToken ?? defaultGetToken;
@@ -5475,6 +5636,15 @@ function jwtMiddleware(opts) {
5475
5636
  });
5476
5637
  const staticKeys = opts.secret != null && typeof opts.secret !== "function" ? normaliseStaticKeys(opts.secret) : [];
5477
5638
  const keyResolver = typeof opts.secret === "function" ? opts.secret : null;
5639
+ if (algorithms.some(isHmacAlg)) {
5640
+ for (const k of staticKeys) {
5641
+ if (staticKeyIsAsymmetric(k)) {
5642
+ throw new IngeniumJwtKeyAlgMismatchError(
5643
+ "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."
5644
+ );
5645
+ }
5646
+ }
5647
+ }
5478
5648
  return async (ctx, next) => {
5479
5649
  const token = await getToken(ctx);
5480
5650
  if (!token) {
@@ -5504,7 +5674,7 @@ function jwtMiddleware(opts) {
5504
5674
  logger({ reason: "no_keys_available" });
5505
5675
  throw new IngeniumUnauthorizedError("Invalid token");
5506
5676
  }
5507
- const verifyOpts = { algorithms, clockSkewSeconds };
5677
+ const verifyOpts = { algorithms, clockSkewSeconds, requireExp };
5508
5678
  if (opts.audience !== void 0) verifyOpts.audience = opts.audience;
5509
5679
  if (opts.issuer !== void 0) verifyOpts.issuer = opts.issuer;
5510
5680
  if (opts.maxAgeSeconds !== void 0) verifyOpts.maxAgeSeconds = opts.maxAgeSeconds;
@@ -5550,6 +5720,15 @@ function coerceJwtKey(k) {
5550
5720
  }
5551
5721
  throw new Error("jwtMiddleware: invalid `secret` entry \u2014 expected string, Buffer, KeyObject, or { kid, key }");
5552
5722
  }
5723
+ function staticKeyIsAsymmetric(entry) {
5724
+ const key = typeof entry === "object" && entry !== null && !Buffer$1.isBuffer(entry) && "key" in entry ? entry.key : entry;
5725
+ if (typeof key === "string") return looksLikePem2(key);
5726
+ if (Buffer$1.isBuffer(key)) return looksLikePem2(key.toString("latin1"));
5727
+ return key.type === "public" || key.type === "private";
5728
+ }
5729
+ function looksLikePem2(s) {
5730
+ return s.trimStart().startsWith("-----BEGIN");
5731
+ }
5553
5732
  function isKeyObject(v) {
5554
5733
  if (typeof v !== "object" || v === null) return false;
5555
5734
  const t = v.type;
@@ -6379,6 +6558,7 @@ var MemoryStore2 = class {
6379
6558
  };
6380
6559
 
6381
6560
  // src/session/middleware.ts
6561
+ var IS_DEV8 = process.env.NODE_ENV !== "production";
6382
6562
  var DEFAULT_COOKIE_NAME = "ingenium.sid";
6383
6563
  var DEFAULT_MAX_AGE_SECONDS = 60 * 60 * 24 * 7;
6384
6564
  var ID_BYTES = 18;
@@ -6514,8 +6694,19 @@ function sessionMiddleware(opts) {
6514
6694
  const cookieName = opts.cookieName ?? DEFAULT_COOKIE_NAME;
6515
6695
  const maxAgeSeconds = opts.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
6516
6696
  const rolling = opts.rolling ?? false;
6517
- const cookieOpts = opts.cookie ?? {};
6697
+ const baseCookieOpts = opts.cookie ?? {};
6518
6698
  const store = opts.store ?? new MemoryStore2();
6699
+ const resolvedSecure = baseCookieOpts.secure ?? !IS_DEV8;
6700
+ const cookieOpts = { ...baseCookieOpts, secure: resolvedSecure };
6701
+ if (!IS_DEV8 && resolvedSecure === false) {
6702
+ try {
6703
+ process.emitWarning(
6704
+ "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.",
6705
+ { type: "IngeniumSessionInsecureCookieWarning" }
6706
+ );
6707
+ } catch {
6708
+ }
6709
+ }
6519
6710
  return async (ctx, next) => {
6520
6711
  const cookies = parseCookieHeader2(ctx.headers.cookie);
6521
6712
  const raw = cookies[cookieName];
@@ -6594,6 +6785,7 @@ async function commit(ctx, session, signingSecret, cookieName, maxAgeSeconds, ro
6594
6785
  }
6595
6786
 
6596
6787
  // src/ws/middleware.ts
6788
+ var IS_DEV9 = process.env.NODE_ENV !== "production";
6597
6789
  async function peerHasWs() {
6598
6790
  try {
6599
6791
  await import('ws');
@@ -6612,6 +6804,14 @@ function createWebSocketRegistrar() {
6612
6804
  if (routes.has(path2)) {
6613
6805
  throw new Error(`ingenium.ws: path "${path2}" already has a WebSocket handler`);
6614
6806
  }
6807
+ if (IS_DEV9 && options2.origin === void 0) {
6808
+ try {
6809
+ process.emitWarning(
6810
+ `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).`
6811
+ );
6812
+ } catch {
6813
+ }
6814
+ }
6615
6815
  routes.set(path2, { path: path2, handler, options: options2 });
6616
6816
  }
6617
6817
  function attach(httpServer) {
@@ -6629,6 +6829,14 @@ function createWebSocketRegistrar() {
6629
6829
  socket.destroy();
6630
6830
  return;
6631
6831
  }
6832
+ if (route.options.origin !== void 0) {
6833
+ const origin = req.headers.origin;
6834
+ if (!isOriginAllowed(route.options.origin, origin, req)) {
6835
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
6836
+ socket.destroy();
6837
+ return;
6838
+ }
6839
+ }
6632
6840
  void (async () => {
6633
6841
  try {
6634
6842
  if (wsModule === null) wsModule = await import('ws');
@@ -6700,6 +6908,22 @@ function createWebSocketRegistrar() {
6700
6908
  }
6701
6909
  return { add, attach, close };
6702
6910
  }
6911
+ function isOriginAllowed(policy, origin, req) {
6912
+ if (typeof policy === "function") return policy(origin, req);
6913
+ if (policy === false) return true;
6914
+ if (policy === true) {
6915
+ if (origin === void 0) return false;
6916
+ let originHost;
6917
+ try {
6918
+ originHost = new URL(origin).host;
6919
+ } catch {
6920
+ return false;
6921
+ }
6922
+ return originHost === req.headers.host;
6923
+ }
6924
+ if (origin === void 0) return false;
6925
+ return Array.isArray(policy) ? policy.includes(origin) : policy === origin;
6926
+ }
6703
6927
  function buildMinimalContext(req, path2) {
6704
6928
  const ctx = new IngeniumContext();
6705
6929
  ctx.method = req.method ?? "GET";
@@ -6958,6 +7182,6 @@ var ingenium = Object.assign(ingeniumCore, {
6958
7182
  });
6959
7183
  var index_default = ingenium;
6960
7184
 
6961
- export { CronRegistry, DecoratorRegistry, HTTP_METHODS, HooksRegistry, Http2Adapter, Http2cAdapter, IdempotencyMemoryStore, IngeniumApp, IngeniumBadRequestError, IngeniumBody, IngeniumContext, IngeniumContextPool, IngeniumCronJob, IngeniumCsrfError, IngeniumError, IngeniumHaltError, IngeniumHeaderInjectionError, IngeniumMethodNotAllowedError, IngeniumNotFoundError, IngeniumPayloadTooLargeError, IngeniumQueue, IngeniumTimeoutError, IngeniumUnauthorizedError, IngeniumUnserializableError, IngeniumValidationError, MemoryQueueStore, NodeAdapter, QueueRegistry, MemoryStore as RateLimitMemoryStore, RouteBuilder, Router, RouterTrie, ScopedApp, MemoryStore2 as SessionMemoryStore, TrieNode, WsNodeAdapter, _resetDefaultApp, accepts, acceptsCharsets, acceptsEncodings, acceptsLanguages, after, apiKeyMiddleware, before, clearJwksCache, compose, composeWithHandler, computeEtag, corsMiddleware as cors_, createWebSocketRegistrar, csrfMiddleware, index_default as default, defaultApp, del as delete, enableWebSockets, expandShorthand, fetchJwks, formatResponse, generateOpenApi, get, gracefulShutdown, head, idempotencyMiddleware, ingenium, isFresh, isStandardSchema, jwtMiddleware, listen, nextFireFrom, onError, openapiHandler, options, parseAcceptHeader, parseCronSpec, patch, peerHasWs, post, problemDetailsMiddleware, put, rateLimit, resolveForwarded, respondJsonWithEtag, safeJsonStringify, selectBest, sessionMiddleware, sortByPreference, sse, startKeepAlive, staticMiddleware as static_, toProblemDetails, use, verifyJwt };
7185
+ export { CronRegistry, DecoratorRegistry, HTTP_METHODS, HooksRegistry, Http2Adapter, Http2cAdapter, IdempotencyMemoryStore, IngeniumApp, IngeniumBadRequestError, IngeniumBody, IngeniumContext, IngeniumContextPool, IngeniumCronJob, IngeniumCsrfError, IngeniumError, IngeniumHaltError, IngeniumHeaderInjectionError, IngeniumJwtKeyAlgMismatchError, IngeniumMethodNotAllowedError, IngeniumNotFoundError, IngeniumPayloadTooLargeError, IngeniumQueue, IngeniumTimeoutError, IngeniumUnauthorizedError, IngeniumUnserializableError, IngeniumValidationError, MemoryQueueStore, NodeAdapter, QueueRegistry, MemoryStore as RateLimitMemoryStore, RouteBuilder, Router, RouterTrie, ScopedApp, MemoryStore2 as SessionMemoryStore, TrieNode, WsNodeAdapter, _resetDefaultApp, accepts, acceptsCharsets, acceptsEncodings, acceptsLanguages, after, apiKeyMiddleware, before, clearJwksCache, compose, composeWithHandler, computeEtag, corsMiddleware as cors_, createWebSocketRegistrar, csrfMiddleware, index_default as default, defaultApp, del as delete, enableWebSockets, expandShorthand, fetchJwks, formatResponse, generateOpenApi, get, gracefulShutdown, head, idempotencyMiddleware, ingenium, isFresh, isStandardSchema, jwtMiddleware, listen, nextFireFrom, onError, openapiHandler, options, parseAcceptHeader, parseCronSpec, patch, peerHasWs, post, problemDetailsMiddleware, put, rateLimit, resolveForwarded, respondJsonWithEtag, safeJsonStringify, selectBest, sessionMiddleware, sortByPreference, sse, startKeepAlive, staticMiddleware as static_, toProblemDetails, use, verifyJwt };
6962
7186
  //# sourceMappingURL=index.js.map
6963
7187
  //# sourceMappingURL=index.js.map