node: fail closed when a required-labels spec names no label - #348
Conversation
There was a problem hiding this comment.
Code Review
This pull request updates the parseRequiredLabels function in internal/node/openai_scorer.go to handle whitespace-only inputs and reject contentful specifications that do not resolve to any valid labels (e.g., ",,"). This ensures that a caller's intent to constrain a request is not silently ignored, preventing a potential fail-open security issue. Accompanying unit tests have been added in internal/node/openai_facade_test.go to verify these new behaviors. There are no review comments, so I have no feedback to provide.
|
@aojea — the The failureWorth noting because it's easy to misread from the end of the log: This PR touches two files, both in Reproduced on a clean treeI reproduced it on a clean checkout of The mechanism looks like a wall-clock deadline losing to scheduler starvation rather than anything about the code under test: On the remedy — measurements, since the obvious fix doesn't holdI tried the tidy answer (use
Raising the constant improves the odds but none of these is immune — under enough starvation a wall-clock deadline will always lose. My load is deliberately pathological (~18× oversubscription), harsher than a CI runner, so this exaggerates the effect rather than reproducing CI exactly. One possibly relevant detail: Since the right fix looks like a judgement call about what this test should assert rather than a constant to bump, I didn't want to fold a guess into this PR. Happy to open a separate issue with the above, or a PR in whichever direction you prefer — just let me know. Sorry for the long comment; I wanted to save you the log-digging rather than just asking for a re-run. |
yeah, this is something we need to enforce more, the unit test has an option to pass a larger biscuit timeout to deal with resource constraints as you correctly explain |
|
good catch |
X-Sam-Required-Labels (and the identical required_labels parameter on the call_remote_tool MCP tool) is a fail-closed control: the request is only served by a provider that attests one of the named labels. An empty requirement set is the documented way to say "no requirement", and it switches the control off completely -- VerifyPeerLabels returns before it opens a stream, and rankProviders skips the label filter. parseRequiredLabels skipped empty comma-separated entries and then returned whatever it had accumulated, so a specification that carried content but named no label produced an empty set and no error: X-Sam-Required-Labels: ,, -> nil, nil -> no requirement at all The caller asked to be constrained and was silently served by an arbitrary provider instead. Reproduced end to end at the facade boundary: the request was forwarded and answered 200. Only a blank specification now means "no requirement"; a non-blank one that yields no label is rejected. Empty entries alongside real ones stay tolerated, so a trailing comma remains harmless, and both call sites already surface the error (400 invalid_request on the facade, a failed tool call over MCP). The sibling parsers were checked and are deliberately left alone: parseLabelsFlag in cmd/sam-node declares a node's own labels, where parsing to nothing grants less rather than more, and it is re-validated by api.ValidateLabels at startup. Tests, next to the change in internal/node/openai_facade_test.go: - ContentfulSpecNamingNoLabelFailsClosed pins the six separator-only shapes as errors (all six returned a nil error before this change) - BlankSpecMeansNoRequirement keeps "" and whitespace-only unconstrained - EmptyEntriesBesideRealOnesStayTolerated guards against over-correcting the trailing-comma case - Facade_Completions_ContentfulRequiredLabelsNamingNoLabelIsRejected asserts 400 and that no provider is reached (it saw 200 before) No new module: stdlib strings/fmt only, both already imported.
PR google#347 added a third parseRequiredLabels call site after the fail-closed fix was written. The shared parser covers it, but the a2a egress gate now pins ",," as a 400 alongside the malformed-entry case.
223a7a7 to
ecec9d2
Compare
|
Thanks @HosniBelfeki — the analysis is exactly right, and the fix is correct. Reviewed against current Two updates pushed (your commit preserved, authorship intact):
Re-ran CI; the earlier All green locally: full |
TestVerifyBiscuit_Concurrent flaked in CI: 5,000 concurrent VerifyBiscuit calls each carried a 500ms WithMaxDuration — a wall-clock bound — and on a 2-core -race runner a goroutine can be starved past that budget purely by scheduling (google#348 has the measurements: raising the constant only improves the odds, no finite value is immune under enough contention). None of these tests asserts timing, so the budget only needs to never bind: all thirteen call sites (500ms at four, 5s at nine) now share a single deliberately generous testTimeout constant.
|
Also pushed 8e6c047 folding in the flake fix, since your measurements already did the hard part: no finite budget survives arbitrary starvation, and none of these tests asserts timing — so the budget just needs to never bind. All thirteen call sites (the four 500ms and nine 5s you spotted) now share one deliberately generous |
|
Thanks |
|
Thanks @aojea — and for going beyond the review itself: the rebase onto On the separate issue you asked for: I'll skip it, since Happy to file one anyway if you'd still prefer it tracked separately. |
|
Sounds fair, thanks for your contributions |
Summary
X-Sam-Required-Labels— and the identicalrequired_labelsparameter on thecall_remote_toolMCP tool — is a fail-closed control. Its own schema says so: "Fails closed: the call is rejected unless the peer attests any one of them. Empty means no requirement." An empty requirement set switches the control off completely:VerifyPeerLabelsreturns before it opens a stream, andrankProvidersskips the label filter entirely.parseRequiredLabelsskipped empty comma-separated entries and then returned whatever it had accumulated, with no check that anything had been accumulated at all. A specification that carried content but named no label therefore produced an empty set and no error:The caller asked to be constrained, was told nothing was wrong, and was served by an arbitrary provider. Every separator-only shape behaved this way:
",",",,",",,,"," , ","\t,\t"," ,, ".This is a fail-open outcome on a fail-closed control, reachable from in-band, caller-supplied input on both the sidecar's inference surface and the MCP tool surface.
Confirmed end to end at the facade boundary, not just at the parser: with a
,,header and one eligible provider,handleCompletionsforwarded the request and answered200. The newTestFacade_Completions_ContentfulRequiredLabelsNamingNoLabelIsRejectedreproduces exactly that — it sees200before the change and400with no provider reached after it.The change
Only a blank specification now means "no requirement"; a non-blank one that yields no label is rejected:
strings.TrimSpace(h) == ""→nil, nil, unchanged and still the documented "no requirement". Trimming also covers the whitespace-only spec (" "), whichnet/httpalready collapses on the header path but which reaches the parser verbatim through the MCP tool parameter.Empty entries alongside real ones stay tolerated, so a trailing comma remains harmless —
"region=eu,"and" region=eu , , team=platform ,,"parse exactly as before. This is deliberate and pinned by its own test, because a list written with a trailing separator is normal and tightening it would be an unrelated behaviour change.Both call sites already surface the error and needed no changes:
400 invalid_requeston the facade (openai_facade.go), and a failed tool call wrapped asinvalid required_labelsover MCP (mcp_handlers.go).The fix follows the precedent already set by the sibling policy parser in the same subsystem:
NewEgressPolicyininternal/sambox/route.gorejects an empty allowlist entry outright rather than skipping it, for the same reason — "an allowlist entry that silently means something other than what it looks like is how allowlists leak."Deliberately out of scope
parseLabelsFlagincmd/sam-node/main.goshares the skip-empty shape but is left alone: it parses a node's own declared labels, where parsing to nothing grants less rather than more, and it is re-validated byapi.ValidateLabelsat startup. Noting it here so the asymmetry is a recorded decision rather than an oversight.No new module, and no new dependency: stdlib
stringsandfmtonly, both already imported. Net diff is +98/-1 across one source file and its test file.Tests
Four tests are added next to the change in
internal/node/openai_facade_test.go. All four were run against the unmodified parser first to confirm they actually pin the bug — the two marked below fail without the fix:TestParseRequiredLabels_ContentfulSpecNamingNoLabelFailsClosed— pins all six separator-only shapes as errors returning a nil map. ✗ without the fix: all six returnednilerror.TestFacade_Completions_ContentfulRequiredLabelsNamingNoLabelIsRejected— the boundary behaviour the fix exists for: asserts400and thatf.forwardis never reached. ✗ without the fix:200, request forwarded to the provider.TestParseRequiredLabels_BlankSpecMeansNoRequirement— keeps""," ","\t"," \t "unconstrained.TestParseRequiredLabels_EmptyEntriesBesideRealOnesStayTolerated— guards against over-correcting the trailing-comma case.The pre-existing
TestParseRequiredLabelsis untouched and still passes, including its" region=eu , team=platform ,,"case.One note on local verification
Six tests in
internal/nodefail on my Windows workstation both with and without this change —TestListenLocalSocket,TestSidecarSocketAuthorizesWithoutToken,TestStaticServiceRegistration*,TestBaseService_InitCommandBackend_BuildsBridge,TestIdentityEvidenceTrailingSlashReturnsNotFound. They are Unix-socket and subprocess-backend tests; I verified the identical set fails on a clean checkout of this branch's merge base, so they are platform artifacts of my environment and not related to this change.make lintandmake e2e-testneed a Linux toolchain I do not have locally, so CI ongoogle/samis the authority for the full gated matrix.