fix(control-plane): bound golden-run name and tag writes into workflow_runs.metadata - #1025
Merged
Conversation
…adata POST /api/ui/v2/workflow-runs/:run_id/golden wrote the caller-supplied name and tag list into workflow_runs.metadata with no bounds at all. The name was only TrimSpace'd, so a 1 MiB name persisted verbatim; sanitizeStringList trimmed and de-duped but capped neither the entry count nor the entry length, and it preallocated its output slice (and an unbounded de-dupe map) straight from the attacker-controlled input length. That row is re-read and re-serialised on every runs-list page that contains the run, so both are stored amplification vectors (#944). Cap tags at 20 entries of at most 64 runes each, and truncate the name at 200 runes. Over-long tags are dropped rather than truncated: byte-slicing can land mid-rune and json.Marshal silently rewrites the invalid UTF-8 to U+FFFD. Lengths are counted with utf8.RuneCountInString so a 64-rune CJK tag survives. The output slice and de-dupe map are now sized min(len(values), maxCount) and the loop stops once maxCount survivors are collected, so a multi-million-entry tag array cannot force a large allocation before the cap applies. This route is UI-private and its only caller sends one hard-coded tag, so oversized input is bounded silently rather than rejected — a 400 would break the existing "Save as golden run" button. The name fallback is unchanged: an empty name still falls back to run_id, and run_id itself is not truncated. Forward-only. Nothing re-validates on read, so rows that already hold oversized golden metadata keep reading back exactly as they do today. The caps are named constants so the follow-up run-metadata endpoint can reuse the same bounds and the same helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y reads Adds the behaviour tests for the golden-run caps: - sanitizeStringList: 100 inputs cap to the first 20; a 65-rune ASCII entry is dropped while its 64-rune neighbour survives; a 64-rune CJK entry is kept byte-identical (proving it is not truncated into U+FFFD) while a 65-rune one is dropped; the legacy trim/de-dupe/order behaviour is pinned unchanged; the maxCount<=0 and maxRunes<=0 guards are exercised. - A 100k-entry input asserts cap(out) <= 20, which is the allocation contract — a length assertion alone would still pass with the old preallocation. - truncateRunes: cut on a rune boundary, result always valid UTF-8 with no replacement character, and the non-positive cap guard. - Handler round-trip: a POST with 50 tags plus one over-long tag stores exactly 20, a 1 MiB name stores 200 runes and keeps the whole metadata blob under 4 KiB, and a blank name still falls back to run_id. - Read-back: a pre-seeded row holding 50 tags and a 500-rune name still surfaces in full on both the runs list and the run detail, pinning that this change is forward-only and read paths do not re-validate. The two pre-existing golden-route tests are left untouched as the behaviour-unchanged regression guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
📊 Coverage gateThresholds from
✅ Gate passedNo surface regressed past the allowed threshold and the aggregate stayed above the floor. |
Contributor
📐 Patch coverage gateThreshold: 80% on lines this PR touches vs
✅ Patch gate passedEvery surface whose lines were touched by this PR has patch coverage at or above the threshold. |
This was referenced Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
POST /api/ui/v2/workflow-runs/:run_id/goldenwrote the caller-supplied golden-run name and tag list intoworkflow_runs.metadatawith no bounds at all. The name was onlyTrimSpace'd, andsanitizeStringListtrimmed and de-duped but capped neither the entry count nor the entry length — and it sized its output slice and de-dupe map straight from the attacker-controlled input length. This caps tags at 20 entries of at most 64 runes, truncates the name at 200 runes, and stops the helper from preallocating from the input length.The change is forward-only and silent: nothing re-validates on read, and oversized input is clamped rather than rejected.
Why
Refs #944.
The golden metadata row is re-read and re-serialised on every runs-list page that contains the run, so an unbounded write there is stored amplification rather than a one-off. A probe stored a 1 MiB name that accounted for ~98% of the resulting metadata row — which is why capping tags alone would have left the strictly larger hole open.
This is not
Fixes #944: that issue is a broader proposal for first-class run display names, labels, links and external status. This PR only closes the unbounded-write hole on the existing golden route.Changes
fix(control-plane): bound golden-run name and tag writes into run metadatamaxGoldenTags(20) /maxGoldenTagRunes(64) /maxGoldenNameRunes(200) as package constants, so the follow-up run-metadata endpoint can reuse the same bounds and the same helper.sanitizeStringListtakesmaxCount, maxRunes; over-long entries are dropped, not truncated, and lengths are counted withutf8.RuneCountInString.min(len(values), maxCount)and break the loop oncemaxCountsurvivors are collected.truncateRuneshelper cuts on a rune boundary by walking rune start offsets, so a multi-megabyte name costs no extra allocation. Applied toreq.Name; therun_idfallback is deliberately not truncated.test(control-plane): cover golden-run metadata bounds and forward-only readssanitizeStringListandtruncateRunes, an allocation-contract test, a handler round-trip, and a read-back test. The two pre-existing golden-route tests are left untouched as the behaviour-unchanged regression guard.Validation contract
TestSanitizeStringListBounds/count_cap_preserves_first_entries;TestWorkflowRunHandlerSaveGoldenRunBoundsNameAndTags(51 tags posted → 20 stored)json.Marshalrewrites to U+FFFD. Counted in runes, so a 64-rune CJK tag survives.TestSanitizeStringListBounds/overlong_ASCII_dropped,/multibyte_rune_cap, plus the byte-identity assertion on a 64-rune CJK tag inTestSanitizeStringListBoundsrun_idand would change the saved label).TestTruncateRunes(all 7 rows, incl.multibyte_boundary);TestWorkflowRunHandlerSaveGoldenRunBoundsNameAndTagsasserts a 1 MiB name stores exactly 200 runes, non-empty, and that a blank name still falls back torun_idmin(len(values), maxCount)and the loop breaks atmaxCount, so a 5M-element array cannot allocate ~80 MB before any cap applies; the de-dupe map is bounded with it.TestSanitizeStringListDoesNotPreallocateFromInputLength— 100k entries, assertscap(out) <= 20(a length-only assertion would still pass against the old preallocation)TestSanitizeStringListBounds/legacy_sanitizing, plus the untouched pre-existingTestWorkflowRunHandlerSaveGoldenRun*testsTestWorkflowRunHandlerSaveGoldenRunBoundsNameAndTagsasserts200 OKwith clamped storage; the pre-existingTestWorkflowRunHandlerSaveGoldenRunErrorsstill pins the genuine 400/404/409 casesTestWorkflowRunHandlerGoldenReadBackIsNotRevalidated— a pre-seeded row with 50 tags and a 500-rune name surfaces in full on both the list and detail responsesconstblock inworkflow_runs.go);maxGoldenNameRunesis referenced directly by the handler test rather than a hard-coded 200How it was tested
CI-literal gates for the
control-planesurface, re-run after rebasing ontoorigin/main(b458f9c3, v0.1.138-rc.2):go build ./...— PASSgofmt -l <touched files>— PASS (no output)go vet ./internal/handlers/ui(plus the packages the incoming main commits touched) — PASSgo test -tags sqlite_fts5 -count=1 -timeout 40m $(go list ./... | grep -v internal/packages)— PASSPatch-coverage gate (the required CI check, threshold 80% on lines touched vs
origin/main): 100.00% across 25 touched lines oncontrol-plane. Run before the rebase; the rebase was conflict-free and the incoming main commits only added tests ininternal/events/internal/handlersplus a Go template bump, so the touched-line set is unchanged.No live control plane was started — every assertion runs against the in-process handler plus the test storage fixture.
Notes / follow-ups
Findings from review that were deliberately left for later rather than folded in here:
continuewith a rune-safe truncate still passes every current assertion, because a truncated 65-漢prefix collides with the 64-rune entry already in the list and the de-dupe map swallows it. The dangerous variant the contract actually warns about — naive byte-slicingtrimmed[:64]— is caught (it makesmultibyte_rune_capfail on invalid UTF-8), so the security-relevant property is genuinely pinned; only the benign distinction is unguarded. One extra table row with a non-colliding value would close it./api/ui/v2group and the server'smaxRequestBodyHandlergates only/api/v1/execute*and/api/v1/nodes/register*. SoShouldBindJSONstill decodes the whole body beforesanitizeStringListis entered. This PR closes the stored amplification hole that Proposal: first-class run display names, labels, links, and external status #944 is about; bounding the request body (viahttp.MaxBytesReader, asexecution_logs.goalready does) belongs with the stacked run-metadata cluster, which touches this file and already does reject-with-400 validation. The helper's doc comment overstates this slightly and should be reworded there.truncateRunesnow exists twice underinternal/handlers/with the same name and shape but different semantics — the pre-existing one ininternal/handlers/agentic/reasoners.goappends an ellipsis and trims trailing whitespace. They are unexported siblings so they do not collide today, but whoever hoists one into a shared package should rename rather than merge them.TrimSpaceruns before the cut. Purely cosmetic, and it cannot produce an empty name, so the contract still holds.This must land before the stacked run-metadata cluster, which edits the same file and is specified to reuse the capped helper.
🤖 Generated with Claude Code