fix: validate agent-supplied AllowedIPs in coordinator (#26144)

`AgentCoordinateeAuth.Authorize` validated every prefix in
`upd.Node.Addresses` (each must be a `/128` derived from the
authenticating agent's own UUID) but applied no equivalent check to
`upd.Node.AllowedIps`. Because `AllowedIPs` are installed verbatim into
the WireGuard peer config (`tailnet/configmaps.go`) and WireGuard
routing is driven by `AllowedIPs`, a malicious agent could advertise a
victim agent's `/128` and become an eligible route for that IP. With
`ServerTailnet` tunneling to many agents and routing by destination IP,
this could let an attacker intercept sessions intended for the victim
workspace.

This applies the same UUID-derivation validation to `AllowedIps` that
already guards `Addresses`, extracted into a shared
`authorizeNodePrefixes` helper. The check is the single chokepoint used
by both the in-memory coordinator (`tailnet/coordinator.go`) and the
Postgres coordinator (`enterprise/tailnet/connio.go`), so one fix covers
both. Legitimate agents are unaffected: an agent's `AllowedIPs` is a
clone of its `Addresses` (`tailnet/node.go`), which are already
UUID-derived `/128`s.

Fixes PLAT-264 (SEC-89): https://linear.app/codercom/issue/PLAT-264

<details>
<summary>Implementation notes and decision log</summary>

### Root cause

Asymmetric validation in `tailnet/tunnel.go`: `Addresses` were bound to
the agent's UUID, but `AllowedIps` were trusted as-is and propagated
into the WireGuard peer config, which drives routing.

### Why the fix is safe for legitimate agents

- `tailnet/node.go` builds the node with `AllowedIPs:
slices.Clone(u.addresses)`, identical to `Addresses`.
- `agent/agent.go` sets those addresses to
`TailscaleServicePrefix.PrefixFromUUID(agentID)` and
`CoderServicePrefix.PrefixFromUUID(agentID)` (both `/128`,
UUID-derived).
- The existing `Addresses` check already accepts exactly those prefixes
plus the legacy workspace agent IP, so identical validation of
`AllowedIPs` passes for real traffic and only rejects forged prefixes.

### Coverage: one method, both coordinators

`AgentCoordinateeAuth.Authorize` is the shared auth path. A failed
`Authorize` is wrapped as `AuthorizationError{Wrapped: err}` and closes
the agent's response stream.

### Tests

- `tailnet/tunnel_internal_test.go`: fast unit tests on `Authorize`
(valid AllowedIPs accepted; foreign `/128` rejected with
`InvalidNodeAddressError`; wrong-bits rejected with
`InvalidAddressBitsError`).
- `tailnet/coordinator_test.go`: in-memory coordinator closes the agent
stream on a forged `AllowedIp`.
- `enterprise/tailnet/pgcoord_test.go`: same regression for the Postgres
coordinator.

Verified the regression tests fail when the new `AllowedIps` check is
disabled, then pass with it enabled. Local validation: targeted tests
(in-memory, internal, and Postgres-backed enterprise), plus `make
pre-commit` (gen/fmt/lint/build) passing.

</details>

> Generated by Coder Agents on behalf of @f0ssel.
This commit is contained in:
Garrett Delfosse
2026-06-11 15:26:58 -04:00
committed by GitHub
parent f3c7c23623
commit 9b351c3602
3 changed files with 90 additions and 17 deletions
+32 -2
View File
@@ -120,7 +120,7 @@ func TestPGCoordinatorSingle_AgentInvalidIP(t *testing.T) {
// The agent connection should be closed immediately after sending an invalid addr
agent.AssertEventuallyResponsesClosed(
agpl.AuthorizationError{Wrapped: agpl.InvalidNodeAddressError{Addr: prefix.Addr().String()}}.Error())
agpl.AuthorizationError{Wrapped: xerrors.Errorf("Addresses: %w", agpl.InvalidNodeAddressError{Addr: prefix.Addr().String()})}.Error())
assertEventuallyLost(ctx, t, store, agent.ID)
}
@@ -146,7 +146,37 @@ func TestPGCoordinatorSingle_AgentInvalidIPBits(t *testing.T) {
// The agent connection should be closed immediately after sending an invalid addr
agent.AssertEventuallyResponsesClosed(
agpl.AuthorizationError{Wrapped: agpl.InvalidAddressBitsError{Bits: 64}}.Error())
agpl.AuthorizationError{Wrapped: xerrors.Errorf("Addresses: %w", agpl.InvalidAddressBitsError{Bits: 64})}.Error())
assertEventuallyLost(ctx, t, store, agent.ID)
}
func TestPGCoordinatorSingle_AgentInvalidAllowedIP(t *testing.T) {
t.Parallel()
store, ps := dbtestutil.NewDB(t)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
defer cancel()
logger := testutil.Logger(t)
coordinator, err := tailnet.NewPGCoord(ctx, logger, ps, store)
require.NoError(t, err)
defer coordinator.Close()
agent := agpltest.NewAgent(ctx, t, coordinator, "agent")
defer agent.Close(ctx)
// A valid self-address paired with an AllowedIP belonging to a different
// (victim) agent must be rejected.
victim := agpl.TailscaleServicePrefix.PrefixFromUUID(uuid.New())
agent.UpdateNode(&proto.Node{
Addresses: []string{
agpl.TailscaleServicePrefix.PrefixFromUUID(agent.ID).String(),
},
AllowedIps: []string{victim.String()},
PreferredDerp: 10,
})
// The agent connection should be closed after sending an invalid AllowedIP.
agent.AssertEventuallyResponsesClosed(
agpl.AuthorizationError{Wrapped: xerrors.Errorf("AllowedIps: %w", agpl.InvalidNodeAddressError{Addr: victim.Addr().String()})}.Error())
assertEventuallyLost(ctx, t, store, agent.ID)
}