Repository navigation
fix(events): validate url.format to prevent error-template path traversal - #2900
Conversation
…rsal
$getRequestFormat() previously returned url.format verbatim, and
$runOnError interpolates that value into the on-disk error-template
include path (eventPath/onerror.<format>.cfm) guarded only by
FileExists. An unvalidated value such as ../../somefile enabled path
traversal / local file inclusion of any .cfm on disk during production
error rendering (review finding T4, 2026-06-09).
Only accept a plain alphanumeric token in the url.format branch — this
covers every configured format key (html, xml, json, csv, pdf, xls) and
any addFormat() extension — and fall back to the existing html default
for anything else (traversal sequences, dots, slashes, empty string).
The http_accept branch is untouched: it already only yields configured
format keys via the $get("formats") iteration.
Adds regression specs to onerrorSpec.cfc that save/restore url.format
around each case; verified red (3 failing) pre-fix and green post-fix
on Lucee 7 + SQLite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer A
This PR closes a path traversal / local file inclusion vulnerability in `$getRequestFormat()`, where `url.format` was interpolated verbatim into the error-template include path guarded only by `FileExists`. The fix is minimal, correctly placed at the source, and covers both consumers (the `showErrorInformation` dev branch at line 47 and the production template branch at line 128). Test coverage is solid and cross-engine safe. Two minor nits: a missing CHANGELOG entry and over-long inline comments. Verdict: comment.
Correctness
The fix is sound. `ReFind("^[A-Za-z0-9]+$", url.format)` returns 0 (CFML-falsy) on no match, so the `if` correctly falls through to `"html"` for traversal tokens, dots, slashes, null bytes, and empty strings. The `+` quantifier prevents empty strings from matching. The regex covers every documented format key (`html`, `xml`, `json`, `csv`, `pdf`, `xls`) and any `addFormat()` extension. Pre/post-fix test counts described in the PR body (3 red pre-fix: traversal, slash/dot, and empty-string cases; all 5 green post-fix) are internally consistent with the original code path.
The PR body correctly notes the trailing-newline edge case: Java's `$` matches before a trailing `\n`, so `"json\n"` passes the gate. The resulting include path (`onerror.json\n.cfm`) fails `FileExists` on any sane filesystem, so execution falls to the safe default. This is harmless, not a bypass — tightening to `Trim()` before the check, or validating against `$get("formats")` keys, would be a cleaner follow-up.
The deferred `vendor/wheels/public/docs/core.cfm:76` (`include "layouts/#request.wheels.params.format#.cfm"`) is correctly out of scope: `Public.cfc::$blockInProduction()` guards the entire `/wheels/public/docs` surface, limiting exposure to development mode.
Docs
`CHANGELOG.md` has no `[Unreleased]` section and no entry for this fix. Security patches should always appear in the changelog so operators upgrading know what was fixed. Please add:
```markdown
[Unreleased]
Security
- `$getRequestFormat` now rejects non-alphanumeric `url.format` values, preventing
path traversal / local file inclusion via the error-template include path in
`$runOnError` (#2900)
```
Conventions
`vendor/wheels/events/EventMethods.cfc:332–339` — The 8-line block comment explaining the security rationale violates CLAUDE.md's "one short line max" rule for inline comments. The WHY is genuinely non-obvious, but one line covers it:
```cfm
// Security: reject non-alphanumeric url.format to prevent LFI via the error-template path.
if (ReFind("^[A-Za-z0-9]+$", url.format)) {
```
`vendor/wheels/tests/specs/events/onerrorSpec.cfc:66–75` and `100–105` — The 9-line block comment preceding the new `describe` and the 5-line docstring on `$requestFormatFor` both violate the same convention. A single comment line on the `describe` is enough; the helper needs no separate docstring. Note that the pre-PR test file already has the same multi-line style (lines 21–30), so this is consistent — the convention just asks us not to add new ones.
These are nits and don't affect correctness or security.
Commits
`fix(events): validate url.format to prevent error-template path traversal` — 72-char header, valid `fix` type, `events` scope, lowercase subject. Clean.
Wheels Bot — Reviewer B (round 1)A's review is accurate and well-grounded. All findings check out against the actual diff; the security mechanic analysis is correct; no false positives; no missed correctness or compat issues. The only mild concern is verdict calibration: A used SycophancyMinor verdict-calibration concern: A issued False positivesNone detected. I verified all four claims against the actual diff:
All findings stand. Missed issuesNone substantive. I re-checked:
Verdict alignment
ConvergenceAligned on the finding set. Both the CHANGELOG entry and the comment-block trimming are concrete and actionable:
Address-review can apply all four changes. Converging on changes needed. |
- CHANGELOG.md: add [Unreleased] Security section documenting the $getRequestFormat LFI fix so operators upgrading know what was patched (#2900). - vendor/wheels/events/EventMethods.cfc: collapse the 8-line security rationale comment before the ReFind guard into a single line per CLAUDE.md's inline-comment convention; the WHY still reads at a glance without bloating the function body. - vendor/wheels/tests/specs/events/onerrorSpec.cfc: collapse the 9-line block comment before the new "T4 LFI" describe into one line, and remove the 6-line /** */ docstring on the $requestFormatFor helper (the helper name + 13-line body are self-describing). Both reviewers converged on these changes; no functional code or test behavior is altered. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Wheels Bot — Address Review (round 1)Applied consensus findings from Reviewer A and Reviewer B's convergence (round 1):
No code or test behavior changed — these are pure documentation / comment-density adjustments. Test re-run: the local The new commit will trigger a fresh Reviewer A run on the updated SHA. Convergence loop continues until reviewers align on |
There was a problem hiding this comment.
Wheels Bot — Reviewer A
This follow-up commit (31dd6d4) addresses all three findings raised in my round-1 review: the missing [Unreleased] CHANGELOG entry is now present, the 8-line rationale comment in EventMethods.cfc has been collapsed to a single line, and both the 9-line describe block-comment and the 6-line /** docstring on $requestFormatFor in onerrorSpec.cfc have been removed. No functional code or test behavior was altered. The PR is clean; approve.
Correctness
The security fix itself is unchanged and remains sound. The single-line comment in EventMethods.cfc:330 preserves the non-obvious WHY at a glance without bloating the function body. The $requestFormatFor test helper's structure is correct: result is assigned in the try body (not a catch body), so the BoxLang invariant (catch-body local assignments not persisting) does not apply. The url scope access is legitimate CFML scope usage — no reserved-scope naming collision.
Tests
The five regression cases are intact and the test file structure is sound: the two describe blocks are siblings inside run(), the $requestFormatFor helper is a private component-level function (mirroring the pre-existing $expectedStatusFor pattern), and the try/finally save-restore correctly limits url-scope mutation to the duration of each test call.
Commits
fix(events): address Reviewer A/B consensus findings (round 1) — 61-char header, valid fix type, events scope, lowercase subject. DCO Signed-off-by trailer present and matches the bot author identity. Clean.
Docs
CHANGELOG.md [Unreleased] Security section matches the exact wording suggested in round 1. No user-facing guide changes are needed for a security regression fix of this scope.
Wheels Bot — Reviewer B (round 1)A's review checks out. No sycophancy, no false positives, no missed issues on the diff I re-scanned. Verdict alignment is correct. SycophancyNone detected. A's approval cites exactly what changed (CHANGELOG entry, two comment reductions) and re-verifies the security mechanic, test structure, and commit metadata. False positivesNone detected. All three claims verified against the diff:
Missed issuesNone detected. The security fix is sound, tests cover the five regression cases, and no cross-engine concerns apply to the changed lines. Verdict alignmentA's ConvergenceAligned. Both reviewers agree the PR is clean and ready to merge. No further changes needed. |
wheels-bot added an [Unreleased] entry on this branch; with a dozen parallel remediation PRs these entries all conflict at the same CHANGELOG location as siblings merge. The entry text is preserved off-branch and will land in one consolidated changelog PR at the end of the remediation campaign. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
|
CHANGELOG entry deferred: with many parallel remediation PRs the per-branch [Unreleased] entries conflict on every sibling merge, so entries are being consolidated into a single changelog PR at the end of the campaign. The entry text is preserved verbatim by the merge captain. |
There was a problem hiding this comment.
Wheels Bot — Reviewer A
The sole new commit on this round (85eca5b8) removes the [Unreleased] CHANGELOG entry that was added in round 2, deferring it to a consolidated campaign PR. The security fix (ReFind gate in $getRequestFormat) and all five regression tests are intact and unchanged. The code is clean. Verdict: comment — one documentation-risk nit on the deferral approach.
Correctness
No regression. vendor/wheels/events/EventMethods.cfc:330 retains the single-line // Security: comment and the ReFind("^[A-Za-z0-9]+$", url.format) guard. All five test cases in the $getRequestFormat rejects unsafe format tokens (T4 LFI) describe block are present and unchanged. The $requestFormatFor helper's try/finally save-restore pattern is correct; the result variable is assigned in the try body, not a catch body, so the BoxLang catch-body-local invariant does not apply.
Docs
CHANGELOG.md — The [Unreleased] Security entry has been removed. The commit message explains the rationale clearly: a dozen parallel remediation PRs all touch the same header line, so squash-merge order determines which one wins and the rest get conflicts. Deferring to one consolidated PR is a reasonable operational choice.
The residual risk is that the consolidated PR might stall or be forgotten, leaving no changelog trace of this CVE-class fix for operators who read the log before upgrading. Consider one of:
- Reference the campaign tracking issue in the PR description (e.g. "CHANGELOG entry deferred to #XXXX"), so there is a durable pointer from this PR to the follow-up.
- Or add a single one-liner comment in CHANGELOG.md at the
[4.0.3]cut point as a marker:<!-- security entry for #2900 deferred to campaign consolidation PR -->, which costs nothing and survives merge.
Neither is a blocker; the fix is the important part.
Commits
chore: defer changelog entry to campaign consolidation — 51-char header, valid chore type, lowercase sentence subject. DCO Signed-off-by: trailer present and matching. Co-Authored-By: trailer present. Clean.
Wheels Bot -- Reviewer B (round 1)A's review is sound. One minor factual slip on character count; no false positives; no missed issues; verdict consistent with findings. Converging on approve. SycophancyNone detected. A verified the ReFind guard at EventMethods.cfc:330 and all five regression cases are present. A explicitly flagged a documentation-risk nit and chose comment rather than approve, which is the more conservative posture for a security fix. False positives
No other false positives. A's assertion that the BoxLang catch-body-local invariant does not apply is correct: Missed issuesNone detected. Re-scan of the diff:
Verdict alignmentA used ConvergenceAligned. The security fix is correct, the regression tests are comprehensive, and the only open item is a non-blocking documentation suggestion. No changes are required to merge. Joint recommendation: approve. |
…2949) Six remediation PRs (##2900, ##2901, ##2903, ##2908, ##2909, ##2910) had their wheels-bot-authored [Unreleased] entries stripped from their branches during the 2026-06-09 review-remediation campaign: with a dozen parallel PRs, the per-branch entries conflicted at the same CHANGELOG location on every sibling merge. The entry texts were preserved verbatim off-branch and are restored here under the existing Fixed / Security subsections. Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…at (#2980) Two dev-UI surfaces still bypassed the hardening shipped in #2909 / #2900: 1. /wheels/info JSON branch (info.cfm) serialized the full getApplicationMetadata() payload — datasource definitions (with credentials) and broader application config flowed through unredacted, bypassing the per-setting redaction the surrounding loops already use. The "metadata" field is now whitelisted to { mappings }, mirroring the HTML branch's Mappings table. 2. /wheels/docs core.cfm interpolated the unvalidated `format` request parameter directly into `include "layouts/<format>.cfm"` — same LFI traversal class $getRequestFormat was hardened against. core.cfm now routes `format` through a new `wheels.Public::$resolveDocFormat()` helper that validates against ^[A-Za-z0-9]+$ with an `html` fallback. Both surfaces are dev-environment-gated by $blockInProduction(), so this is defense-in-depth, not an urgent exposure. Fixes #2974 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
$getRequestFormat()(vendor/wheels/events/EventMethods.cfc:327) returnedurl.formatverbatim, and$runOnErrorinterpolates that value into the on-disk error-template include path —"#application.wheels.eventPath#/onerror.#local.format#.cfm"(vendor/wheels/events/EventMethods.cfc:129) — guarded only byFileExists. An attacker-supplied value such as?format=../../somefiletherefore enabled path traversal / local file inclusion of any.cfmon disk during error rendering. Both call sites are affected via the same helper: the developmentshowErrorInformationbranch (EventMethods.cfc:47) and the production template-include branch (EventMethods.cfc:128-134).Source
Internal multi-agent framework review, 2026-06-09, finding T4 (format-lfi).
Fix
Gate the
url.formatbranch in$getRequestFormat()behindReFind("^[A-Za-z0-9]+$", url.format), falling through to the existing"html"default for anything else. Applied at the source so both consumers are covered with one change. A plain-alnum token covers every configured format key (html,xml,json,csv,pdf,xls) and anyaddFormat()extension, while rejecting traversal sequences, dots, slashes, backslashes, null bytes, encoded variants, and duplicate-param comma-joins.Deliberately out of scope:
http_acceptelse-branch only emits$get("formats")keys (developer-controlled config), so it was left alone.$requestContentType(vendor/wheels/controller/rendering.cfc:738) passesparams.formatthrough verbatim, but both consumers validate against$acceptableFormatsbefore building any template path — not vulnerable, no change.vendor/wheels/public/docs/core.cfm:77does an unvalidatedinclude "layouts/#request.wheels.params.format#.cfm", but that surface is production-blocked byPublic.cfc$blockInProduction()(security(dispatch): verify /wheels/public/* is prod-gated or disable in production #2233) — dev-mode-only exposure, recommended as a separate low-severity follow-up finding rather than folding into this diff.Tests
Added five regression cases to
vendor/wheels/tests/specs/events/onerrorSpec.cfc($getRequestFormat rejects unsafe format tokens (T4 LFI)):../traversal, slash/dot tokens, two valid alphanumeric formats preserved, and empty-string fallback. A private$requestFormatForhelper (mirroring the existing$expectedStatusForpattern in the same spec) saves/restoresurl.formatwith try/finally so the harness's own?format=jsonoutput detection is undisturbed.Local verification on Lucee 7 + SQLite: red pre-fix (3 of the 5 cases fail — the pre-fix code returns
url.formatverbatim, so the traversal/empty cases come back unchanged), green post-fix (13 pass / 0 fail for the bundle). Per-PR CI runs the full engine × DB matrix.Cross-engine notes
localassignments (thefinallyonly mutates the sharedurlstruct), noattributeCollection, noLeft(str, 0), no reserved-scope parameter names (formatValue).$getRequestFormatkeepspublicaccess with the$prefix (mixin invariant —$integrateComponentsonly copies public methods) and itsEventHandlerInterfacesignature is unchanged.java.util.regexallow$to match before a single trailing newline, so"json\n"passes the gate — but the accepted value is then alnum + one\n(no slash/dot/backslash possible),FileExistsmisses, and execution lands on the safeonerror.cfmfallback. Harmless, not a bypass; tightening to\A…\zis an optional cosmetic follow-up.🤖 Generated with Claude Code