mirror of
https://github.com/coder/coder.git
synced 2026-09-23 05:43:53 +08:00
`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.
228 lines
5.9 KiB
Go
228 lines
5.9 KiB
Go
package tailnet
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/netip"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/tailnet/proto"
|
|
)
|
|
|
|
var legacyWorkspaceAgentIP = netip.MustParseAddr("fd7a:115c:a1e0:49d6:b259:b7ac:b1b2:48f4")
|
|
|
|
type InvalidAddressBitsError struct {
|
|
Bits int
|
|
}
|
|
|
|
func (e InvalidAddressBitsError) Error() string {
|
|
return fmt.Sprintf("invalid address bits, expected 128, got %d", e.Bits)
|
|
}
|
|
|
|
type InvalidNodeAddressError struct {
|
|
Addr string
|
|
}
|
|
|
|
func (e InvalidNodeAddressError) Error() string {
|
|
return fmt.Sprintf("invalid node address, got %s", e.Addr)
|
|
}
|
|
|
|
type CoordinateeAuth interface {
|
|
Authorize(ctx context.Context, req *proto.CoordinateRequest) error
|
|
}
|
|
|
|
// SingleTailnetCoordinateeAuth allows all tunnels, since Coderd and wsproxy are allowed to initiate a tunnel to any agent
|
|
type SingleTailnetCoordinateeAuth struct{}
|
|
|
|
func (SingleTailnetCoordinateeAuth) Authorize(context.Context, *proto.CoordinateRequest) error {
|
|
return nil
|
|
}
|
|
|
|
// ClientCoordinateeAuth allows connecting to a single, given agent
|
|
type ClientCoordinateeAuth struct {
|
|
AgentID uuid.UUID
|
|
}
|
|
|
|
func (c ClientCoordinateeAuth) Authorize(_ context.Context, req *proto.CoordinateRequest) error {
|
|
if tun := req.GetAddTunnel(); tun != nil {
|
|
uid, err := uuid.FromBytes(tun.Id)
|
|
if err != nil {
|
|
return xerrors.Errorf("parse add tunnel id: %w", err)
|
|
}
|
|
|
|
if c.AgentID != uid {
|
|
return xerrors.Errorf("invalid agent id, expected %s, got %s", c.AgentID.String(), uid.String())
|
|
}
|
|
}
|
|
|
|
return handleClientNodeRequests(req)
|
|
}
|
|
|
|
// AgentCoordinateeAuth disallows all tunnels, since agents are not allowed to initiate their own tunnels
|
|
type AgentCoordinateeAuth struct {
|
|
ID uuid.UUID
|
|
}
|
|
|
|
func (a AgentCoordinateeAuth) Authorize(_ context.Context, req *proto.CoordinateRequest) error {
|
|
if tun := req.GetAddTunnel(); tun != nil {
|
|
return xerrors.New("agents cannot open tunnels")
|
|
}
|
|
|
|
if upd := req.GetUpdateSelf(); upd != nil {
|
|
// Both Addresses and AllowedIPs are installed into the WireGuard peer
|
|
// config and drive routing, so an agent may only advertise prefixes
|
|
// derived from its own UUID. Without this an agent could claim a victim
|
|
// agent's IP and have traffic routed to it.
|
|
if err := a.authorizeNodePrefixes(upd.Node.Addresses); err != nil {
|
|
return xerrors.Errorf("Addresses: %w", err)
|
|
}
|
|
if err := a.authorizeNodePrefixes(upd.Node.AllowedIps); err != nil {
|
|
return xerrors.Errorf("AllowedIps: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// authorizeNodePrefixes verifies that every prefix is a /128 address derived
|
|
// from the agent's own UUID (or the legacy workspace agent IP).
|
|
func (a AgentCoordinateeAuth) authorizeNodePrefixes(prefixes []string) error {
|
|
for _, prefixStr := range prefixes {
|
|
pre, err := netip.ParsePrefix(prefixStr)
|
|
if err != nil {
|
|
return xerrors.Errorf("parse node address: %w", err)
|
|
}
|
|
|
|
if pre.Bits() != 128 {
|
|
return InvalidAddressBitsError{pre.Bits()}
|
|
}
|
|
|
|
if TailscaleServicePrefix.AddrFromUUID(a.ID).Compare(pre.Addr()) != 0 &&
|
|
CoderServicePrefix.AddrFromUUID(a.ID).Compare(pre.Addr()) != 0 &&
|
|
legacyWorkspaceAgentIP.Compare(pre.Addr()) != 0 {
|
|
return InvalidNodeAddressError{pre.Addr().String()}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type ClientUserCoordinateeAuth struct {
|
|
Auth TunnelAuthorizer
|
|
}
|
|
|
|
func (a ClientUserCoordinateeAuth) Authorize(ctx context.Context, req *proto.CoordinateRequest) error {
|
|
if tun := req.GetAddTunnel(); tun != nil {
|
|
uid, err := uuid.FromBytes(tun.Id)
|
|
if err != nil {
|
|
return xerrors.Errorf("parse add tunnel id: %w", err)
|
|
}
|
|
err = a.Auth.AuthorizeTunnel(ctx, uid)
|
|
if err != nil {
|
|
return xerrors.Errorf("workspace agent not found or you do not have permission")
|
|
}
|
|
}
|
|
|
|
return handleClientNodeRequests(req)
|
|
}
|
|
|
|
// handleClientNodeRequests validates GetUpdateSelf requests and declines ReadyForHandshake requests
|
|
func handleClientNodeRequests(req *proto.CoordinateRequest) error {
|
|
if upd := req.GetUpdateSelf(); upd != nil {
|
|
for _, addrStr := range upd.Node.Addresses {
|
|
pre, err := netip.ParsePrefix(addrStr)
|
|
if err != nil {
|
|
return xerrors.Errorf("parse node address: %w", err)
|
|
}
|
|
|
|
if pre.Bits() != 128 {
|
|
return InvalidAddressBitsError{pre.Bits()}
|
|
}
|
|
}
|
|
}
|
|
|
|
if rfh := req.GetReadyForHandshake(); rfh != nil {
|
|
return xerrors.Errorf("clients may not send ready_for_handshake")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// tunnelStore contains tunnel information and allows querying it. It is not threadsafe and all
|
|
// methods must be serialized by holding, e.g. the core mutex.
|
|
type tunnelStore struct {
|
|
bySrc map[uuid.UUID]map[uuid.UUID]struct{}
|
|
byDst map[uuid.UUID]map[uuid.UUID]struct{}
|
|
}
|
|
|
|
func newTunnelStore() *tunnelStore {
|
|
return &tunnelStore{
|
|
bySrc: make(map[uuid.UUID]map[uuid.UUID]struct{}),
|
|
byDst: make(map[uuid.UUID]map[uuid.UUID]struct{}),
|
|
}
|
|
}
|
|
|
|
func (s *tunnelStore) add(src, dst uuid.UUID) {
|
|
srcM, ok := s.bySrc[src]
|
|
if !ok {
|
|
srcM = make(map[uuid.UUID]struct{})
|
|
s.bySrc[src] = srcM
|
|
}
|
|
srcM[dst] = struct{}{}
|
|
dstM, ok := s.byDst[dst]
|
|
if !ok {
|
|
dstM = make(map[uuid.UUID]struct{})
|
|
s.byDst[dst] = dstM
|
|
}
|
|
dstM[src] = struct{}{}
|
|
}
|
|
|
|
func (s *tunnelStore) remove(src, dst uuid.UUID) {
|
|
delete(s.bySrc[src], dst)
|
|
if len(s.bySrc[src]) == 0 {
|
|
delete(s.bySrc, src)
|
|
}
|
|
delete(s.byDst[dst], src)
|
|
if len(s.byDst[dst]) == 0 {
|
|
delete(s.byDst, dst)
|
|
}
|
|
}
|
|
|
|
func (s *tunnelStore) removeAll(src uuid.UUID) {
|
|
for dst := range s.bySrc[src] {
|
|
s.remove(src, dst)
|
|
}
|
|
}
|
|
|
|
func (s *tunnelStore) findTunnelPeers(id uuid.UUID) []uuid.UUID {
|
|
set := make(map[uuid.UUID]struct{})
|
|
for dst := range s.bySrc[id] {
|
|
set[dst] = struct{}{}
|
|
}
|
|
for src := range s.byDst[id] {
|
|
set[src] = struct{}{}
|
|
}
|
|
out := make([]uuid.UUID, 0, len(set))
|
|
for id := range set {
|
|
out = append(out, id)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *tunnelStore) tunnelExists(src, dst uuid.UUID) bool {
|
|
_, srcOK := s.bySrc[src][dst]
|
|
_, dstOK := s.byDst[src][dst]
|
|
return srcOK || dstOK
|
|
}
|
|
|
|
func (s *tunnelStore) htmlDebug() []HTMLTunnel {
|
|
out := make([]HTMLTunnel, 0)
|
|
for src, dsts := range s.bySrc {
|
|
for dst := range dsts {
|
|
out = append(out, HTMLTunnel{Src: src, Dst: dst})
|
|
}
|
|
}
|
|
return out
|
|
}
|