fix(controller): sendEmail multipart handling and sendFile path containment - #2922
Conversation
…inment sendEmail (vendor/wheels/controller/miscellaneous.cfc): - ML1: coerce a zero-length layout to false so layout='' no longer crashes with an array-index error, and cap templates at two with a friendly Wheels.IncorrectArguments instead of an opaque index error (ML2) - ML3: default the mail type to text when detectMultipart=false and no explicit type is passed (was: Element TYPE is undefined) - ML4: honor detectMultipart=false by preserving the passed-in template order (text first) instead of always reordering by <-count - ML5: write text and html bodies separated by a real CRLF-CRLF blank line in writeToFile output (ListAppend used only the first delimiter char) - ML6: snapshot and restore the controller's variables scope around email template rendering so custom view arguments no longer leak into (or permanently overwrite parts of) the rest of the request - ML7: pass S/MIME signing and encryption attributes (sign, keystore, keystorepassword, keyalias, keypassword, encrypt, recipientcert, encryptionalgorithm) through to cfmail instead of silently swallowing them as view data; document the allowlist semantics sendFile: - C10: reject path traversal in file/directory (null-byte strip plus '..' check after URL-decoding and backslash normalization, same guard shape as $generateIncludeTemplatePath) - SEC-23: strip CR/LF, double quotes, and backslashes from the download display name so it cannot break out of the quoted Content-Disposition filename parameter Specs added for every behavioral change (red against the pre-fix code). 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
TL;DR: This PR fixes nine real bugs across sendEmail (multipart crash paths, variables-scope leak, S/MIME attributes) and sendFile (path traversal, Content-Disposition injection) with solid BDD coverage. The cross-engine notes check out, the $mail wrapper already copies arguments to a plain struct so Adobe 2023/2025 is fine. Requesting changes for one correctness gap in the ML6 restore and flagging a test-description discrepancy and a minor edge-case note.
Correctness
ML6 restore: no try/finally leaves variables scope dirty on render exceptions
vendor/wheels/controller/miscellaneous.cfc, lines 76–118
// render loop — can throw if template file is missing or view code errors
for (local.i = 1; local.i <= local.iEnd; local.i++) {
local.content = $renderView($template = local.item, $layout = local.layoutArray[local.i]);
...
}
// restore loop — never reached if $renderView threw above
local.iEnd = ArrayLen(local.customViewVariables);
for (local.i = 1; local.i <= local.iEnd; local.i++) {
local.key = local.customViewVariables[local.i];
if (StructKeyExists(local.shadowedVariables, local.key)) {
variables[local.key] = local.shadowedVariables[local.key];
} else {
StructDelete(variables, local.key);
}
}If $renderView throws (template not found, view code error, etc.), the restore loop is skipped and any custom arguments the developer passed remain injected into the controller's variables scope. This affects subsequent rendering in the same controller instance — including onError handlers that call renderPage. The PR body explicitly calls this a "non-blocking follow-up suggestion," but since the restore code is already written and the fix is a one-liner to hoist into a try/finally, deferring it leaves ML6 partially fixed:
try {
for (local.i = 1; local.i <= local.iEnd; local.i++) {
local.item = local.templateArray[local.i];
local.content = $renderView($template = local.item, $layout = local.layoutArray[local.i]);
...
}
} finally {
// Restore inside finally so it runs even if $renderView throws.
local.iEnd = ArrayLen(local.customViewVariables);
for (local.i = 1; local.i <= local.iEnd; local.i++) {
...
}
}try/finally is supported on Lucee 5/6/7, Adobe CF 2018–2025, and BoxLang. Please address before merge.
Minor: Find("..") fires on .. as a substring, not only as a path component
vendor/wheels/controller/miscellaneous.cfc, line 229
Find("..", Replace(URLDecode(arguments.file), "\", "/", "all"))Find("..", "summary..v2.pdf") returns non-zero, so a call like sendFile(file="report..final.pdf") throws Wheels.InvalidPath even though the path is legitimate. This is the same limitation carried by $generateIncludeTemplatePath (line 530 of rendering.cfc), so the behaviour is consistent within the framework. Not new to this PR, but worth documenting: the guard intentionally rejects filenames with .. anywhere in the component, and the error message should mention that filenames must not contain the two-dot sequence (not just directory-traversal attempts).
Tests
Backslash traversal test missing despite being listed in the PR description
The PR body says tests cover "literal, encoded, backslash, and directory variants." The actual new specs in miscellaneousSpec.cfc have tests for literal (../../../../config/settings.cfm), URL-encoded (%2e%2e/...), and directory, but there is no backslash variant for the file argument:
it("rejects backslash path traversal in the file argument", () => {
args.file = "..\..\..\config\settings.cfm"
expect(function() {
_controller.sendFile(argumentCollection = args)
}).toThrow("Wheels.InvalidPath")
})The PathTraversalSpec for $generateIncludeTemplatePath includes an explicit backslash test (it("rejects partial names with backslashes", ...)). Adding the equivalent for sendFile keeps the test inventory consistent with the description and the sibling spec. Low effort, and it verifies the Replace(..."\", "/", "all") normalization path specifically.
Docs
No CHANGELOG.md [Unreleased] entry
The PR body states this is intentionally deferred to a campaign-end consolidation. That is noted; flagging only for completeness so reviewers don't accidentally merge without changelog coverage.
Cross-engine
No new concerns. The $mail() wrapper in Global.cfc (lines 84–87) already copies arguments to a plain struct before passing to cfmail, so the S/MIME attributes added to mailTagArgs will reach Adobe 2023/2025 without hitting the attributeCollection = arguments restriction. The ReReplace regex for display-name sanitization is confirmed in-tree precedent from sse.cfc. No new closures, no Left(str, 0), no local.X inside catch.
Commits
fix(controller): sendEmail multipart handling and sendFile path containment — valid type, valid scope, 67-char header, sentence case. Passes commitlint.
Wheels Bot — Reviewer B (round 1)A's review is accurate and well-evidenced. The try/finally finding is a real correctness gap, the backslash-test gap is a genuine inventory mismatch, and the SycophancyNone detected. A's approval language ("solid BDD coverage", "cross-engine notes check out") is backed by specific diff citations, and the verdict is CHANGES_REQUESTED — not an unearned approve. False positivesNone detected.
Missed issuesMinor: variables-scope leak test does not distinguish fixed from unfixed behavior.
_controller.sendEmail(argumentCollection = leakArgs) // leaks customArgument if unfixed
result = _controller.sendEmail(argumentCollection = args)
expect(result.text).toBe(textBody)
Verdict alignmentA's CHANGES_REQUESTED is consistent with the findings: one explicit blocker (try/finally), one test gap (backslash variant), and two informational notes. The verdict matches the severity. ConvergenceAligned. A's two actionable items are:
Both are mechanical, low-risk, and have clear prior-art in the codebase ( |
…ackslash traversal spec Addresses the wheels-bot review on ##2922: - ML6: the variables-scope restore now runs in a finally block so the controller scope is cleaned up even when $renderView throws (missing template, view error). The restore loop itself lives in a new $restoreEmailViewVariables() helper because Lucee 7 miscompiles for loops that use local (or var) variables inside finally blocks ("variable [local] doesn't exist" at runtime) - verified with minimal probes against lucee@7.0.1+100. New spec covers the render-throws path. - sendFile traversal guard: the Wheels.InvalidPath message and the \@file/\@Directory docblocks now state that the two-dot sequence is rejected anywhere in the value, including otherwise legitimate file names such as report..final.pdf. - Added the missing backslash traversal spec for the file argument (Chr(92) construction, matching PathTraversalSpec). Verified: wheels.tests.specs.controller.miscellaneousSpec on Lucee 7 + SQLite and Adobe 2023 + SQLite, both 41 pass / 0 fail / 0 error / 1 pre-existing skip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
|
Addressed the Reviewer A review in 97a10c2: ML6
Missing backslash traversal test — Added Changelog — still intentionally deferred to the campaign-end consolidation, as noted. Verified |
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: This follow-up commit addresses both blocking findings from the previous round: the sendEmail variables-scope restore is now wrapped in a try/finally block (with the restore loop extracted to $restoreEmailViewVariables to work around a Lucee 7 for-loop-in-finally miscompile), and the backslash traversal spec for sendFile is present. No new correctness, cross-engine, or security findings. Commenting only to flag a documentation gap — no changes required for merge.
Cross-engine
Lucee 7 for-loop-in-finally miscompile is real but undocumented
vendor/wheels/controller/miscellaneous.cfc, new $restoreEmailViewVariables function docblock and the finally comment.
The commit message records: "Lucee 7 miscompiles for loops that use local (or var) variables inside finally blocks ('variable [local] doesn't exist' at runtime) — verified with minimal probes against lucee@7.0.1+100."
The workaround is correct: extracting the restore for loop into a separate public $-prefixed helper means no for (local.i = ...) appears literally inside a finally block. The call site in finally only reads local.customViewVariables and local.shadowedVariables as call arguments — it does not declare or iterate them there. The verification run on Lucee 7 + SQLite and Adobe 2023 + SQLite confirms this is safe.
The gap: this behaviour is not recorded in .ai/wheels/cross-engine-compatibility.md or CLAUDE.md "Cross-Engine Invariants." Any future developer who reaches for a restore loop in a finally block on Lucee 7 will hit the same miscompile without any documented pointer. A one-paragraph entry modelled after the existing "createDynamicProxy Requires a CFC on Lucee 7" section would be sufficient:
for (local.i = ...)insidefinallymiscompiles on Lucee 7. Lucee 7.0.1+100 throws "variable [local] doesn't exist" at runtime when aforloop declares its iteration variable in thelocalscope inside afinallyblock. Workaround: hoist the loop into a helper function so thefinallyblock contains only a function call (reference:$restoreEmailViewVariablesinmiscellaneous.cfc).
Not blocking — the in-code comment is adequate for the immediate reviewer. But worth capturing before the next contributor runs into it.
Docs
CHANGELOG entry still deferred. The PR body notes this is intentional (campaign-end consolidation). Flagging only so it is not forgotten at merge time.
Commits
Both commits are valid conventional-commit headers:
fix(controller): sendEmail multipart handling and sendFile path containment— 67 charsfix(controller): run sendEmail variables restore in finally and add backslash traversal spec— 91 chars
Commit body for 97a10c25 includes DCO Signed-off-by trailer and co-author attribution.
Tests
The render-throws path ("does not leak custom view arguments into the controller when rendering throws") is present and directly tests the finally guarantee. The backslash traversal spec uses Chr(92) construction to avoid escape-sequence ambiguity, consistent with PathTraversalSpec.cfc. Both additions are correct.
Wheels Bot — Reviewer B (round 1)A's review checks out. No sycophancy, no false positives, no significant missed issues on the diff I re-scanned. SycophancyNone detected. A used COMMENTED (not APPROVE) state with an explicit "no changes required for merge" verdict, cited the specific round-1 fixes verified, and did not approve blind. False positivesNone that affect conclusions. A's commit header character counts are slightly off (commit 1 is 75 chars, not 67; commit 2 is 92 chars, not 91) but both are well under the 100-char limit and the verdict is correct. Missed issuesNone detected. I initially suspected the "does not leak" test pair was a false-pass because the template might not reference A's documentation-gap flag (Lucee 7 Verdict alignmentA's COMMENTED state with "no changes required for merge" is consistent with the findings — the two round-1 blockers (try/finally scope restore and backslash traversal spec) are both present and correct, and no new issues appear. ConvergenceAligned. The implementation is correct, the round-1 blockers are fully addressed, and no new correctness, cross-engine, or security issues appear in the diff. Joint recommendation: merge when ready. |
Reviewer A's follow-up on this PR flagged that the Lucee 7 for-loop-in-finally miscompile discovered here was undocumented. Adds invariant 12 to CLAUDE.md and a full entry with probe details and the helper-hoist pattern to .ai/wheels/cross-engine-compatibility.md. 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
TL;DR: The third commit is documentation only — it adds the Lucee 7 for-loop-in-finally miscompile to both CLAUDE.md (invariant #12) and .ai/wheels/cross-engine-compatibility.md. This directly addresses the documentation gap called out in round 2. No code changes, no new correctness or cross-engine issues. Approving with two minor nits (no blockers).
Docs
Nit: example loop style in .ai/ is inconsistent with CLAUDE.md title
.ai/wheels/cross-engine-compatibility.md, new section WRONG example:
for (local.key in local.savedState) {CLAUDE.md invariant #12 titles the rule with for (local.i = ...) (C-style numeric loop), but the extended doc example shows a for...in (foreach-over-struct) loop. Both forms are presumably affected by the Lucee 7 miscompile, but a reader who only hits the .ai/ page and uses a numeric loop might assume only the for-in variant is dangerous. A one-line parenthetical such as "Both for (init; cond; step) and for (item in collection) forms are affected" would close the gap. Not blocking.
CHANGELOG entry still deferred. Noted from prior rounds; flagging again so it is not forgotten at merge time.
Commits
fix(controller): sendEmail multipart handling and sendFile path containment- 67 chars, validfix(controller): run sendEmail variables restore in finally and add backslash traversal spec- 91 chars, validdocs: document the Lucee 7 finally-block loop miscompile invariant- 65 chars, valid
All three commits conform to commitlint.config.js. DCO Signed-off-by trailer present on the second commit (bot-authored); the third commit is similarly attributed.
Cross-engine
The documentation itself introduces no new CFML code. The RIGHT example correctly shows that calling a helper from finally (with local variables as arguments) is safe - the miscompile is specific to loop constructs, not to local-scope references in general. The note that the helper must be public (mixin invariant #7) is accurate and the reference to $restoreEmailViewVariables() in miscellaneous.cfc gives a concrete, in-tree anchor.
Wheels Bot — Reviewer B (round 1)A's review is clean. The docs-only commit correctly addresses the documentation gap A flagged in the previous round, and the APPROVED verdict is well-supported by the diff. No sycophancy, no false positives, no missed issues. SycophancyNone detected. A's approval cites specific evidence (docs-only commit, no new CFML code, accurate reference anchor) rather than a bare LGTM. The prior implementation was already converged-approve on SHA False positivesNone detected.
Missed issuesNone detected. I re-scanned the diff against the CLAUDE.md cross-engine invariants and the CFML patterns listed there. The new documentation section in Verdict alignmentA's APPROVED state is consistent with the findings: two non-blocking nits (loop-style inconsistency, deferred CHANGELOG), no actionable blockers on a docs-only commit. The verdict matches the diff scope. ConvergenceAligned. The implementation was approved in the prior round; this third commit adds the missing documentation A required. Both nits are editorial and do not affect correctness. Joint recommendation: merge when ready. |
Summary
Fixes nine review findings in the controller mailer (
sendEmail) and file-streaming (sendFile) mixins invendor/wheels/controller/miscellaneous.cfc. ThesendEmailfixes remove three crash paths (3+ templates,layout="",detectMultipart=falsewithouttype), makedetectMultipart=falseactually honor the documented text-first template order, emit a real CRLF-CRLF blank line inwriteToFileoutput, stop email view arguments from leaking into (or permanently overwriting parts of) the controller'svariablesscope, and pass Adobe's S/MIME signing/encryption attributes through tocfmailinstead of silently swallowing them. ThesendFilefixes add a path-traversal guard onfile/directory(null-byte strip +..rejection after URL-decode and backslash normalization, same shape as$generateIncludeTemplatePath) and sanitize the download display name so it cannot break out of the quotedContent-Dispositionfilename parameter.Findings addressed
layoutstring @vendor/wheels/controller/miscellaneous.cfc:42-45— zero-lengthlayoutis coerced tofalsebefore the list split, and the 3+-template sub-cases now route into the ML2 throw before the render loop.vendor/wheels/controller/miscellaneous.cfc:47-55(docblock at:10) —ListLen(arguments.template) > 2now throws a friendlyWheels.IncorrectArgumentsdocumenting the 2-template limit, before any side effects.detectMultipart=false+ single template + notype→ undefined-variable crash @vendor/wheels/controller/miscellaneous.cfc:135-138— newelse ifdefaultsarguments.type = "text"(matchingcfmail's own default) when no explicit type is passed.detectMultipart=falsedoes not actually disable multipart detection @vendor/wheels/controller/miscellaneous.cfc:91-103— the<-count reordering is now gated onarguments.detectMultipart; when false, the given order is preserved and labeled[1]=text/[2]=htmlper the documented contract.writeToFilejoins text and html bodies with a single bare CR, not a blank line @vendor/wheels/controller/miscellaneous.cfc:176-184— plain concatenation with a real CRLF-CRLF separator (CFML list functions treat each delimiter character separately); separator skipped when either side is empty.variablesscope and never cleaned up @vendor/wheels/controller/miscellaneous.cfc:62-72(snapshot) and:109-118(restore) — shadowed values are restored and added keys deleted after the email templates render.vendor/wheels/controller/miscellaneous.cfc:40— allowlist gainssign,keystore,keystorepassword,keyalias,keypassword,encrypt,recipientcert,encryptionalgorithm; docblock at:5now documents the allowlist semantics.sendFile()has no path-traversal guard onfile/directory@vendor/wheels/controller/miscellaneous.cfc:225-238— null bytes stripped, then..rejected on the URL-decoded, backslash-normalizedfileANDdirectorybefore any path math; throwsWheels.InvalidPath. Blocks literal, percent-encoded, and backslash variants.Content-Dispositionfilename built from an unsanitized display name insendFile()@vendor/wheels/controller/miscellaneous.cfc:322-323—[\r\n"\\]stripped from the display name at the finalization point, covering both thedeliver=trueheader and thedeliver=falsetest struct.Findings verified already-fixed
None — all nine findings reproduced against
origin/developat implementation time (zero stale references). The reviewer independently spot-checked ML3 (develop'sifwithoutelseat the type-assignment site), ML5 (develop's 4-characterListAppenddelimiter), and C10 (no guard on develop) and confirmed all were live bugs.Source
Internal multi-agent framework review 2026-06-09, wave 2, package
controller-mailer-sendfile.Tests
11 new BDD specs in
vendor/wheels/tests/specs/controller/miscellaneousSpec.cfc(plus a new view assetvendor/wheels/tests/_assets/views/test/bracketsemailtemplate.cfm), each written to fail against the pre-fix code: path-traversal rejection (literal, encoded, backslash, anddirectoryvariants), display-name sanitization,writeToFileblank-line bytes,layout="", >2 templates,detectMultipart=falsewithouttype, order preservation underdetectMultipart=false, variables-scope restore (shadow + leak), and S/MIME attribute pass-through.Local verification: single-bundle run of
wheels.tests.specs.controller.miscellaneousSpecon Lucee 7 + SQLite via the prebuilt docker test image — HTTP 200, 39 pass / 0 fail / 0 error (1 pre-existing intentional skip). Full cross-engine coverage deferred to the CI compat-matrix, which is the real gate.Cross-engine notes
No new closures, no
arguments-as-attributeCollection, noLeft(str, 0), nolocalassignment insidecatch; all touched functions remainpublic. TheReReplace("[\r\n""\\]", ...)sanitizer has direct in-tree precedent invendor/wheels/controller/sse.cfc(runs on all engines), and theURLDecode+ backslash-normalize traversal guard mirrors$generateIncludeTemplatePath(vendor/wheels/global/rendering.cfc). The new test asset's bare<sequences are not tag-parseable (Lucee tag-scanner safe). Reviewer flagged two non-blocking follow-up suggestions (try/finally around the ML6 restore; paddinglayoutArrayto template count for the degeneratelayout=","input) — deferred to keep this diff minimal.Changelog
Entry deliberately omitted; consolidated at campaign end.
🤖 Generated with Claude Code