Skip to content

fix(proxy): refuse what the proxy cannot read, and lock the approval UI - #109

Open
CharmingGroot wants to merge 3 commits into
mainfrom
fix/proxy-hardening
Open

CharmingGroot wants to merge 3 commits into
mainfrom
fix/proxy-hardening

Conversation

@CharmingGroot

Copy link
Copy Markdown
Owner

The proxy treated anything it could not read as "nothing to guard". A Responses API body, a compressed body, an unknown endpoint and a stream event it did not parse were all forwarded unchecked — and a default: deny policy let a shell call through. Separately, the approval UI took a form post from anything that could reach its port, including the agent it guards.

Each fix below was reproduced as a failing test against the old code first, run with the shipped deploy/rules.yaml, then mutation-checked: reverting the guard makes its test fail again.

The approval UI answers only to whoever started it

/decision and /egress/allow took unauthenticated form posts, and approval ids are sequential. The kernel layer always passes loopback, so an allowed shell tool could approve the agent's own pending Ask, or add an address to the kernel allowlist. Any web page the operator had open could do the same cross-site.

Every UI request now needs a token generated from the OS random source at startup. The printed URL carries it once; after that it lives in a SameSite=Strict, HttpOnly cookie, and a state change whose Origin names another host is refused.

Tool calls in every shape they arrive in

  • OpenAI Responses API output[] items, streamed and not. Any *_call item the provider does not run itself counts as a tool call, including types newer than this code.
  • The deprecated message.function_call, and Gemini streamGenerateContent answered as a JSON array.
  • SSE is framed per the spec (CR/LF/CRLF, multi-line data: joined). An event that is not JSON, or argument fragments that belong to no named call, make the stream unreadable instead of silently shorter.
  • OpenAI argument strings are re-serialized before the guard sees them, so escapes such as / cannot hide a path from a rule.

Forward only what it can guard

  • WireFormat::route names each endpoint:
    • A completion's response must parse, or it is refused.
    • Token counting is screened.
    • Model listing passes.
    • Anything else is refused before it leaves, unless named with --passthrough-path.
    • The default route is the strict reading, so out-of-tree formats are covered.
  • With an inspector on, a prompt that cannot be read is refused. Responses API prompts are now read.
  • The upstream is asked for Accept-Encoding: identity, hop-by-hop headers are dropped, and a completion compressed anyway is refused.
  • --max-request-bytes (32 MiB) and --max-response-bytes (64 MiB). The read timeout is idle-only, so an endless stream used to be buffered forever.
  • Provider errors (4xx/5xx) are relayed with their status and headers.

Behaviour changes

  • Endpoints the provider format does not name (embeddings, files, batches) are now refused. Name them with --passthrough-path to forward them uninspected.
  • pasu_ui::router, serve and serve_all take an Access.

Verification

  • cargo test, clippy -D warnings and fmt --check pass (portable crates).
  • The binary was replayed against a mock provider. Results:
    • A Responses API shell call: 403.
    • /v1/embeddings: 403.
    • The upstream saw identity although the client asked for gzip.
    • 51 guessed unauthenticated approvals: 51× 401.
    • Cross-site post: 401.
    • Operator deny: 303, and the agent got a 403.

🤖 Generated with Claude Code

The approval UI took a form post from anything that could reach its port.
The guarded agent can: the kernel layer always passes loopback, so one curl
from an allowed shell tool approved the agent's own pending Ask, and on the
dashboard added an address to the kernel allowlist. A web page the operator
had open could do the same cross-site.

A token is generated from the OS random source at startup and printed in the
login URL. Opening it moves the token into a SameSite=Strict, HttpOnly
cookie; every route requires it, and a state change whose Origin names
another host is refused.

Signed-off-by: Ho Geun Choi <ohyes9711@gmail.com>
The OpenAI parser read Chat Completions only, and every field of that shape
defaulted, so a Responses API body parsed as a completion with no tool
calls: function_call, local_shell_call and custom_tool_call items reached
the agent under a default-deny policy. The deprecated message.function_call
field and Gemini's streamGenerateContent answered as a JSON array were
missed the same way.

Responses output items are read, streamed and not. Any *_call item the
provider does not execute itself is treated as a tool call, so item types
newer than this parser are guarded rather than skipped.

The SSE reader now frames events as the spec does (CR/LF/CRLF, multi-line
data joined) and fails closed: an event that is not JSON, or argument
fragments that belong to no named call, make the stream unreadable instead
of silently shorter. OpenAI argument strings are re-serialized before the
guard sees them, so model-written escapes such as \u002f cannot hide a
path from a rule.

Signed-off-by: Ho Geun Choi <ohyes9711@gmail.com>
… read

Every path went upstream, and a completion response that did not parse was
relayed unchecked, so a body in a shape the parser did not know carried its
tool calls straight to the agent, and /v1/files or /v1/embeddings carried a
prompt past every inspector.

WireFormat::route names each endpoint. A completion's response must parse
as the format or it is refused; token counting is screened but not parsed;
model listing passes; anything else is refused before it leaves unless the
operator names it with --passthrough-path, announced at every start. The
default route treats every path as a completion, so an out-of-tree format
gets the strict reading. With an inspector on, a prompt that cannot be read
is refused, and Responses API prompts are read. Provider errors are relayed
with status and headers.

The upstream is asked for Accept-Encoding: identity, hop-by-hop headers are
dropped, and a completion compressed anyway is refused: what was checked
must be what the agent decodes. Request and response bodies are capped
(--max-request-bytes 32 MiB, --max-response-bytes 64 MiB); the read timeout
is idle-only, so without the cap an endless stream was buffered forever.

Signed-off-by: Ho Geun Choi <ohyes9711@gmail.com>
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