Safe Json
Hardened JSON parse + stringify + schema validation. Native JSON.parse leaves four footguns to the caller — no size cap (DoS the parser thread), no depth cap (stack-overflow downstream), no guard on __proto__ / constructor / prototype keys (prototype pollution after any later merge/clone), and errors that report only a character offset with no surrounding context. b.safeJson closes all four with conservative defaults.
Defaults: 1 MiB body cap, depth 100, 10 000 keys per object (CVE-2026-21717 V8 HashDoS guard), poisoned keys stripped. Stringify refuses circular references unless the caller asks for the [Circular] placeholder. canonical produces RFC 8785 JCS key-sorted output for signature inputs.
The validator is a strict subset of JSON Schema (type / enum / minLength etc. / required / properties / additionalProperties), pluggable formats via b.safeJson.registerFormat, two modes: throw on first error (trust-boundary parse) or collect every error (form-style bulk validation).
Validation policy: opts and inputs are validated at the call site and throw SafeJsonError. The throw IS the security signal; HTTP middleware catches it and emits 400 with .code / .path.
b.safeJson.SafeJsonError #
Error class thrown by every b.safeJson primitive on bad input, cap exceedance, or schema-validation failure. Extends FrameworkError. Carries a stable .code (e.g. json/too-large, json/syntax, json/validation, json/circular) plus an optional JSON-pointer-shaped .path (e.g. $.user.email) for schema-validation errors. HTTP middleware translates these into 400 responses without leaking parser internals.
var b = require("blamejs");
try {
b.safeJson.parse("{not json");
} catch (e) {
e instanceof b.safeJson.SafeJsonError; // → true
e.code; // → "json/syntax"
}
b.safeJson.parse(input, opts?) #
{
maxBytes: number, // default 1 MiB; capped at 64 MiB
maxDepth: number, // default 100; capped at 1000
maxKeys: number, // default 10 000; capped at 1 000 000
allowProto: boolean, // default false; keep __proto__/constructor/prototype keys
refuseProtoMover: boolean, // default false; throw json/proto-key on a __proto__ key
schema: object, // optional JSON-Schema subset; runs b.safeJson.validate
collectErrors: boolean, // pair with `schema`: return { ok, value, errors[] } instead of throwing
expectType: string, // legacy: "string"|"number"|"boolean"|"null"|"array"|"object"
requiredKeys: string[],// legacy: required top-level keys (prefer `schema.required`)
}
Hardened JSON parse. Accepts string / Buffer / Uint8Array, normalizes to UTF-8 text, enforces the byte cap BEFORE the parser sees the input, then bounds nesting depth and per-object key count so a hostile body can't DoS the parse thread or trip V8's HashDoS shape-cache degeneracy (CVE-2026-21717). Strips __proto__ / constructor / prototype keys via the JSON.parse reviver so a later spread / merge / clone can't pivot into prototype pollution.
Nesting is counted on the text before the parse runs, so a document deeper than maxDepth is refused as json/too-deep whatever its size.
Throws SafeJsonError with a documented .code: json/too-large / json/syntax / json/too-deep / json/too-many-keys / json/wrong-input-type / json/type-mismatch / json/missing-key / json/validation.
An allowProto that is not a boolean throws json/bad-opt, so the string "false" cannot keep __proto__ as an own key by reading as truthy. Under refuseProtoMover a key that names the prototype setter throws json/proto-key rather than being dropped. When a schema is supplied it is validated too: json/bad-schema when the schema is not an object, json/unknown-format for a format this validator does not implement, and json/bad-pattern for a pattern that is unsafe to run.
var b = require("blamejs");
var obj = b.safeJson.parse('{"name":"alice","age":30}');
obj.name;
// → "alice"
// Prototype-pollution payload: poisoned keys stripped silently.
var clean = b.safeJson.parse('{"__proto__":{"isAdmin":true},"id":1}');
Object.prototype.hasOwnProperty.call(clean, "__proto__");
// → false
// Size cap rejects oversized input before parsing.
var big = '"' + "x".repeat(2000) + '"';
try { b.safeJson.parse(big, { maxBytes: 1024 }); }
catch (e) { e.code; }
// → "json/too-large"
// Depth cap bounds nesting.
try { b.safeJson.parse('[[[[[[1]]]]]]', { maxDepth: 3 }); }
catch (e) { e.code; }
// → "json/too-deep"
b.safeJson.parseOrDefault(input, fallback, opts?) #
{
maxBytes: number, // default 1 MiB; capped at 64 MiB
maxDepth: number, // default 100; capped at 1000
maxKeys: number, // default 10 000; capped at 1 000 000
allowProto: boolean, // default false; keep __proto__/constructor/prototype keys
schema: object, // optional JSON-Schema subset (see b.safeJson.validate)
}
Best-effort parse: returns fallback on any failure (size cap, syntax error, depth/key cap, schema mismatch). Useful for cache thaw / config files / optional metadata where a malformed payload shouldn't crash the caller. Same caps and prototype-pollution defense as parse.
var b = require("blamejs");
b.safeJson.parseOrDefault('{"x":1}', {});
// → { x: 1 }
b.safeJson.parseOrDefault("{not json", { x: 0 });
// → { x: 0 }
b.safeJson.parseOrDefault(null, []);
// → []
b.safeJson.parseTyped(input, opts) #
{
errorClass: Function, // required — the error class to throw
code: string, // required — the code to carry
label: string, // message prefix, e.g. "parse: manifest"
maxBytes: number, // as b.safeJson.parse
maxDepth: number,
maxKeys: number,
allowProto: boolean,
schema: object,
}
Parse under the same caps as parse, and report a failure as the caller's own error instead of SafeJsonError, so a document read at a protocol boundary refuses with the code that boundary documents. The message is . errorClass is built through its factory(code, message) when it has one, and as new errorClass(code, message) otherwise.
b.safeJson.parseTyped('{"a":1}', {
errorClass: b.backup.BackupManifestError,
code: "backup-manifest/bad-json",
label: "parse",
});
// → { a: 1 }
b.safeJson.parseStringOrObject(input, opts?) #
{
maxBytes: number, // forwarded to parse (default 1 MiB; capped 64 MiB)
maxDepth: number, // forwarded to parse
maxKeys: number, // members per object, both forms (default 10,000)
allowProto: boolean, // default false; keep __proto__/constructor/prototype keys
refuseProtoMover: boolean, // default false; throw json/proto-key on a __proto__ key
errorClass: function, // typed error class to throw (else SafeJsonError)
jsonCode: string, // error code for invalid JSON (used with errorClass)
inputCode: string, // error code for a non-string/non-object input
label: string, // message prefix (default "safeJson.parseStringOrObject")
}
Accept EITHER a JSON string — parsed through parse, so the proto-pollution-key strip, depth/key caps, and size cap all apply — OR an already-decoded value, put through jsonCopyUnderCap so that the SAME caps apply to it and the caller is handed the value they were measured against rather than one someone else still holds. This is the recurring "operator hands me a document as a JSON string or a pre-built object" surface (b.openapi / b.asyncapi). Routing it here means a raw JSON.parse on operator input — which imposes no size bound — cannot be hand-rolled per consumer. The divergence each consumer needs (its typed error class + codes + a generous document size cap, and whether the proto-poisoning names are keys of its document or a hazard in its input) is carried as data, so there is no per-consumer branch. Both forms answer alike: whichever way the document arrives, the same names survive it.
var doc = b.safeJson.parseStringOrObject(input, {
maxBytes: C.BYTES.mib(16), errorClass: OpenApiError,
jsonCode: "openapi/bad-json", inputCode: "openapi/bad-input",
label: "openapi.parse",
});
b.safeJson.stringify(value, opts?) #
{
onCircular: "throw" | "replace", // default "throw"
circularReplacement: any, // default "[Circular]" (used when onCircular === "replace")
allowProto: boolean, // default false; keep __proto__/constructor/prototype keys
refuseProtoMover: boolean, // default false; throw json/proto-key on a __proto__ key
indent: number | string, // forwarded to JSON.stringify
maxBytes: number, // refuse with json/too-large once the form runs past this
maxDepth: number, // refuse with json/too-deep at this nesting, before recursing further
}
JSON-encode a value with two safeguards JSON.stringify doesn't provide: a documented circular-reference policy (throw, or substitute every cycle with a placeholder string) and prototype- key suppression so an object built from a tainted parse can't leak __proto__ / constructor / prototype keys back out.
Throws SafeJsonError with .code = "json/circular" when onCircular: "throw" (default) hits a cycle, json/too-large when the written form passes maxBytes, json/stringify carrying the original message where JSON.stringify itself throws, as it does on a BigInt, and json/bad-opt for an option this primitive does not accept.
A value JSON has no form for throws .code = "json/no-form", naming the key, rather than being written as {}: a Map, Set, WeakMap, WeakSet, Promise, RegExp, ArrayBuffer or DataView keeps its data outside its own properties, so there is nothing for JSON to write. A value defining toJSON is serialized by it, so a Date writes its ISO string.
maxBytes stops a value whose JSON form runs away as it is being written, which a toJSON hook or a getter is free to do however small the value looked beforehand. The count is of the bytes the form actually occupies: a key and a string by their escaped UTF-8 size, a number by its written form, false by five and true by four, a member JSON.stringify drops by nothing inside an object and by the four null takes inside an array, a container by its two brackets, and one byte for each separator after the first member. For a value whose JSON form is fixed, which is plain data, that count is exact: a value over the cap is refused where it passes it, a value under it is never refused, and the returned string is never longer than maxBytes. A member whose form is decided by code the value carries, a toJSON hook, an accessor, or a boxed wrapper with its own valueOf, can write something other than what was counted, since the count and the write read it separately. No count can bound that, which is why b.safeJson.jsonCopyUnderCap measures the finished string and hands back what that one reading produced. Such a member is counted the least its kind can occupy, so it is never refused early. refuseProtoMover throws json/proto-key on a __proto__ key instead of writing it, for a caller that keeps the other reserved names with allowProto. A maxBytes that is not a non-negative finite number throws TypeError, and so does maxBytes together with indent, whose newline and padding per member grow with a nesting depth the count does not follow.
var b = require("blamejs");
b.safeJson.stringify({ a: 1, b: 2 });
// → '{"a":1,"b":2}'
// Cycles throw by default.
var cyclic = { name: "root" };
cyclic.self = cyclic;
try { b.safeJson.stringify(cyclic); }
catch (e) { e.code; }
// → "json/circular"
// Opt into placeholder-substitution.
var out = b.safeJson.stringify(cyclic, { onCircular: "replace" });
// → '{"name":"root","self":"[Circular]"}'
b.safeJson.stringifyForScript(value, opts?) #
{
indent: number | string, // forwarded to b.safeJson.stringify
allowProto:boolean, // forwarded
maxBytes: number, // refuse with json/too-large once escaped
}
Like b.safeJson.stringify but safe to embed verbatim inside an inline element. Raw JSON.stringify does not escape <, >, or &, so a string value containing (or