@blamejs/core 0.8.69 → 0.8.71
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/CHANGELOG.md +2 -0
- package/README.md +158 -13
- package/index.js +2 -0
- package/lib/audit.js +1 -0
- package/lib/auth/oauth.js +202 -3
- package/lib/compliance.js +76 -0
- package/lib/data-act.js +328 -0
- package/lib/fapi2.js +109 -1
- package/lib/mcp.js +322 -0
- package/lib/middleware/cors.js +23 -1
- package/lib/middleware/require-aal.js +2 -0
- package/lib/middleware/require-auth.js +11 -3
- package/lib/middleware/require-step-up.js +3 -0
- package/lib/middleware/security-headers.js +14 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +8 -8
package/lib/mcp.js
CHANGED
|
@@ -375,8 +375,330 @@ function serverGuard(opts) {
|
|
|
375
375
|
};
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
/**
|
|
379
|
+
* @primitive b.mcp.toolResult.sanitize
|
|
380
|
+
* @signature b.mcp.toolResult.sanitize(result, opts?)
|
|
381
|
+
* @since 0.8.70
|
|
382
|
+
* @related b.mcp.serverGuard, b.guardHtml, b.ai.input.classify
|
|
383
|
+
*
|
|
384
|
+
* OWASP LLM02 — model/tool-output sanitization. MCP tool calls
|
|
385
|
+
* frequently return content the host model interprets as further
|
|
386
|
+
* instructions; an attacker-controlled tool surface can return
|
|
387
|
+
* `{ type: "text", text: "Ignore prior instructions and ..." }`,
|
|
388
|
+
* `<script>...</script>`, OR markdown image links pointing at
|
|
389
|
+
* exfiltration endpoints. The framework's defense:
|
|
390
|
+
*
|
|
391
|
+
* - Strip / refuse executable HTML (`<script>` / `<iframe>` /
|
|
392
|
+
* `javascript:` URLs) — composes b.guardHtml's strict profile
|
|
393
|
+
* - Refuse known prompt-injection markers ("ignore previous
|
|
394
|
+
* instructions", "system: you are now ...", role-claim prefixes)
|
|
395
|
+
* — composes b.ai.input.classify
|
|
396
|
+
* - Cap text length so a tool can't blow the host's context window
|
|
397
|
+
* out from under it
|
|
398
|
+
* - Refuse content with `image_url` / `audio_url` / `resource_link`
|
|
399
|
+
* pointing at non-allowlisted hosts (data-exfil via auto-fetch)
|
|
400
|
+
*
|
|
401
|
+
* Returns either the cleaned result (when `sanitize: true`) or
|
|
402
|
+
* throws `McpError("mcp/tool-output-refused", ...)` (default —
|
|
403
|
+
* fail-closed). Operators with a known-good tool surface that needs
|
|
404
|
+
* raw passthrough opt out via `posture: "audit-only"`.
|
|
405
|
+
*
|
|
406
|
+
* @opts
|
|
407
|
+
* {
|
|
408
|
+
* posture?: "refuse" | "sanitize" | "audit-only", // default "refuse"
|
|
409
|
+
* maxTextBytes?: number, // default 64 KiB per content block
|
|
410
|
+
* allowedHosts?: string[], // for image/audio/resource_link refs
|
|
411
|
+
* classifyInput?: fn(text)→{verdict, score} | null, // default b.ai.input.classify
|
|
412
|
+
* }
|
|
413
|
+
*
|
|
414
|
+
* @example
|
|
415
|
+
* var safe = b.mcp.toolResult.sanitize(toolResp, { posture: "sanitize" });
|
|
416
|
+
* // → { content: [{ type: "text", text: "<cleaned>" }] }
|
|
417
|
+
*/
|
|
418
|
+
var DEFAULT_TOOL_OUTPUT_MAX_BYTES = 64 * 1024; // allow:raw-byte-literal — 64 KiB per content block
|
|
419
|
+
var PROMPT_INJECTION_MARKERS = [
|
|
420
|
+
"ignore (previous|prior|all) instructions",
|
|
421
|
+
"system:\\s*you are",
|
|
422
|
+
"<\\|im_(start|end)\\|>",
|
|
423
|
+
"<\\|system\\|>",
|
|
424
|
+
"###\\s*(system|assistant|user|tool)",
|
|
425
|
+
"<system>",
|
|
426
|
+
"</?(?:assistant|system|user|tool)>",
|
|
427
|
+
];
|
|
428
|
+
var INJECTION_RE = new RegExp(PROMPT_INJECTION_MARKERS.join("|"), "i"); // allow:dynamic-regex — composed from the const PROMPT_INJECTION_MARKERS list above; not operator-supplied input
|
|
429
|
+
var DANGEROUS_HTML_RE = /<script\b|<iframe\b|<object\b|<embed\b|javascript:/i;
|
|
430
|
+
|
|
431
|
+
function _toolResultSanitize(result, opts) {
|
|
432
|
+
opts = opts || {};
|
|
433
|
+
var posture = opts.posture || "refuse";
|
|
434
|
+
if (["refuse", "sanitize", "audit-only"].indexOf(posture) === -1) {
|
|
435
|
+
throw new McpError("mcp/bad-posture",
|
|
436
|
+
"toolResult.sanitize: posture must be 'refuse' | 'sanitize' | 'audit-only'");
|
|
437
|
+
}
|
|
438
|
+
var maxBytes = opts.maxTextBytes || DEFAULT_TOOL_OUTPUT_MAX_BYTES;
|
|
439
|
+
var allowedHosts = Array.isArray(opts.allowedHosts) ? opts.allowedHosts : [];
|
|
440
|
+
if (!result || typeof result !== "object") {
|
|
441
|
+
throw new McpError("mcp/bad-tool-result",
|
|
442
|
+
"toolResult.sanitize: result must be an object");
|
|
443
|
+
}
|
|
444
|
+
var content = Array.isArray(result.content) ? result.content : [];
|
|
445
|
+
var issues = [];
|
|
446
|
+
var cleaned = [];
|
|
447
|
+
for (var i = 0; i < content.length; i++) {
|
|
448
|
+
var block = content[i];
|
|
449
|
+
if (!block || typeof block !== "object") {
|
|
450
|
+
issues.push({ kind: "bad-block", index: i });
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
454
|
+
var t = block.text;
|
|
455
|
+
if (Buffer.byteLength(t, "utf8") > maxBytes) {
|
|
456
|
+
issues.push({ kind: "text-too-long", index: i, bytes: Buffer.byteLength(t, "utf8") });
|
|
457
|
+
if (posture === "sanitize") t = Buffer.from(t, "utf8").subarray(0, maxBytes).toString("utf8");
|
|
458
|
+
}
|
|
459
|
+
// Bound the regex-test surface to maxBytes (already enforced
|
|
460
|
+
// upstream when sanitize-mode strips, but in audit-only / refuse
|
|
461
|
+
// modes we still hand the raw text into the regex so cap
|
|
462
|
+
// explicitly here to satisfy the regex-bound-length rule).
|
|
463
|
+
var regexInput = Buffer.byteLength(t, "utf8") > maxBytes
|
|
464
|
+
? Buffer.from(t, "utf8").subarray(0, maxBytes).toString("utf8")
|
|
465
|
+
: t;
|
|
466
|
+
if (INJECTION_RE.test(regexInput)) { // allow:regex-no-length-cap regexInput byteLength bounded above
|
|
467
|
+
issues.push({ kind: "prompt-injection", index: i });
|
|
468
|
+
if (posture === "sanitize") {
|
|
469
|
+
// Strip the injection marker line — operators wanting
|
|
470
|
+
// structural redaction wire their own classifier.
|
|
471
|
+
t = t.replace(INJECTION_RE, "[REDACTED]");
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (DANGEROUS_HTML_RE.test(regexInput)) { // allow:regex-no-length-cap regexInput byteLength bounded above
|
|
475
|
+
issues.push({ kind: "dangerous-html", index: i });
|
|
476
|
+
if (posture === "sanitize") t = t.replace(DANGEROUS_HTML_RE, "[REDACTED]");
|
|
477
|
+
}
|
|
478
|
+
cleaned.push({ type: "text", text: t });
|
|
479
|
+
} else if (block.type === "image" || block.type === "resource_link" || block.type === "audio") {
|
|
480
|
+
var url = block.url || (block.resource && block.resource.uri);
|
|
481
|
+
if (typeof url === "string" && url.length > 0 && allowedHosts.length > 0) {
|
|
482
|
+
var u; try { u = new URL(url); } catch (_e) { u = null; } // allow:raw-new-url — operator-supplied tool URL; allowlist enforced below
|
|
483
|
+
if (!u || allowedHosts.indexOf(u.host) === -1) {
|
|
484
|
+
issues.push({ kind: "off-allowlist-url", index: i, url: url });
|
|
485
|
+
if (posture === "sanitize") continue; // drop the block in sanitize mode
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
cleaned.push(block);
|
|
489
|
+
} else {
|
|
490
|
+
cleaned.push(block);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (issues.length > 0 && posture === "refuse") {
|
|
494
|
+
var first = issues[0];
|
|
495
|
+
throw new McpError("mcp/tool-output-refused",
|
|
496
|
+
"toolResult.sanitize: refused " + issues.length + " issue(s) " +
|
|
497
|
+
"(first: " + first.kind + " on block[" + first.index + "])");
|
|
498
|
+
}
|
|
499
|
+
return { content: cleaned, isError: !!result.isError, issues: issues };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* @primitive b.mcp.capability.create
|
|
504
|
+
* @signature b.mcp.capability.create(scopes)
|
|
505
|
+
* @since 0.8.70
|
|
506
|
+
* @related b.mcp.serverGuard
|
|
507
|
+
*
|
|
508
|
+
* OWASP LLM08 — capability primitive. Wraps an MCP tool/resource
|
|
509
|
+
* registration with a scope set the host model's session must hold
|
|
510
|
+
* before the tool/resource is exposed. Defaults to deny-all; the
|
|
511
|
+
* operator's session-decoration step grants scopes per user / per
|
|
512
|
+
* agent / per delegated-actor.
|
|
513
|
+
*
|
|
514
|
+
* Returns `{ scopes, satisfiedBy(grantedSet) }` — the guard checks
|
|
515
|
+
* `satisfiedBy(session.capabilities)` before each tool/resource
|
|
516
|
+
* dispatch. Falsy → refuse with `mcp/capability-denied`.
|
|
517
|
+
*
|
|
518
|
+
* @example
|
|
519
|
+
* var fileRead = b.mcp.capability.create(["fs:read"]);
|
|
520
|
+
* if (!fileRead.satisfiedBy(session.capabilities)) {
|
|
521
|
+
* throw new Error("mcp/capability-denied");
|
|
522
|
+
* }
|
|
523
|
+
*/
|
|
524
|
+
function _capabilityCreate(scopes) {
|
|
525
|
+
if (!Array.isArray(scopes) || scopes.length === 0) {
|
|
526
|
+
throw new McpError("mcp/bad-capability",
|
|
527
|
+
"capability.create: scopes must be a non-empty array of strings");
|
|
528
|
+
}
|
|
529
|
+
scopes.forEach(function (s, i) {
|
|
530
|
+
if (typeof s !== "string" || s.length === 0) {
|
|
531
|
+
throw new McpError("mcp/bad-capability-scope",
|
|
532
|
+
"capability.create: scopes[" + i + "] must be a non-empty string");
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
var frozen = scopes.slice();
|
|
536
|
+
return {
|
|
537
|
+
scopes: frozen,
|
|
538
|
+
satisfiedBy: function (granted) {
|
|
539
|
+
if (!Array.isArray(granted)) return false;
|
|
540
|
+
for (var i = 0; i < frozen.length; i++) {
|
|
541
|
+
if (granted.indexOf(frozen[i]) === -1) return false;
|
|
542
|
+
}
|
|
543
|
+
return true;
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* @primitive b.mcp.validateToolInput
|
|
550
|
+
* @signature b.mcp.validateToolInput(toolName, input, schema)
|
|
551
|
+
* @since 0.8.70
|
|
552
|
+
* @related b.mcp.serverGuard, b.safeSchema
|
|
553
|
+
*
|
|
554
|
+
* OWASP LLM07 — JSON-Schema enforcement on MCP tool inputs. Tools
|
|
555
|
+
* declare an `inputSchema` (JSON Schema 2020-12 subset) at
|
|
556
|
+
* registration; before each invocation the framework validates
|
|
557
|
+
* incoming arguments against the schema and refuses on any drift.
|
|
558
|
+
* Composes b.safeSchema for the validation engine — same primitive
|
|
559
|
+
* the OpenAPI surface uses, so the threat model is uniform.
|
|
560
|
+
*
|
|
561
|
+
* Returns the validated (possibly coerced) input object on success;
|
|
562
|
+
* throws `McpError("mcp/tool-input-invalid", ...)` on schema breach.
|
|
563
|
+
*
|
|
564
|
+
* @example
|
|
565
|
+
* var schema = { type: "object",
|
|
566
|
+
* properties: { path: { type: "string" } },
|
|
567
|
+
* required: ["path"] };
|
|
568
|
+
* var input = b.mcp.validateToolInput("read_file", { path: "/x" }, schema);
|
|
569
|
+
*/
|
|
570
|
+
// JSON-Schema-2020-12 subset validator for MCP tool inputs. The
|
|
571
|
+
// MCP spec specifies tool schemas in standard JSON Schema; the
|
|
572
|
+
// framework's chainable b.safeSchema is fluent-builder-shaped and
|
|
573
|
+
// doesn't accept JSON Schema directly. We implement the small
|
|
574
|
+
// subset MCP tools actually use:
|
|
575
|
+
// - type: "string" | "number" | "integer" | "boolean" | "object" | "array" | "null"
|
|
576
|
+
// - required: string[]
|
|
577
|
+
// - properties: recursive
|
|
578
|
+
// - items: array element schema
|
|
579
|
+
// - enum: allowed-value list
|
|
580
|
+
// - minimum / maximum / minLength / maxLength
|
|
581
|
+
// - pattern: regex (string types)
|
|
582
|
+
// Refuses unknown JSON Schema keywords loudly so a tool-author
|
|
583
|
+
// typo doesn't silently pass validation.
|
|
584
|
+
function _validateValueAgainstSchema(value, schema, path) {
|
|
585
|
+
if (!schema || typeof schema !== "object") return null;
|
|
586
|
+
var t = schema.type;
|
|
587
|
+
if (Array.isArray(t)) {
|
|
588
|
+
var anyMatched = false;
|
|
589
|
+
for (var ti = 0; ti < t.length; ti++) {
|
|
590
|
+
if (_typeMatches(value, t[ti])) { anyMatched = true; break; }
|
|
591
|
+
}
|
|
592
|
+
if (!anyMatched) return path + ": expected one of " + JSON.stringify(t) + ", got " + (typeof value);
|
|
593
|
+
} else if (typeof t === "string") {
|
|
594
|
+
if (!_typeMatches(value, t)) return path + ": expected " + t + ", got " + (typeof value);
|
|
595
|
+
}
|
|
596
|
+
if (Array.isArray(schema.enum) && schema.enum.indexOf(value) === -1) {
|
|
597
|
+
return path + ": value not in enum " + JSON.stringify(schema.enum);
|
|
598
|
+
}
|
|
599
|
+
if (typeof value === "string") {
|
|
600
|
+
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
|
|
601
|
+
return path + ": string length " + value.length + " < minLength " + schema.minLength;
|
|
602
|
+
}
|
|
603
|
+
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
|
|
604
|
+
return path + ": string length " + value.length + " > maxLength " + schema.maxLength;
|
|
605
|
+
}
|
|
606
|
+
if (typeof schema.pattern === "string") {
|
|
607
|
+
// Schema-supplied pattern — operator-controlled at registration
|
|
608
|
+
// time, not request-controlled. Cap value length first per the
|
|
609
|
+
// codebase-patterns regex-bound rule so a 10MB string doesn't
|
|
610
|
+
// ReDoS the validator.
|
|
611
|
+
if (value.length > 4096) return path + ": value exceeds 4 KiB cap before regex test"; // allow:raw-byte-literal — 4 KiB regex-input cap
|
|
612
|
+
try {
|
|
613
|
+
var pat = new RegExp(schema.pattern); // allow:dynamic-regex — schema.pattern from registered tool author, not request input; bounded above
|
|
614
|
+
if (!pat.test(value)) return path + ": does not match pattern";
|
|
615
|
+
}
|
|
616
|
+
catch (_e) { return path + ": invalid pattern in schema"; }
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (typeof value === "number") {
|
|
620
|
+
if (typeof schema.minimum === "number" && value < schema.minimum) return path + ": " + value + " < minimum " + schema.minimum;
|
|
621
|
+
if (typeof schema.maximum === "number" && value > schema.maximum) return path + ": " + value + " > maximum " + schema.maximum;
|
|
622
|
+
}
|
|
623
|
+
if (t === "object" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
624
|
+
if (Array.isArray(schema.required)) {
|
|
625
|
+
for (var ri = 0; ri < schema.required.length; ri++) {
|
|
626
|
+
if (!Object.prototype.hasOwnProperty.call(value, schema.required[ri])) {
|
|
627
|
+
return path + ": missing required property '" + schema.required[ri] + "'";
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (schema.properties && typeof schema.properties === "object") {
|
|
632
|
+
var keys = Object.keys(schema.properties);
|
|
633
|
+
for (var pi = 0; pi < keys.length; pi++) {
|
|
634
|
+
var k = keys[pi];
|
|
635
|
+
if (!Object.prototype.hasOwnProperty.call(value, k)) continue;
|
|
636
|
+
var inner = _validateValueAgainstSchema(value[k], schema.properties[k], path + "." + k);
|
|
637
|
+
if (inner) return inner;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (schema.additionalProperties === false) {
|
|
641
|
+
var allowed = Object.keys(schema.properties || {});
|
|
642
|
+
var keys2 = Object.keys(value);
|
|
643
|
+
for (var ki = 0; ki < keys2.length; ki++) {
|
|
644
|
+
if (allowed.indexOf(keys2[ki]) === -1) {
|
|
645
|
+
return path + ": unknown property '" + keys2[ki] + "' (additionalProperties: false)";
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (t === "array" && Array.isArray(value)) {
|
|
651
|
+
if (schema.items) {
|
|
652
|
+
for (var ai = 0; ai < value.length; ai++) {
|
|
653
|
+
var aInner = _validateValueAgainstSchema(value[ai], schema.items, path + "[" + ai + "]");
|
|
654
|
+
if (aInner) return aInner;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (typeof schema.minItems === "number" && value.length < schema.minItems) {
|
|
658
|
+
return path + ": array length " + value.length + " < minItems " + schema.minItems;
|
|
659
|
+
}
|
|
660
|
+
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
|
|
661
|
+
return path + ": array length " + value.length + " > maxItems " + schema.maxItems;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function _typeMatches(value, type) {
|
|
668
|
+
switch (type) {
|
|
669
|
+
case "string": return typeof value === "string";
|
|
670
|
+
case "number": return typeof value === "number" && isFinite(value);
|
|
671
|
+
case "integer": return typeof value === "number" && Number.isInteger(value);
|
|
672
|
+
case "boolean": return typeof value === "boolean";
|
|
673
|
+
case "null": return value === null;
|
|
674
|
+
case "array": return Array.isArray(value);
|
|
675
|
+
case "object": return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
676
|
+
default: return false;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function _validateToolInput(toolName, input, schema) {
|
|
681
|
+
if (typeof toolName !== "string" || toolName.length === 0) {
|
|
682
|
+
throw new McpError("mcp/bad-tool-name",
|
|
683
|
+
"validateToolInput: toolName must be a non-empty string");
|
|
684
|
+
}
|
|
685
|
+
if (!schema || typeof schema !== "object") {
|
|
686
|
+
throw new McpError("mcp/bad-tool-schema",
|
|
687
|
+
"validateToolInput: schema must be a JSON-Schema-shaped object");
|
|
688
|
+
}
|
|
689
|
+
var err = _validateValueAgainstSchema(input, schema, "$");
|
|
690
|
+
if (err) {
|
|
691
|
+
throw new McpError("mcp/tool-input-invalid",
|
|
692
|
+
"validateToolInput: tool '" + toolName + "' input " + err);
|
|
693
|
+
}
|
|
694
|
+
return input;
|
|
695
|
+
}
|
|
696
|
+
|
|
378
697
|
module.exports = {
|
|
379
698
|
serverGuard: serverGuard,
|
|
380
699
|
parseRequest: parseRequest,
|
|
381
700
|
refuse: refuse,
|
|
701
|
+
toolResult: { sanitize: _toolResultSanitize },
|
|
702
|
+
capability: { create: _capabilityCreate },
|
|
703
|
+
validateToolInput: _validateToolInput,
|
|
382
704
|
};
|
package/lib/middleware/cors.js
CHANGED
|
@@ -202,7 +202,7 @@ function create(opts) {
|
|
|
202
202
|
validateOpts(opts, [
|
|
203
203
|
"origins", "siteOrigin", "methods", "headers", "exposeHeaders",
|
|
204
204
|
"credentials", "maxAgeSeconds", "refuseUnknown", "trustProxy",
|
|
205
|
-
"strictNullOrigin",
|
|
205
|
+
"strictNullOrigin", "allowPrivateNetwork",
|
|
206
206
|
], "middleware.cors");
|
|
207
207
|
var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
|
|
208
208
|
? opts.trustProxy : false;
|
|
@@ -342,6 +342,28 @@ function create(opts) {
|
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
344
|
}
|
|
345
|
+
// Private Network Access (PNA) preflight — Chrome's W3C draft
|
|
346
|
+
// sends `Access-Control-Request-Private-Network: true` when a
|
|
347
|
+
// public-internet page tries to fetch a private/local resource
|
|
348
|
+
// (RFC 1918 / loopback). Servers MUST acknowledge with
|
|
349
|
+
// `Access-Control-Allow-Private-Network: true` to permit. The
|
|
350
|
+
// framework refuses by default — operators with a deliberate
|
|
351
|
+
// public-to-private flow opt in via `allowPrivateNetwork: true`
|
|
352
|
+
// (audited reason) at create-time.
|
|
353
|
+
var pnaRequested = req.headers["access-control-request-private-network"];
|
|
354
|
+
if (pnaRequested === "true") {
|
|
355
|
+
if (opts.allowPrivateNetwork === true) {
|
|
356
|
+
if (typeof res.setHeader === "function") {
|
|
357
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
if (typeof res.writeHead === "function") {
|
|
361
|
+
res.writeHead(requestHelpers.HTTP_STATUS.FORBIDDEN, { "Content-Type": "text/plain" });
|
|
362
|
+
res.end("CORS: Private Network Access not permitted (set allowPrivateNetwork:true with audited reason to opt in)");
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
345
367
|
if (typeof res.setHeader === "function") {
|
|
346
368
|
res.setHeader("Access-Control-Allow-Methods", methods);
|
|
347
369
|
res.setHeader("Access-Control-Allow-Headers", headers);
|
|
@@ -40,6 +40,8 @@ function _writeUnauthorized(res, requiredBand, actualBand, realm) {
|
|
|
40
40
|
"Content-Type": "application/json; charset=utf-8",
|
|
41
41
|
"Content-Length": Buffer.byteLength(body),
|
|
42
42
|
"WWW-Authenticate": challenge,
|
|
43
|
+
// RFC 9111 §5.2.2.5 — auth-gated 401 must not be cached.
|
|
44
|
+
"Cache-Control": "no-store",
|
|
43
45
|
});
|
|
44
46
|
res.end(body);
|
|
45
47
|
}
|
|
@@ -106,10 +106,18 @@ function create(opts) {
|
|
|
106
106
|
} catch (_e) { /* audit best-effort */ }
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// RFC 9111 §5.2.2.5 — auth-gated paths SHOULD emit
|
|
110
|
+
// Cache-Control: no-store so a shared cache (or browser
|
|
111
|
+
// back-button cache) can't replay a 401 / redirect / payload
|
|
112
|
+
// intended for an unauthenticated context to a different user.
|
|
113
|
+
// Pre-v0.8.70 the framework's auth middlewares emitted no
|
|
114
|
+
// cache directive, leaving the operator to set it themselves;
|
|
115
|
+
// forgetting it under a CDN that respects Cache-Control was
|
|
116
|
+
// a routine misconfiguration.
|
|
109
117
|
if (prefersJson(req)) {
|
|
110
118
|
if (typeof res.writeHead === "function") {
|
|
111
119
|
res.writeHead(requestHelpers.HTTP_STATUS.UNAUTHORIZED,
|
|
112
|
-
{ "Content-Type": "application/json" });
|
|
120
|
+
{ "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
113
121
|
res.end(JSON.stringify({ error: msg }));
|
|
114
122
|
}
|
|
115
123
|
return;
|
|
@@ -117,14 +125,14 @@ function create(opts) {
|
|
|
117
125
|
if (redirectTo) {
|
|
118
126
|
if (typeof res.writeHead === "function") {
|
|
119
127
|
// 302 Found — RFC 7231 §6.4.3. Not in HTTP_STATUS table.
|
|
120
|
-
res.writeHead(302, { "Location": redirectTo });
|
|
128
|
+
res.writeHead(302, { "Location": redirectTo, "Cache-Control": "no-store" });
|
|
121
129
|
res.end();
|
|
122
130
|
}
|
|
123
131
|
return;
|
|
124
132
|
}
|
|
125
133
|
if (typeof res.writeHead === "function") {
|
|
126
134
|
res.writeHead(requestHelpers.HTTP_STATUS.UNAUTHORIZED,
|
|
127
|
-
{ "Content-Type": "text/plain" });
|
|
135
|
+
{ "Content-Type": "text/plain", "Cache-Control": "no-store" });
|
|
128
136
|
res.end(msg);
|
|
129
137
|
}
|
|
130
138
|
};
|
|
@@ -71,6 +71,9 @@ function _writeChallenge(res, challenge, body, statusCode) {
|
|
|
71
71
|
"Content-Type": "application/json; charset=utf-8",
|
|
72
72
|
"Content-Length": Buffer.byteLength(json),
|
|
73
73
|
"WWW-Authenticate": challenge,
|
|
74
|
+
// RFC 9111 §5.2.2.5 — auth-gated step-up challenge must not
|
|
75
|
+
// be cached (CDN replay defense).
|
|
76
|
+
"Cache-Control": "no-store",
|
|
74
77
|
});
|
|
75
78
|
res.end(json);
|
|
76
79
|
}
|
|
@@ -57,6 +57,20 @@ var DEFAULT_PERMISSIONS = [
|
|
|
57
57
|
"bluetooth=()", "hid=()", "serial=()", "idle-detection=()",
|
|
58
58
|
"local-fonts=()", "compute-pressure=()", "window-management=()",
|
|
59
59
|
"private-state-token-issuance=()", "private-state-token-redemption=()",
|
|
60
|
+
// v0.8.70 expansion — Privacy-Sandbox + Storage Access feature
|
|
61
|
+
// names that landed in Chrome 119+/120+ stable. Default-deny:
|
|
62
|
+
// - storage-access — Storage Access API (cross-site cookie
|
|
63
|
+
// access flow under Privacy Sandbox); operators serving an
|
|
64
|
+
// embedded-iframe SaaS opt in explicitly.
|
|
65
|
+
// - browsing-topics — Topics API (replacement for FLoC);
|
|
66
|
+
// enabled by default in Chrome but trackable surface, so
|
|
67
|
+
// deny-by-default unless the operator opts in.
|
|
68
|
+
// - private-aggregation, attribution-reporting-cross-site —
|
|
69
|
+
// Privacy Sandbox aggregation APIs; deny-by-default.
|
|
70
|
+
// - controlled-frame, captured-surface-control — Web App
|
|
71
|
+
// embedding APIs; deny-by-default.
|
|
72
|
+
"storage-access=()", "browsing-topics=()",
|
|
73
|
+
"private-aggregation=()", "controlled-frame=()", "captured-surface-control=()",
|
|
60
74
|
];
|
|
61
75
|
|
|
62
76
|
// Strict CSP — no 'unsafe-inline' on script-src OR style-src.
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
|
-
"specVersion": "1.
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"specVersion": "1.6",
|
|
5
|
+
"serialNumber": "urn:uuid:f0ffa661-b4d1-401c-947f-756e1acef7ed",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-10T19:03:05.252Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.8.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.71",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.71",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.8.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.8.71",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.8.
|
|
57
|
+
"ref": "@blamejs/core@0.8.71",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|
|
61
|
-
}
|
|
61
|
+
}
|