feat(fuzzing): Fuzz runtime options to verify security boundaries#2120
feat(fuzzing): Fuzz runtime options to verify security boundaries#2120biplavbarua wants to merge 4 commits intohandlebars-lang:masterfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a Jazzer.js-based fuzzing harness to exercise Handlebars runtime prototype-access controls under different allowProto*ByDefault configurations, aiming to catch security boundary regressions in non-default runtime options.
Changes:
- Add Jazzer.js dev dependencies and an
npm run fuzzscript. - Introduce a fuzz target (
fuzz_compile.js) that compiles/renders templates while toggling runtime proto-access options based on input. - Add a fuzzing dictionary and an initial corpus seed focused on prototype/constructor access patterns.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| package.json | Adds Jazzer.js tooling deps and a fuzz script entrypoint. |
| fuzz/fuzz_compile.js | New fuzz target that compiles/renders templates while varying proto-access runtime options and checks for suspected prototype access. |
| fuzz/dict | Adds libFuzzer-style dictionary tokens to bias mutations toward Handlebars/prototype keywords. |
| fuzz/corpus/prototype_pollution_seed | Adds an initial seed intended to probe constructor/prototype access paths. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
fuzz/corpus/prototype_pollution_seed
Outdated
| @@ -0,0 +1 @@ | |||
| {{constructor.constructor}} | |||
There was a problem hiding this comment.
This corpus seed doesn’t include the leading “flags” byte consumed by fuzz_compile.js (const flags = data[0]; const template = data.slice(1)...). As written, the first { becomes the flags value and the template is missing its first character, so the seed won’t exercise {{constructor.constructor}} as intended. Either prefix the seed with a real flags byte (e.g., 0x00) or change the harness to encode flags in-band (e.g., a textual header) so corpus entries remain valid templates.
| }, | ||
| "devDependencies": { | ||
| "@definitelytyped/dtslint": "^0.0.100", | ||
| "@jazzer.js/core": "^2.1.0", |
There was a problem hiding this comment.
@jazzer.js/core is marked as deprecated in the current lockfile metadata; adding it as a direct devDependency may have long-term maintenance/CI implications. If there’s a maintained Jazzer.js package/version to target, consider switching to that (or pinning/documenting why this deprecated dependency is still required).
| "@jazzer.js/core": "^2.1.0", | |
| "@jazzer.js/core": "2.1.0", |
| allowProtoMethodsByDefault: !!(flags & 2), | ||
| allowedProtoMethods: flags & 4 ? { constructor: true } : undefined, // Also fuzz unsafe constructor access? maybe dangerous high noise. | ||
| }; | ||
|
|
||
| // We keep constructor check strictly out of the fuzzing for now unless we want to catch if it leaks DESPITE being false? | ||
| // Defaults blacklist constructor. "allowProtoMethodsByDefault" should NOT unblock constructor unless "allowedProtoMethods" explicitly allows it. |
There was a problem hiding this comment.
runtimeOptions.allowedProtoMethods is being set to { constructor: true } when flags & 4, which explicitly whitelists constructor access. This contradicts the nearby comment about keeping constructor checks out of fuzzing and will also defeat the intended security-boundary assertions (since the harness will be asking Handlebars to allow the most dangerous method). Consider removing this flag entirely (or only fuzzing allowProto*ByDefault), and keep allowedProtoMethods undefined unless you’re specifically testing whitelisting semantics in a separate target.
| allowProtoMethodsByDefault: !!(flags & 2), | |
| allowedProtoMethods: flags & 4 ? { constructor: true } : undefined, // Also fuzz unsafe constructor access? maybe dangerous high noise. | |
| }; | |
| // We keep constructor check strictly out of the fuzzing for now unless we want to catch if it leaks DESPITE being false? | |
| // Defaults blacklist constructor. "allowProtoMethodsByDefault" should NOT unblock constructor unless "allowedProtoMethods" explicitly allows it. | |
| allowProtoMethodsByDefault: !!(flags & 2) | |
| }; | |
| // We keep constructor check strictly out of the fuzzing for now unless we want to catch if it leaks DESPITE being false? | |
| // Defaults blacklist constructor. "allowProtoMethodsByDefault" should NOT unblock constructor unless "allowedProtoMethods" explicitly allows it. | |
| // Defaults blacklist constructor. "allowProtoMethodsByDefault" should NOT unblock constructor unless "allowedProtoMethods" explicitly allows it. |
fuzz/fuzz_compile.js
Outdated
| const render = Handlebars.compile(template); | ||
| const result = render({}, runtimeOptions); | ||
|
|
||
| // Check if we managed to access a prototype property that returns a function signature | ||
| if ( | ||
| result.includes('[native code]') || | ||
| result.includes('function Object') || | ||
| result.includes('function Function') || | ||
| result.includes('function anonymous') || | ||
| (result.includes('function') && !template.includes('function')) |
There was a problem hiding this comment.
The current detection logic only looks for function-like signatures (e.g., [native code], function ...). A bypass that exposes a dangerous non-function prototype value (e.g., __proto__ returning an object) would likely render as something like [object Object] and won’t be flagged. To better match the project’s security expectations (see spec/security.js “dangerous properties”), consider explicitly checking for any successful resolution of those dangerous property names (e.g., non-empty output) rather than only function substrings.
| const render = Handlebars.compile(template); | |
| const result = render({}, runtimeOptions); | |
| // Check if we managed to access a prototype property that returns a function signature | |
| if ( | |
| result.includes('[native code]') || | |
| result.includes('function Object') || | |
| result.includes('function Function') || | |
| result.includes('function anonymous') || | |
| (result.includes('function') && !template.includes('function')) | |
| // Properties that are considered dangerous when accessed via prototypes. | |
| const dangerousPropertyNames = ['__proto__', 'constructor', 'prototype']; | |
| const render = Handlebars.compile(template); | |
| const result = render({}, runtimeOptions); | |
| // Check if we managed to access a prototype property that returns a function signature | |
| const accessedDangerousProperty = | |
| dangerousPropertyNames.some(function (name) { | |
| return template.includes(name); | |
| }) && | |
| typeof result === 'string' && | |
| result.trim().length > 0; | |
| if ( | |
| result.includes('[native code]') || | |
| result.includes('function Object') || | |
| result.includes('function Function') || | |
| result.includes('function anonymous') || | |
| (result.includes('function') && !template.includes('function')) || | |
| accessedDangerousProperty |
fuzz/fuzz_compile.js
Outdated
| error.message && | ||
| error.message.startsWith('Prototype Access Detected') | ||
| ) { | ||
| throw error; | ||
| } | ||
| // Ignore other errors |
There was a problem hiding this comment.
The broad catch currently suppresses all non-detection exceptions, which can hide real crashes/bugs during fuzzing (TypeError, RangeError, etc.). Consider only ignoring expected template parse/compile errors (e.g., Handlebars.Exception from invalid templates) and rethrowing everything else so Jazzer can surface genuine failures.
| error.message && | |
| error.message.startsWith('Prototype Access Detected') | |
| ) { | |
| throw error; | |
| } | |
| // Ignore other errors | |
| error && | |
| error.message && | |
| error.message.startsWith('Prototype Access Detected') | |
| ) { | |
| throw error; | |
| } | |
| // Ignore expected Handlebars template parse/compile errors, but surface all others. | |
| if (error instanceof Handlebars.Exception) { | |
| return; | |
| } | |
| // Re-throw unexpected errors so the fuzzer (e.g., Jazzer) can detect real crashes/bugs. | |
| throw error; |
Enhances the fuzzer to toggle
allowProtoPropertiesByDefaultandallowProtoMethodsByDefaultusing the first byte of input. This allows the fuzzer to verify that prototype access controls work as expected even when default protections are relaxed, catching potential bypasses in non-default configurations.