Skip to content

Refuse a trailing newline with fullmatch in the control plane's validators and both UI relays - #243

Merged
larsvikb merged 2 commits into
mainfrom
egress-label-fullmatch
Nov 1, 2026
Merged

larsvikb merged 2 commits into
mainfrom
egress-label-fullmatch

Conversation

@larsvikb

@larsvikb larsvikb commented Nov 1, 2026

Copy link
Copy Markdown
Owner

What

re.match with a $-anchored pattern accepts one trailing "\n". The egress label check applies _LABEL_RE per label after splitting on ".", and _normalize_pattern strips only the ends of the whole pattern, so example\n.com, a.b\n.com and .example\n.com passed and were stored as allows or blocks that match nothing. Task 37, plan 13. Two commits, kept apart so each can be judged and reverted on its own.

Control plane (4a12f02)

  • policy._rule_error's label check uses fullmatch: the task's bug, live through create_rule and edit_rule.
  • The module's sibling validators do too: _server_name_error, _auth_descriptor_error's header check and _tool_rule_error. Their routes strip first, so those paths were latent. The one path that does not strip first is inventory.record, which checks the gateway push's server keys as sent. Its only caller pushes roster names stripped and validated at registration, so a key like mcp-github\n could only come from a gateway bug, and would be stored as an entry of its own.
  • inventory._clean_tools uses fullmatch for consistency only. It strips just before the check, so no behaviour changes and no test can show it.

The regex text is unchanged, as with _PATH_RE and _PIN_FIELD_RE, so the gateway's copies of the server and tool patterns need no change.

UI relays (150cce9)
_relay_allowed in control-plane-ui/app.py and tasks-ui/app.py matched its $-anchored routes with re.match. Both now use fullmatch; the route table's text is unchanged.

This changes no behaviour on the live stack today. Starlette's {path:path} catch-all already drops one trailing newline before proxy sees it, so a %0A path was, and still is, relayed as the clean path. The change makes the allowlist mean exactly what it spells, independent of the router: defence in depth.

Checked

In the container: make lint, make consistency and make test pass.

New tests:

  • _rule_error refuses a newline ending a non-final label for allow and block; create_rule answers 400 and writes no row; edit_rule answers 400 and changes nothing.
  • Each sibling validator refuses a valid value with "\n" appended; an inventory push keyed ok-one\n is skipped.
  • An allowed path with a trailing "\n" is refused 403 and nothing is sent, in RelayAllowlistTests (api/egress/rules) and in tasks-ui's RelayTests (api/repos). These call proxy as a function, not through a request, so they show the allowlist, not the router.

Mutation: each of the six tested sites reverted to .match on its own, all killed:

  • the label check fails the PatternValidationTests (6 subtests), CreateRuleTests and EditRuleTests cases;
  • the server name check fails its validator test and the inventory test;
  • the header check, the tool check and each relay fail their own test.

The pre-push review reran all of these and checked the router on real FastAPI in a venv (fastapi 0.141.1, starlette 1.10.0, TestClient): at main and at this branch, /api/egress/rules%0A and tasks-ui's /api/repos%0A are relayed as the clean path, and a newline anywhere else is the router's 404. The suite does not check it.

Not run: anything needing Docker. Nothing here touches compose, images or the network.

Generated with Claude Code

larsvikb and others added 2 commits November 1, 2026 13:18
…match

## What

`re.match` with a `$`-anchored pattern accepts one trailing "\n". The
egress label check applies `_LABEL_RE` per label after splitting on ".",
and `_normalize_pattern` strips only the ends of the whole pattern, so
`example\n.com`, `a.b\n.com` and `.example\n.com` passed and were stored
as allows or blocks that match nothing. Task 37, plan 13.

- **`policy._rule_error`'s label check** uses `fullmatch`: the task's
bug, live through `create_rule` and `edit_rule`.
- **The module's sibling validators** do too: `_server_name_error`,
`_auth_descriptor_error`'s header check and `_tool_rule_error`. Their
routes strip first, so those paths were latent. The one path that does
not strip first is `inventory.record`, which checks the gateway push's
server keys as sent. Its only caller pushes roster names stripped and
validated at registration, so a key like `mcp-github\n` could only come
from a gateway bug, and would be stored as an entry of its own.
- **`inventory._clean_tools`** uses `fullmatch` for consistency only. It
strips just before the check, so no behaviour changes and no test can
show it.

The regex text is unchanged, as with `_PATH_RE` and `_PIN_FIELD_RE`, so
the gateway's copies of the server and tool patterns need no change.

## Checked

In the container: `make lint`, `make consistency` and `make test` pass.

New tests: `_rule_error` refuses a newline ending a non-final label for
allow and block, `create_rule` answers 400 and writes no row, `edit_rule`
answers 400 and changes nothing; each sibling validator refuses a valid
value with "\n" appended; an inventory push keyed `ok-one\n` is skipped.

Mutation: each of the four `policy.py` sites reverted to `.match` on its
own, against its targeted tests, all killed:
- the label check fails the `PatternValidationTests` (6 subtests),
`CreateRuleTests` and `EditRuleTests` cases;
- the server name check fails its validator test and the inventory test;
- the header check and the tool check each fail their validator test.

Not run: anything needing Docker. Nothing here touches compose, images
or the network.

Co-Authored-By: Claude <noreply@anthropic.com>
## What

`_relay_allowed` in control-plane-ui/app.py and tasks-ui/app.py matched
its `$`-anchored routes with `re.match`, which accepts one trailing
"\n". Both now use `fullmatch`; the route table's text is unchanged.
Task 37, plan 13, second of two commits.

This changes no behaviour on the live stack today. Starlette's
`{path:path}` catch-all already drops one trailing newline before
`proxy` sees it, so a `%0A` path was, and still is, relayed as the clean
path. The change makes the allowlist mean exactly what it spells,
independent of the router: defence in depth.

## Checked

In the container: `make lint`, `make consistency` and `make test` pass.

New tests: an allowed path with a trailing "\n" is refused 403 and
nothing is sent, in `RelayAllowlistTests` (`api/egress/rules`) and in
tasks-ui's `RelayTests` (`api/repos`, a new method rather than an entry
in the path tuples).

The tests call `proxy` as a function, not through a request, so they
show the allowlist, not the router.

Mutation: each relay reverted to `.match` on its own fails its new test.

The router behaviour was checked by the pre-push review on real FastAPI
in a venv (fastapi 0.141.1, starlette 1.10.0, TestClient): at main and
at this commit, `/api/egress/rules%0A` and tasks-ui's `/api/repos%0A`
are relayed as the clean path, and a newline anywhere else is the
router's 404. The suite does not check it.

Not run: anything needing Docker.

Co-Authored-By: Claude <noreply@anthropic.com>
@larsvikb
larsvikb merged commit dcc97dd into main Nov 1, 2026
6 checks passed
@larsvikb
larsvikb deleted the egress-label-fullmatch branch November 1, 2026 12:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant