paperclip/packages/tailscale-https-broker/src/strict-json.ts

195 lines
5.4 KiB
TypeScript

/**
* Minimal strict JSON parser that rejects duplicate object keys. `JSON.parse`
* silently keeps the last value for a duplicated key, which the threat model
* treats as request smuggling (PAP-17050 verdict requirements #4/#5). This
* recursive-descent parser throws on any duplicate key at any depth.
*
* It is intentionally small and only used for the broker's tiny, length-bounded
* request frames — not a general-purpose JSON library.
*/
export function parseJsonNoDuplicateKeys(text: string): unknown {
const parser = new StrictParser(text);
const value = parser.parseValue();
parser.skipWhitespace();
if (!parser.atEnd()) {
throw new SyntaxError("unexpected trailing content after JSON value");
}
return value;
}
class StrictParser {
private pos = 0;
constructor(private readonly src: string) {}
atEnd(): boolean {
return this.pos >= this.src.length;
}
skipWhitespace(): void {
while (this.pos < this.src.length) {
const ch = this.src[this.pos];
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
this.pos += 1;
} else {
break;
}
}
}
parseValue(): unknown {
this.skipWhitespace();
if (this.atEnd()) throw new SyntaxError("unexpected end of input");
const ch = this.src[this.pos];
switch (ch) {
case "{":
return this.parseObject();
case "[":
return this.parseArray();
case '"':
return this.parseString();
case "t":
case "f":
return this.parseBoolean();
case "n":
return this.parseNull();
default:
return this.parseNumber();
}
}
private parseObject(): Record<string, unknown> {
this.expect("{");
const obj: Record<string, unknown> = {};
const seen = new Set<string>();
this.skipWhitespace();
if (this.peek() === "}") {
this.pos += 1;
return obj;
}
for (;;) {
this.skipWhitespace();
if (this.peek() !== '"') throw new SyntaxError("expected object key string");
const key = this.parseString();
if (seen.has(key)) {
throw new SyntaxError(`duplicate object key: ${JSON.stringify(key)}`);
}
seen.add(key);
this.skipWhitespace();
this.expect(":");
const value = this.parseValue();
// Use defineProperty so a "__proto__" key cannot poison the prototype.
Object.defineProperty(obj, key, {
value,
enumerable: true,
writable: true,
configurable: true,
});
this.skipWhitespace();
const next = this.next();
if (next === "}") return obj;
if (next !== ",") throw new SyntaxError("expected , or } in object");
}
}
private parseArray(): unknown[] {
this.expect("[");
const arr: unknown[] = [];
this.skipWhitespace();
if (this.peek() === "]") {
this.pos += 1;
return arr;
}
for (;;) {
arr.push(this.parseValue());
this.skipWhitespace();
const next = this.next();
if (next === "]") return arr;
if (next !== ",") throw new SyntaxError("expected , or ] in array");
}
}
private parseString(): string {
this.expect('"');
let out = "";
for (;;) {
if (this.atEnd()) throw new SyntaxError("unterminated string");
const ch = this.src[this.pos++];
if (ch === '"') return out;
if (ch === "\\") {
const esc = this.src[this.pos++];
switch (esc) {
case '"': out += '"'; break;
case "\\": out += "\\"; break;
case "/": out += "/"; break;
case "b": out += "\b"; break;
case "f": out += "\f"; break;
case "n": out += "\n"; break;
case "r": out += "\r"; break;
case "t": out += "\t"; break;
case "u": {
const hex = this.src.slice(this.pos, this.pos + 4);
if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new SyntaxError("bad unicode escape");
out += String.fromCharCode(parseInt(hex, 16));
this.pos += 4;
break;
}
default:
throw new SyntaxError("bad string escape");
}
} else {
const code = ch.charCodeAt(0);
if (code < 0x20) throw new SyntaxError("unescaped control character in string");
out += ch;
}
}
}
private parseNumber(): number {
const start = this.pos;
const re = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y;
re.lastIndex = this.pos;
const match = re.exec(this.src);
if (!match || match.index !== start) throw new SyntaxError("invalid number");
this.pos += match[0].length;
const num = Number(match[0]);
if (!Number.isFinite(num)) throw new SyntaxError("non-finite number");
return num;
}
private parseBoolean(): boolean {
if (this.src.startsWith("true", this.pos)) {
this.pos += 4;
return true;
}
if (this.src.startsWith("false", this.pos)) {
this.pos += 5;
return false;
}
throw new SyntaxError("invalid literal");
}
private parseNull(): null {
if (this.src.startsWith("null", this.pos)) {
this.pos += 4;
return null;
}
throw new SyntaxError("invalid literal");
}
private peek(): string {
return this.src[this.pos];
}
private next(): string {
return this.src[this.pos++];
}
private expect(ch: string): void {
if (this.src[this.pos] !== ch) {
throw new SyntaxError(`expected '${ch}'`);
}
this.pos += 1;
}
}