Skip to content

fix(mitm): TPROXY OUTPUT-based recipe for local traffic (validated e2e on VPS) - #4156

Merged
diegosouzapw merged 1 commit into
release/v3.8.29from
feat/mitm-tproxy-output-recipe
Jun 18, 2026
Merged

diegosouzapw merged 1 commit into
release/v3.8.29from
feat/mitm-tproxy-output-recipe

Conversation

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Summary

The TPROXY command builder (#4139) emitted a PREROUTING-only recipe. A VPS spike proved that recipe never intercepts local outbound traffic — and local outbound is the real MITM use case (IDE agents run on the same host). PREROUTING only sees forwarded traffic; local traffic must be captured via the OUTPUT chain.

Rework to the OUTPUT-based recipe, validated end-to-end on the VPS:

iptables -t mangle -A OUTPUT -p tcp --dport N [-m mark ! --mark BYPASS] -j MARK --set-mark M
ip rule add fwmark M lookup T
ip route add local 0.0.0.0/0 dev lo table T
iptables -t mangle -A PREROUTING -p tcp --dport N -m mark --mark M -j TPROXY --on-port L --tproxy-mark M

Flow: mark new local outbound conns in mangle OUTPUT → the ip rule reroutes marked packets to local delivery (lo) → on re-entry the PREROUTING TPROXY target assigns them to the IP_TRANSPARENT listener (#4148). revert stays the exact inverse in reverse order (crash-safe — the Fase 1 / repairMitm() invariant).

bypassMark (optional, anti-loop): the SO_MARK the proxy sets on its own upstream connections; the OUTPUT rule excludes it so the proxy's forwarded traffic isn't re-intercepted. (The listener wiring that sets SO_MARK is a follow-up.)

Validated e2e on the VPS (Hard Rule #18)

Kernel 6.8.0-124, isolated to a test port (9999) + dedicated fwmark (0x2333) so production 443/80 traffic was never touched:

OUTPUT mark: 4 pkts · PREROUTING TPROXY: 4 pkts (mark match)
[client] CONNECTED
[listener] INTERCEPTED orig-dest=198.51.100.7:9999   ← original destination preserved

(The earlier PREROUTING-only recipe, by contrast, never delivered the SYN to the socket — confirmed even with a pure-C IP_TRANSPARENT listener, proving the socket was fine and the recipe was the gap.)

Follow-ups (camada 4)

  • The listener: adopt the addon fd → terminate TLS → feed the capture buffer (reuse the MITM path), setting SO_MARK on upstream conns so bypassMark excludes them.
  • repairMitm() calling revertTproxy() once a TPROXY config is persisted.
  • Capture-mode route + Traffic Inspector UI tab + addon prebuilds.

Test Plan

  • node --test tproxy-commands.test.ts tproxy-setup.test.ts — 11/11 (recipe, anti-loop exclusion, exact-inverse revert, execFile-safe, validation)
  • typecheck:core — 0 errors · eslint — 0
  • VPS live intercept (evidence above)

🤖 Generated with Claude Code

…e on VPS)

The MITM use case is LOCAL outbound traffic (IDE agents on the same host), which
TPROXY in PREROUTING does NOT intercept — PREROUTING only sees forwarded traffic.
The previous builder (#4139) emitted a PREROUTING-only recipe; a VPS spike proved
it never delivered local connections to the socket.

Rework the builder to the OUTPUT-based recipe: mark new local outbound conns in
mangle OUTPUT → an ip rule reroutes marked packets to local delivery (lo) → the
mangle PREROUTING TPROXY target assigns them to the IP_TRANSPARENT listener. Add
an optional bypassMark (the SO_MARK the proxy sets on its own upstream conns) so
the OUTPUT rule can exclude the proxy's traffic (anti-loop). revert stays the
exact inverse in reverse order.

Validated e2e on the VPS (kernel 6.8.0), isolated to a test port + dedicated
fwmark so production 443/80 was untouched: client CONNECTED and the listener saw
the original destination preserved (198.51.100.7:9999). 11/11 unit tests
(commands + setup wiring) green.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request updates the TPROXY transparent capture mode to use an OUTPUT-based recipe, enabling the interception of local outbound traffic. It introduces a bypassMark configuration option to prevent infinite routing loops by excluding the proxy's own upstream connections. The command builders and unit tests have been updated to support this new 4-command flow. The review feedback suggests adding validation to ensure that firewall marks do not exceed the 32-bit unsigned integer limit (0xffffffff) to prevent runtime command failures, along with corresponding unit tests to verify this boundary check.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +69 to +74
if (cfg.bypassMark !== undefined) {
if (!Number.isInteger(cfg.bypassMark) || cfg.bypassMark < 1) {
return `bypassMark must be a positive integer when set, got ${cfg.bypassMark}`;
}
if (cfg.bypassMark === cfg.mark) return "bypassMark must differ from mark (anti-loop)";
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

In Linux, firewall marks (fwmark) are represented as 32-bit unsigned integers (up to 4294967295 or 0xffffffff). If a value larger than this is passed, the iptables or ip commands will fail at runtime with an error. It is safer to validate that bypassMark (and ideally mark and routeTable as well) does not exceed 0xffffffff.

Suggested change
if (cfg.bypassMark !== undefined) {
if (!Number.isInteger(cfg.bypassMark) || cfg.bypassMark < 1) {
return `bypassMark must be a positive integer when set, got ${cfg.bypassMark}`;
}
if (cfg.bypassMark === cfg.mark) return "bypassMark must differ from mark (anti-loop)";
}
if (cfg.bypassMark !== undefined) {
if (!Number.isInteger(cfg.bypassMark) || cfg.bypassMark < 1 || cfg.bypassMark > 0xffffffff) {
return 'bypassMark must be a positive 32-bit unsigned integer when set, got ' + cfg.bypassMark;
}
if (cfg.bypassMark === cfg.mark) return "bypassMark must differ from mark (anti-loop)";
}

assert.match(validateTproxyConfig({ ...CFG, onPort: 70000 }) ?? "", /onPort/i);
assert.match(validateTproxyConfig({ ...CFG, mark: 0 }) ?? "", /mark/i);
assert.match(validateTproxyConfig({ ...CFG, routeTable: -1 }) ?? "", /table/i);
assert.match(validateTproxyConfig({ ...CFG, bypassMark: CFG.mark }) ?? "", /bypassMark/i);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Add a unit test to verify that validateTproxyConfig correctly rejects bypassMark values that exceed the 32-bit unsigned integer limit (0xffffffff).

  assert.match(validateTproxyConfig({ ...CFG, bypassMark: CFG.mark }) ?? "", /bypassMark/i);
  assert.match(validateTproxyConfig({ ...CFG, bypassMark: 0xffffffff + 1 }) ?? "", /bypassMark/i);

diegosouzapw added a commit that referenced this pull request Jun 18, 2026
The OUTPUT-based TPROXY recipe (#4156) marks all new local outbound conns to the
target port — including the proxy's OWN upstream connections when it forwards,
which would re-enter TPROXY (infinite loop). setSocketMark(fd, mark) lets the
listener stamp SO_MARK on its upstream sockets so the mangle OUTPUT rule
(-m mark ! --mark <bypass>) excludes them.

Viability validated on the VPS (root): setSocketMark works on a fd, and a Node
outbound socket's fd is reachable via socket._handle.fd, so the listener can mark
its forward connections. The loader now requires both addon functions; tests +
the addon build updated. 8/8 wrapper tests green.
@diegosouzapw
diegosouzapw merged commit 46c7610 into release/v3.8.29 Jun 18, 2026
4 checks passed
@diegosouzapw
diegosouzapw deleted the feat/mitm-tproxy-output-recipe branch June 19, 2026 12:07
tkgo11 pushed a commit to tkgo11/OmniRoute that referenced this pull request Sep 23, 2026
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