Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ curl -X GET -H "Authorization: Bearer replace-with-a-secret-token" http://127.0.
```

This returns the oldest added payload on queue `foo` as JSON and removes it, guaranteeing both the order and that each payload will only be read once. Strings, numbers, booleans, arrays, and objects all use `application/json` so a string `"0"` is distinct from the number `0`. Numeric values that JavaScript cannot represent without loss and request bodies that are not well-formed UTF-8 are rejected with a `400` response.
JSON request bodies nested more than 3,000 container levels are also rejected with a `400` response.

That's all you need to get started! 😎

Expand Down
2 changes: 1 addition & 1 deletion mutation/stryker.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
},
"coverageAnalysis": "off",
"timeoutMS": 30000,
"concurrency": 2,
"concurrency": 4,
"thresholds": {
"high": 80,
"low": 70,
Expand Down
56 changes: 38 additions & 18 deletions src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,34 +42,54 @@ function isUnsupportedNumber(value: number, source: string): boolean {
return canonicalJsonNumber(source) !== canonicalJsonNumber(serializedValue);
}

// Matches strings, containers, and number literals in valid JSON. Scanning
// the source avoids invoking a JSON.parse reviver once per JSON value.
const JSON_TOKEN = /"[^"\\]*(?:\\.[^"\\]*)*"|[[{]|[\]}]|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
const EXACT_INTEGER = /^-?\d{1,15}$/;
const MAX_JSON_DEPTH = 3000;

function rejectUnsupportedNumber(source: string): void {
// Every integer with at most 15 digits is below Number.MAX_SAFE_INTEGER.
if (EXACT_INTEGER.test(source)) {
return;
}
if (isUnsupportedNumber(Number(source), source)) {
throw new UnsupportedNumberError();
}
}

function validateJsonSource(source: string): void {
let depth = 0;
for (const match of source.matchAll(JSON_TOKEN)) {
const token = match[0];
if (token === "[" || token === "{") {
depth++;
if (depth > MAX_JSON_DEPTH) {
throw new JsonNestingTooDeepError();
}
} else if (token === "]" || token === "}") {
depth--;
} else if (token[0] !== '"') {
rejectUnsupportedNumber(token);
}
}
}

function parseJsonBody(body: string) {
try {
return JSON.parse(body, rejectUnsupportedNumbers);
const json = JSON.parse(body);
validateJsonSource(body);
return json;
} catch (error) {
// V8 applies the reviver recursively, so deeply nested JSON overflows
// the stack; treat it as unparseable input rather than a server fault.
// Deeply nested JSON can overflow the native parser's stack; treat it
// as unparseable input rather than a server fault.
if (error instanceof RangeError) {
throw new JsonNestingTooDeepError();
}
throw error;
}
}

function rejectUnsupportedNumbers(key: string, value: unknown) {
void key;
if (typeof value !== "number") {
return value;
}

// V8 supplies context.source at runtime; Deno's JSON.parse type still
// only declares the legacy two-argument reviver signature.
const context = arguments[2] as { source: string };
if (isUnsupportedNumber(value, context.source)) {
throw new UnsupportedNumberError();
}
return value;
}

async function readRequestBody(request: Request): Promise<string | Response> {
const contentLength = request.headers.get("content-length");
if (contentLength && parseInt(contentLength) > MAX_BODY_SIZE) {
Expand Down
Loading