Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/mitm/tproxy/native/transparent.c
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,35 @@ static napi_value CreateTransparentListener(napi_env env, napi_callback_info inf
return result;
}

/*
* setSocketMark(fd, mark): set SO_MARK on an existing socket fd. Anti-loop for
* the OUTPUT-based TPROXY recipe — the proxy marks its OWN upstream connections
* so the mangle OUTPUT rule (`-m mark ! --mark <bypass>`) excludes them and they
* are not re-intercepted. Requires CAP_NET_ADMIN. Returns undefined; throws on
* failure.
*/
static napi_value SetSocketMark(napi_env env, napi_callback_info info) {
size_t argc = 2;
napi_value argv[2];
napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
int32_t fd = -1, mark = 0;
napi_get_value_int32(env, argv[0], &fd);
napi_get_value_int32(env, argv[1], &mark);
if (setsockopt(fd, SOL_SOCKET, SO_MARK, &mark, sizeof(mark)) < 0) {
THROW(env, "ESO_MARK", strerror(errno));
}
return NULL;
}
Comment on lines +73 to +84

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.

high

In Node-API, if the JavaScript caller passes fewer than 2 arguments, napi_get_cb_info will update argc to the actual number of arguments passed, leaving the remaining elements of argv uninitialized. Accessing argv[0] or argv[1] when argc < 2 passes uninitialized garbage to napi_get_value_int32, which can cause a segmentation fault and crash the Node.js process.

To prevent this, we should validate that argc is at least 2, and also check the return status of napi_get_value_int32 to ensure the arguments are valid integers.

static napi_value SetSocketMark(napi_env env, napi_callback_info info) {
  size_t argc = 2;
  napi_value argv[2];
  napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
  if (argc < 2) {
    THROW(env, "EINVAL", "Expected 2 arguments");
  }
  int32_t fd = -1, mark = 0;
  if (napi_get_value_int32(env, argv[0], &fd) != napi_ok ||
      napi_get_value_int32(env, argv[1], &mark) != napi_ok) {
    THROW(env, "EINVAL", "Invalid argument types, expected integers");
  }
  if (setsockopt(fd, SOL_SOCKET, SO_MARK, &mark, sizeof(mark)) < 0) {
    THROW(env, "ESO_MARK", strerror(errno));
  }
  return NULL;
}


static napi_value Init(napi_env env, napi_value exports) {
napi_value fn;
napi_create_function(env, "createTransparentListener", NAPI_AUTO_LENGTH,
CreateTransparentListener, NULL, &fn);
napi_set_named_property(env, exports, "createTransparentListener", fn);

napi_value markFn;
napi_create_function(env, "setSocketMark", NAPI_AUTO_LENGTH, SetSocketMark, NULL, &markFn);
napi_set_named_property(env, exports, "setSocketMark", markFn);
return exports;
}

Expand Down
20 changes: 19 additions & 1 deletion src/mitm/tproxy/transparentSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { platform } from "node:os";
export interface TransparentAddon {
/** socket()+SO_REUSEADDR+IP_TRANSPARENT+bind()+listen(); returns the raw fd. */
createTransparentListener(ip: string, port: number): number;
/** setsockopt(SO_MARK) on an fd — anti-loop: mark the proxy's own upstream conns. */
setSocketMark(fd: number, mark: number): void;
}

/** Candidate locations for the built/prebuilt addon, relative to this module. */
Expand All @@ -42,7 +44,11 @@ export function loadTransparentAddon(
for (const path of ADDON_PATHS) {
try {
const mod = req(path) as Partial<TransparentAddon> | undefined;
if (mod && typeof mod.createTransparentListener === "function") {
if (
mod &&
typeof mod.createTransparentListener === "function" &&
typeof mod.setSocketMark === "function"
) {
return mod as TransparentAddon;
}
} catch {
Expand Down Expand Up @@ -75,3 +81,15 @@ export function createTransparentListenerFd(ip: string, port: number): number {
}
return cached.createTransparentListener(ip, port);
}

/**
* Set SO_MARK on a socket fd (anti-loop). The TPROXY listener marks its OWN
* upstream connections with the bypass mark so the mangle OUTPUT rule excludes
* them and they are not re-intercepted. Throws when the addon is unavailable.
*/
export function setSocketMark(fd: number, mark: number): void {
if (!cached) {
throw new Error("TPROXY transparent-socket addon is not available (setSocketMark).");
}
cached.setSocketMark(fd, mark);
}
15 changes: 12 additions & 3 deletions tests/unit/tproxy-transparent-socket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import test from "node:test";
import assert from "node:assert/strict";

const { loadTransparentAddon, isTransparentSocketAvailable, createTransparentListenerFd } =
const { loadTransparentAddon, isTransparentSocketAvailable, createTransparentListenerFd, setSocketMark } =
await import("../../src/mitm/tproxy/transparentSocket.ts");

test("loadTransparentAddon returns null on non-Linux (IP_TRANSPARENT is Linux-only)", () => {
Expand All @@ -32,14 +32,19 @@ test("loadTransparentAddon returns null when the prebuilt addon is absent (requi
});

test("loadTransparentAddon returns the addon when present and well-shaped", () => {
const fake = { createTransparentListener: () => 42 };
const fake = { createTransparentListener: () => 42, setSocketMark: () => {} };
const addon = loadTransparentAddon(() => fake, () => "linux");
assert.equal(addon, fake);
assert.equal(addon?.createTransparentListener("0.0.0.0", 1), 42);
});

test("loadTransparentAddon rejects a module missing createTransparentListener", () => {
const addon = loadTransparentAddon(() => ({ somethingElse: true }), () => "linux");
const addon = loadTransparentAddon(() => ({ setSocketMark: () => {} }), () => "linux");
assert.equal(addon, null);
});

test("loadTransparentAddon rejects a module missing setSocketMark (anti-loop primitive)", () => {
const addon = loadTransparentAddon(() => ({ createTransparentListener: () => 1 }), () => "linux");
assert.equal(addon, null);
});

Expand All @@ -51,3 +56,7 @@ test("isTransparentSocketAvailable returns a boolean (false in CI — addon not
test("createTransparentListenerFd throws a clear, actionable error when unavailable", () => {
assert.throws(() => createTransparentListenerFd("0.0.0.0", 8443), /not available|Linux|build/i);
});

test("setSocketMark throws when the addon is unavailable", () => {
assert.throws(() => setSocketMark(7, 0x539), /not available/i);
});
Loading