Skip to content

feat: support disabling reverse/local port forwarding in agent SSH server#24026

Merged
f0ssel merged 4 commits intomainfrom
f0ssel/plat-32-block-port-forwarding
Apr 8, 2026
Merged

feat: support disabling reverse/local port forwarding in agent SSH server#24026
f0ssel merged 4 commits intomainfrom
f0ssel/plat-32-block-port-forwarding

Conversation

@f0ssel
Copy link
Copy Markdown
Member

@f0ssel f0ssel commented Apr 3, 2026

The agent SSH server unconditionally allows all four SSH forwarding paths (TCP local, TCP reverse, Unix local, Unix reverse). This is a sandbox escape vector when workspaces are used for AI agent containment — a reverse tunnel lets anything inside the workspace reach the user's local machine, bypassing network isolation.

This adds two new agent CLI flags / environment variables:

  • --block-reverse-port-forwarding / CODER_AGENT_BLOCK_REVERSE_PORT_FORWARDING — blocks both TCP (ssh -R) and Unix socket reverse forwarding
  • --block-local-port-forwarding / CODER_AGENT_BLOCK_LOCAL_PORT_FORWARDING — blocks both TCP (ssh -L) and Unix socket local forwarding

Template admins can set these via the env block on the container/VM resource that runs the agent (e.g. docker_container, kubernetes_pod), or via coder_env resources tied to the agent.

Fixes #22275

Implementation notes

Follows the existing BlockFileTransfer pattern:

  1. agent/agentssh/agentssh.go — New BlockReversePortForwarding and BlockLocalPortForwarding fields on Config. TCP callbacks check these before allowing forwarding. The direct-streamlocal@openssh.com channel handler is wrapped to reject Unix local forwards.
  2. agent/agentssh/forward.goforwardedUnixHandler gains a blockReversePortForwarding field to reject streamlocal-forward@openssh.com requests.
  3. agent/agent.go — New fields on Options and agent struct, plumbed to SSH config.
  4. cli/agent.go — New serpent flags with env vars.
  5. Tests cover all four blocked paths: TCP local, TCP reverse, Unix local, Unix reverse.

🤖 Generated by Coder Agents

…erver

Add --block-reverse-port-forwarding and --block-local-port-forwarding
flags to the agent CLI, allowing template admins to disable ssh -R
and ssh -L through environment variables set on the coder_agent
Terraform resource.

This addresses a sandbox escape vector where reverse tunnels can be
used to bypass workspace network isolation in AI agent containment
scenarios.
@f0ssel f0ssel changed the title feat(agent): support disabling reverse/local port forwarding in SSH server feat: support disabling reverse/local port forwarding in agent SSH server Apr 3, 2026
@f0ssel f0ssel marked this pull request as ready for review April 3, 2026 19:25
@coder-tasks
Copy link
Copy Markdown
Contributor

coder-tasks Bot commented Apr 3, 2026

Documentation Check

Updates Needed

  • docs/tutorials/faqs.md - Add a new FAQ entry "How can I restrict SSH port forwarding from Coder workspaces?" mirroring the existing CODER_AGENT_BLOCK_FILE_TRANSFER section. Show how to set CODER_AGENT_BLOCK_REVERSE_PORT_FORWARDING and CODER_AGENT_BLOCK_LOCAL_PORT_FORWARDING via the env attribute on the coder_agent Terraform resource.
  • docs/admin/networking/port-forwarding.md - Add a section or note explaining that template admins can block SSH reverse/local port forwarding (ssh -R / ssh -L) using the new env vars, complementing the existing port-sharing controls.

New Documentation Needed

  • docs/ai-coder/agent-boundaries/index.md - The PR description explicitly frames these flags as a sandbox escape mitigation for AI agent containment. The Agent Boundaries docs should mention CODER_AGENT_BLOCK_REVERSE_PORT_FORWARDING and CODER_AGENT_BLOCK_LOCAL_PORT_FORWARDING as additional SSH-level isolation controls alongside the existing network-policy enforcement.

⚠️ Recent commits added test coverage for all four forwarding paths and verified flag independence, but no documentation changes have been made in this PR.


Automated review via Coder Tasks

@matifali
Copy link
Copy Markdown
Member

matifali commented Apr 3, 2026

Template admins can set these via the env attribute on the coder_agent Terraform resource to control port forwarding on a per-template basis, without requiring any Terraform provider or proto changes.

I think this need to be set on the resource the coder_agent will run in e.g. docker_container.

@f0ssel f0ssel requested a review from mafredri April 6, 2026 14:29
Copy link
Copy Markdown
Member

@mafredri mafredri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean design, correct plumbing from CLI flags through agent Options to SSH Config. The BlockFileTransfer pattern is the right model to follow, and the TCP forwarding callbacks work as intended.

The core problem: the SSH server has four forwarding paths (TCP local, TCP reverse, Unix local, Unix reverse), but the new flags only gate the two TCP paths. The Unix domain socket paths in forward.go have no config access and accept all requests unconditionally. For a feature motivated by AI agent sandbox escape prevention, this is a complete bypass.

Severity breakdown: 2x P1, 1x P2, 2x P3.

As Kurapika put it: "The PR description says 'a reverse tunnel lets anything inside the workspace reach the user's local machine.' Unix socket reverse forwarding does exactly the same thing."

PS. The test names (TestAgent_TCPLocalForwardingBlocked) are more honest than the flag names about what's actually blocked. Worth preserving that honesty when adding Unix socket blocking tests.

PS. Agent-side env var enforcement means a workspace root user can restart the agent without these flags. This is the same threat model as BlockFileTransfer and is a known limitation, not a regression from this PR.

🤖 This review was automatically generated with Coder Agents.

Comment thread agent/agentssh/agentssh.go
return true
},
ReversePortForwardingCallback: func(ctx ssh.Context, bindHost string, bindPort uint32) bool {
if s.config.BlockReversePortForwarding {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unix reverse socket forwarding (streamlocal-forward@openssh.com) bypasses BlockReversePortForwarding. (Kurapika P1, Meruem P0, Bisky P1, Mafuuu P1, Kite P1, Knov P1)

forwardedUnixHandler.HandleSSHRequest (forward.go:54) handles streamlocal-forward@openssh.com and listens on a Unix socket unconditionally. It has no reference to the server config. When BlockReversePortForwarding is true, sshClient.ListenTCP(addr) is rejected by this callback, but sshClient.ListenUnix(path) hits forwardedUnixHandler unchecked.

This is the exact escape vector the PR description warns about: a reverse tunnel lets anything inside the workspace reach the user's local machine. Unix sockets are just a different transport.

Fix: either pass the config to newForwardedUnixHandler or wrap the request handler registration in a closure that checks s.config.BlockReversePortForwarding and returns false, nil when blocked.

🤖

Comment thread agent/agent_test.go
Comment thread agent/agent_test.go
require.NoError(t, err)
defer sshClient.Close()

_, err = sshClient.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", remotePort))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Blocked-forwarding tests assert only require.Error without checking the rejection reason. (Bisky P3)

Both blocked tests do require.Error(t, err) and nothing else. The dial could fail for any reason (timeout, port collision, network flake) and the test would still pass. For a security-critical behavior, asserting the rejection came from the blocking callback is worth the effort.

Consider require.ErrorContains(t, err, "administratively prohibited") or similar to confirm the SSH Prohibited rejection.

🤖

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit. All four blocked-forwarding tests now assert the specific SSH rejection string instead of bare require.Error.

🤖 Generated by Coder Agents

Comment thread cli/agent.go
Description: fmt.Sprintf("Block file transfer using known applications: %s.", strings.Join(agentssh.BlockedFileTransferCommands, ",")),
Value: serpent.BoolOf(&blockFileTransfer),
},
{
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 CLI description says (ssh -L) but ssh -L also supports Unix socket targets, which are not blocked. (Gon P2, Bisky Note, Mafuuu Note, Knov Note)

Same applies to (ssh -R) on line 509. A template admin reads "Block local port forwarding through the SSH server (ssh -L)" and believes all -L usage is denied. It is not: Unix socket forwarding goes through separate handlers.

Once the Unix socket bypass is fixed (see findings on agentssh.go), this becomes accurate. If Unix sockets remain unblocked, the descriptions should say (direct-tcpip only) or similar.

🤖

f0ssel added 2 commits April 6, 2026 16:38
Extend port forwarding blocking to cover all four SSH forwarding paths:
TCP local, TCP reverse, Unix local, and Unix reverse. Without this, the
Unix domain socket paths (direct-streamlocal, streamlocal-forward)
bypass the blocking flags entirely.
Use require.ErrorContains to verify the error comes from the SSH
blocking callback rather than a network flake. Local forwarding
tests check for "administratively prohibited" (gossh.Prohibited),
reverse forwarding tests check for "request denied by peer".
@f0ssel f0ssel requested a review from mafredri April 7, 2026 20:55
Copy link
Copy Markdown
Member

@mafredri mafredri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five Round 1 findings resolved. All four SSH forwarding paths (TCP local, TCP reverse, Unix local, Unix reverse) are now blocked. Tests cover each path with specific rejection string assertions. Clean implementation.

One P3, two notes.

The P3: no test verifies the two flags are independent. Blocking local doesn't affect reverse (and vice versa). The current tests catch field-name typos (each sets only one flag), but not higher-level coupling from a future refactor that merges the flags or middleware that reads both. As Bisky put it: "The independence contract is implicit. One test per direction would make it explicit."

Note: sub-agent SSH servers in devcontainers don't inherit the blocking env vars. CODER_AGENT_BLOCK_REVERSE_PORT_FORWARDING and CODER_AGENT_BLOCK_LOCAL_PORT_FORWARDING are not propagated to runSubAgentInContainer (agentcontainers/api.go). Same gap as BlockFileTransfer, pre-existing pattern, not introduced by this PR. Worth tracking if AI agent containment in devcontainers becomes a use case.

Note: the direct-tcpip handler wraps the channel with NewJetbrainsChannelWatcher (reads /proc) before the library's DirectTCPIPHandler rejects via LocalPortForwardingCallback. The direct-streamlocal@openssh.com handler avoids this by checking the flag first. No correctness or security impact, just a wasted syscall per blocked TCP local forward attempt.

🤖 This review was automatically generated with Coder Agents.

Comment thread agent/agent_test.go
requireEcho(t, conn)
}

func TestAgent_TCPLocalForwardingBlocked(t *testing.T) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 No test verifies the two flags are independent. (Bisky P3)

Each blocked test sets only its own flag and verifies the corresponding direction is rejected. No test sets BlockLocalPortForwarding=true and confirms reverse forwarding still works, or vice versa.

For the AI containment use case, a template admin blocking reverse tunnels must not break local forwarding needed by IDE tooling. One test per direction would make this explicit:

func TestAgent_LocalBlockedDoesNotAffectReverse(t *testing.T) {
    // Set BlockLocalPortForwarding=true only.
    // Assert ListenTCP still succeeds.
}

🤖

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-raising. 6 of 7 reviewers independently flagged this again. No response, no ticket, no code change.

The strongest argument (Meruem): the flags are plumbed through three struct hops (cli -> Options -> agent struct -> Config). A transposition at any hop (e.g. BlockReversePortForwarding: a.blockLocalPortForwarding) would pass all four existing blocked tests because each only exercises its own direction. The result: blocking local silently blocks reverse instead, and the intended direction stays open.

Two tests, one per direction. Each sets one flag, attempts both directions, asserts the unblocked direction succeeds.

🤖

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in the latest commit: TestAgent_LocalBlockedDoesNotAffectReverse and TestAgent_ReverseBlockedDoesNotAffectLocal. Each sets one flag and verifies the opposite direction still works end-to-end.

🤖 Generated by Coder Agents

@mafredri mafredri dismissed their stale review April 7, 2026 21:06

Resolved in follow-up review.

Copy link
Copy Markdown
Member

@mafredri mafredri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 3 (first proper round with full reviewer prompts). Seven reviewers, all prior P1/P2 findings verified resolved. All four SSH forwarding paths are blocked. Tests cover each path with specific SSH rejection string assertions. Claims in CLI descriptions match the implementation. Clean.

One open P3 from round 2 (#comment-3047871971), re-raised by 6 of 7 reviewers: no test verifies the two flags are independent. As Meruem put it: "A transposition at any hop would pass all four existing blocked tests because each only exercises its own direction." Bisky's sketch from round 2 still applies.

Two notes worth knowing:

(1) Unix socket blocked log entries omit the socket path. TCP blocking logs include destination_host/destination_port, but the Unix local handler at agentssh.go:238 logs only "unix local port forward blocked" with no structured context about what path was denied. The reverse handler at forward.go:68 checks before payload parsing so this is unavoidable, but the local handler could parse first.

(2) Sub-agents spawned for devcontainers do not inherit CODER_AGENT_BLOCK_*_PORT_FORWARDING env vars. Same gap as BlockFileTransfer. Pre-existing pattern, not introduced by this PR. Worth tracking if AI agent containment in devcontainers is a use case.

🤖 This review was automatically generated with Coder Agents.

Add tests that set one blocking flag and confirm the opposite
direction still works. This catches field-name transpositions
at any of the three struct hops (cli -> Options -> agent -> Config)
that would silently swap which direction gets blocked.
@f0ssel f0ssel requested a review from mafredri April 8, 2026 14:21
Copy link
Copy Markdown
Member

@mafredri mafredri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All six findings from rounds 1-3 are resolved. The flag independence tests (#comment-3047871971) land in commit 4918e85 with a comment explaining the transposition risk they guard against, showing the author absorbed the reviewer reasoning rather than mechanically complying. Clean.

No new findings. Zero P0-P3 from 6 reviewers.

As Bisky put it: "Remove the blocking code and every one of them fails. These are real gems."

Two process observations from Mafu-san worth noting: (1) the initial commit described blocking "both TCP and Unix" but only implemented TCP, producing the round-1 P1 findings; grepping for all forwarding handler registrations in NewServer before writing the description would have caught this; (2) finding F6 (flag independence tests) went two rounds without author response before being addressed. Silence is not a disposition.

PS. Sub-agent devcontainer env var propagation gap remains a known limitation (same as BlockFileTransfer). Not introduced by this PR.

🤖 This review was automatically generated with Coder Agents.

Copy link
Copy Markdown
Member

@mafredri mafredri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks solid, human approved! 🏂🏻

@f0ssel f0ssel merged commit 7b7baea into main Apr 8, 2026
26 checks passed
@f0ssel f0ssel deleted the f0ssel/plat-32-block-port-forwarding branch April 8, 2026 14:41
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

support disabling reverse port forwarding (ssh -R) in the Coder agent SSH server

3 participants