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/README.md +1 -1
- package/dist/index.cjs +268 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +105 -9
- package/dist/index.d.ts +105 -9
- package/dist/index.js +268 -44
- package/dist/index.js.map +1 -1
- package/package.json +23 -8
- package/src/app.ts +7 -1
- package/src/body/multipart.ts +9 -1
- package/src/cors/middleware.ts +58 -3
- package/src/cors/types.ts +6 -3
- package/src/csrf/middleware.ts +98 -13
- package/src/csrf/types.ts +22 -1
- package/src/idempotency/middleware.ts +78 -5
- package/src/index.ts +1 -1
- package/src/jwt/middleware.ts +74 -3
- package/src/jwt/types.ts +12 -0
- package/src/jwt/verify.ts +75 -11
- package/src/problem/serialize.ts +18 -2
- package/src/proxy/trust.ts +22 -0
- package/src/session/middleware.ts +36 -3
- package/src/session/types.ts +14 -1
- package/src/static/middleware.ts +43 -8
- package/src/ws/index.ts +2 -0
- package/src/ws/middleware.ts +70 -0
- package/src/ws/types.ts +37 -0
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 = {
|
|
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
|
|
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 (
|
|
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 (!
|
|
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
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
4384
|
-
if (!isUnderRoot) {
|
|
4407
|
+
if (!isUnderRoot(absRoot, resolved)) {
|
|
4385
4408
|
ctx.status(403).text("Forbidden");
|
|
4386
4409
|
return;
|
|
4387
4410
|
}
|
|
4388
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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;
|