Repository navigation
sec(api): shell-escape the Bicep/ARM deploy script render (#958) - #963
Conversation
…d#958) deploy-azure.sh in the format=bicep and format=arm federation bundles was rendered from the raw template data, while the cli script and the CFN deploy script go through shellEscapeData. The script embeds the session user's email in a double-quoted bash string, and net/mail accepts addresses with backticks and "$(...)", so a user could plant a command that ran on the machine of whoever executed the bundle. Render the deploy script with shellEscapeData. The params JSON interpolates no fields and keeps the raw data. Tests render both bundles through the handler with hostile session emails, evaluate the CONTACT_EMAIL assignment and the registration URL in a sandboxed bash, and assert the values come back verbatim with no command run. A benign render stays byte-identical, and the params file is pinned to its template. Closes LeanerCloud#958 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 2
✨ Finishing Touches
Comment |
cristim
left a comment
There was a problem hiding this comment.
Independent review verdict: CLEAN at 839c3b6
Reviewer: fresh Claude agent (Sonnet, high effort), not the author, cold brief. The PR body was treated as a claim. Coordinator: claude-cc-reviewer.
Blocking findings: none. For deploy-azure.sh (bicep/arm), the fix is complete.
Non-blocking notes:
}in the email: atinternal/iacfiles/templates/azure-wif-bicep-deploy.sh.tmpl:64, a}closes${CUDLY_CONTACT_EMAIL:-...}early and reorders the value. This is not injection, because the rest stays inside the escaped double quotes. You already list this as out of scope, and the CLI/CFN templates have the same pattern.- Heredoc JSON: at template lines 70-81, the unquoted heredoc puts
${CONTACT_EMAIL}into JSON. An email with"or\gives malformed JSON in the self-registration payload. The heredoc expands the value once and never re-evaluates it, so there is no shell risk. You list this as out of scope too. - Newlines on comment lines (filed as #970): the other shell and tfvars templates put
AccountName/AccountExternalID/Source/AccountSlugon#comment lines.shellEscapedoes not handle\n, and account names are only checked for non-empty. This does not apply to the bicep/arm path.
What was attacked and how (local macOS; no cloud, az, curl or network calls):
- Placeholder inventory: the bicep deploy script has exactly two placeholders.
{{.ContactEmail}}(line 64) is in double quotes inside${X:-...}.{{.CUDlyAPIURL}}(line 83) is in double quotes. Neither is in a heredoc,$(...), single quotes or an unquoted context. Escaping\ "$is therefore sufficient, and!` history expansion is off in non-interactive bash. - Sources:
ContactEmailissession.Email(handler_federation.go:166), gated bymail.ParseAddress. A scratch Go program confirmed it rejects raw CR/LF, including inside quoted local parts and after a backslash.CUDlyAPIURLcomes from trusted config (dashboard URL or Lambda domain), not the Host header. AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null go test ./internal/api/ -run 'Federation|AzureTemplate|Azure' -count=1 -v: all pass, with no SKIP (8 email, 2 URL and 2 benign subtests).go vet ./internal/api/was clean.- Fail-before: in a scratch worktree with the one-line fix reverted, the email test and the URL test both fail.
- Mutation probes: dropping
$, the backtick,"or\fromshellEscape, or dropping theCUDlyAPIURLescaping, is each killed. 0 survivors. - Other generated files: the params JSON template has no fields, and the test pins that. The Bicep/ARM files are static embeds. The benign render is byte-identical.
- CI safety of the new tests: they run
bash --noprofile --norc -cint.TempDir(), with env limited toPATH. They evaluate only the extracted assignment line or URL word, and make no network calls.
CodeRabbit: pending at review time. Any actionable CR findings still need resolving.
Remaining gate items (maintainer or author):
- CI:
mainis red onTestScheduler_ListRecommendations_StaleCacheKicksBackgroundRefresh, which #960 fixes. Please rebase after #960 merges. An emptygit range-diffagainst 839c3b6 keeps this verdict. - Labels: #958 has no triage labels yet. The author suggests type/security, type/bug, priority/p2 and severity/medium.
- Draft: the PR is still a draft. Mark it ready when you consider it done.
|
#960 has merged (2ef2f5d), which fixes the red |
|
Thanks @cristim. I merged 🤖 Generated with Claude Code |
|
Main has been merged in (picks up #960). The new head adds only that merge commit on top of the reviewed 839c3b6. Equivalence check: |
Root cause
writeAzureTemplateFiles(internal/api/handler_federation.go) renderedtemplates/azure-wif-bicep-deploy.sh.tmplwith the raw template data. Thecliscript (renderSingleFile) and the CFN deploy script (writeCFNFiles) both go throughshellEscapeData, but this one didn't. The template puts two values inside double-quoted bash strings:CONTACT_EMAIL="${CUDLY_CONTACT_EMAIL:-{{.ContactEmail}}}", whereContactEmailissession.Email-X POST "{{.CUDlyAPIURL}}/api/register"Every email write path checks only
mail.ParseAddress. It acceptsa`id`b@example.comand"a$(id)b"@example.com, and the raw string is what gets stored. So a user who sets such an address and passes aformat=bicep|armbundle to an Azure admin gets that command run on the admin's machine when the admin runsdeploy-azure.sh.Fix
A one-line change: render the deploy script with
shellEscapeData(data), the same helper the CLI and CFN scripts use, and add a comment.azure-wif-bicep-params.json.tmplinterpolates no fields, so its render keeps the raw data. A test now pins that, so if someone later adds a field to it, the test fails and points at JSON escaping (not shell escaping).shellEscapeescapes\,",`and$. That is exactly the set that is special inside a double-quoted bash string, so ordinary values render unchanged.Closes #958
Tests
New file
internal/api/handler_federation_azure_escape_test.go. It is separate becausehandler_federation_test.gois already 1760 lines, and keeping it separate stays clear of #955's hunks.TestGetFederationIaC_AzureDeployScriptEscapesSessionEmail: goes through the real handler (getFederationIaC) for bothbicepandarm, with four session emails. Each email is first checked to passmail.ParseAddress:"a`touch pwned`b"@example.com"a$(touch pwned)b"@example.coma`id`b@example.com"a\"b\\c$HOME"@example.comFor each one it extracts
deploy-azure.shfrom the zip and runsbash -non the whole script. It then takes theCONTACT_EMAIL=line and evaluates it inbash --noprofile --norc -cwith an empty environment (PATH only), inside an emptyt.TempDir(), followed byprintf '%s' "$CONTACT_EMAIL". It asserts that the output equals the email exactly and that nopwnedfile was created. There are noaz/curlcalls and no network.TestBuildAzureTemplateZip_DeployScriptEscapesEveryField: calls the builder directly with a hostileCUDlyAPIURL(containing$(touch pwned), a backtick and"). It evaluates the-X POST "<url>/api/register"word the same sandboxed way and checks it comes back verbatim with no marker file. It also asserts thatazure-wif-bicep-params.jsonin the zip is byte-identical to its template.TestBuildAzureTemplateZip_BenignDataRendersUnchanged: withadmin@example.comandhttps://cudly.example.com,deploy-azure.shis byte-identical to the unescaped render.Verification
Commands I ran locally on macOS (bash 3.2) at 839c3b6. Every
go testran withAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null.Fail before (fix reverted with
git stash push internal/api/handler_federation.go, new tests kept):That run reproduced the bug directly: the substitution created the marker file, and
idran (its output ended up in CONTACT_EMAIL).Pass after: all three PASS.
Mutation probes (each a temporary edit, then reverted):
ContactEmail...EscapesEveryFieldFAILs (URL unescaped)CUDlyAPIURL...EscapesSessionEmailFAILs{{.ContactEmail}}to the params JSON template...EscapesEveryFieldFAILs (params pin)CI-equivalent checks:
go build ./...OKgo vet ./internal/api/OKgo test ./internal/api/ ./internal/iacfiles/... -count=1OKgo test -short -race ./internal/api/ -count=1OKgolangci-lint run --allow-parallel-runners ./internal/api/...(v2.10.1): 0 issuesgocyclo -over 10 internal/api/handler_federation.go: silentbash scripts/gofmt-hook.sh <changed files>OKbash scripts/gosec-hook.sh <changed files>(v2.28.0) OKmake tidy-checkOKgit secrets --scanon the changed files (AWS patterns plus the custom patterns fromscripts/setup-git-secrets.sh, run in a temp repo copy) OKbash scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAILgo test -short ./...passes exceptinternal/schedulerTestScheduler_ListRecommendations_StaleCacheKicksBackgroundRefresh(5s timeout). It fails the same way on a cleanorigin/maincheckout, and this PR doesn't touch that package.handler_federation.gowas already over 500 lines (839) before this change and is now 842.No cloud APIs, deploys,
azcommands or network calls were run. The bash evaluations run onlyprintfand variable assignments, in an empty temp directory.Merge with #955
#955 (
fix/issue-952) also editshandler_federation.goandhandler_federation_test.go. This PR's hunk is inwriteAzureTemplateFiles, and #955's hunks are aroundshellEscapeandaddBundleTerraform. The new tests are in a separate file.git merge-tree --write-tree fix/issue-958 fork/fix/issue-952exits 0 with treefe530bc5, so there are no conflicts.go test ./internal/api/ -run 'Federation|AzureTemplate|Azure'on that merged tree passes.Out of scope / follow-ups
`,$,",\,<,>,}) at the write paths ininternal/auth/service_user.gowould add defense in depth. It is not a substitute for escaping, and it is a behavior change for existing users, so it belongs in its own issue.}in the email:}is not escaped, so inside${CUDLY_CONTACT_EMAIL:-...}it closes the default value early. The value comes out reordered (a}brenders asab}), but nothing is executed. The CFN deploy script uses the same pattern. This could be handled with the follow-up above."${CONTACT_EMAIL}"and doesn't JSON-escape it, so an email containing"produces a malformed or altered payload. That is a data-integrity issue on the bash side, not command execution. The CFN script has the same shape.Labels
Could a maintainer apply labels? I can't. Suggested:
type/security,type/bug,priority/p2,severity/medium.🤖 Generated with Claude Code