Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e42460e
feat: add outbound proxy support to @arcjet/transport and @arcjet/guard
davidmytton Jun 17, 2026
ec17c13
fix: resolve proxy support CI failures and review feedback
davidmytton Jun 17, 2026
329d125
fix(guard): honor proxies on Deno
arcjet-rei Jun 17, 2026
21f67be
test(transport): cover the HTTPS-through-proxy CONNECT path
arcjet-rei Jun 17, 2026
8c45215
test(transport): exercise Bun and Deno proxying on the real runtimes
arcjet-rei Jun 17, 2026
3ff244c
fix(turbo): declare lowercase proxy env vars
arcjet-rei Jun 17, 2026
7f9585c
fix(transport): don't fail transport creation when the log level is u…
arcjet-rei Jun 17, 2026
ba0c91f
fix(guard): surface invalid base URLs instead of swallowing them
arcjet-rei Jun 17, 2026
df07519
docs: document that NO_PROXY does not support IP/CIDR ranges
arcjet-rei Jun 17, 2026
2400e01
fix(guard): gate the proxy startup line on ARCJET_LOG_LEVEL
arcjet-rei Jun 17, 2026
001c524
test(guard): isolate proxy environment in transport tests
arcjet-rei Jun 17, 2026
9aaf3eb
test(guard): cover the HTTP-proxy agent branch
arcjet-rei Jun 17, 2026
4051908
test(transport): guard against detect-proxy duplication drift
arcjet-rei Jun 17, 2026
125e908
fix(transport,guard): type the proxy env literal so key typos are caught
arcjet-rei Jun 17, 2026
08fef0e
test(transport): verify HTTPS proxy routing via CONNECT, not by disab…
arcjet-rei Jun 17, 2026
c16da28
ci: fix the transport runtime proxy job
arcjet-rei Jun 17, 2026
ea9501c
test(transport): use a node:net CONNECT proxy for cross-runtime support
arcjet-rei Jun 17, 2026
2ab297c
test(transport): set HTTPS_PROXY at startup; lower Bun floor to 1.3.0
arcjet-rei Jun 17, 2026
d878ad5
refactor(transport,guard): have detectProxy take a parsed URL
arcjet-rei Jun 17, 2026
b01b878
refactor: simplify proxy transport selection and NO_PROXY parsing
arcjet-rei Jun 17, 2026
2e91b25
fix(transport,guard): type the agent proxyEnv option for @types/node …
arcjet-rei Jun 18, 2026
218aa13
feat(transport): keep HTTP/2 when proxying via a CONNECT tunnel
arcjet-rei Jun 19, 2026
11987f4
refactor(guard): select the transport by export condition, not runtim…
arcjet-rei Jun 22, 2026
f7ba8df
refactor(transport): dedupe the HTTP/2 setup and tidy the proxy tunnel
arcjet-rei Jun 22, 2026
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
57 changes: 57 additions & 0 deletions .github/workflows/reusable-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,60 @@ jobs:

- name: Run tests
run: npm test

transport-runtime:
name: "@arcjet/transport proxy (${{ matrix.runtime }} ${{ matrix.bun-version || matrix.deno-version }})"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# Bun's native-fetch proxy support is exercised here (not under Node).
# `HTTPS_PROXY` is set at process startup (by the npm script), which
# Bun honors back to 1.3.0, the project's minimum supported Bun.
- runtime: bun
script: test-runtime-bun
bun-version: 1.3.0
- runtime: bun
script: test-runtime-bun
bun-version: latest
- runtime: deno
script: test-runtime-deno
deno-version: lts
- runtime: deno
script: test-runtime-deno
deno-version: latest
permissions:
contents: read
steps:
- uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
allowed-endpoints: >
api.github.com:443
deno.com:443
dl.deno.land:443
github.com:443
nodejs.org:443
objects.githubusercontent.com:443
registry.npmjs.org:443
release-assets.githubusercontent.com:443
disable-sudo-and-containers: true
egress-policy: block
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 22
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
if: matrix.runtime == 'bun'
with:
bun-version: ${{ matrix.bun-version }}
- uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
if: matrix.runtime == 'deno'
with:
deno-version: ${{ matrix.deno-version }}
# Build the whole workspace first so `@arcjet/transport`'s dependencies
# (`@arcjet/env`, `@arcjet/logger`) are available to the runtime test.
- run: npm ci && npm run build
- run: npm run ${{ matrix.script }} --workspace=@arcjet/transport
27 changes: 27 additions & 0 deletions arcjet-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,33 @@ See the [docs](https://docs.arcjet.com/mcp-server) for setup instructions.

You can also manage sites and keys with the CLI: `npx @arcjet/cli`.

## Proxy support

The standard proxy environment variables (`HTTP_PROXY` and `HTTPS_PROXY`, while
respecting `NO_PROXY`) are auto-detected, making it possible to connect to the
Arcjet API through a proxy such as [Squid](https://www.squid-cache.org/). When a
proxy is in use, a line is logged at startup; the proxy
URL itself is not logged, since it can contain credentials. How the request is
actually proxied depends on the runtime:

- **Node.js** — uses the HTTP/2 transport; when a proxy is detected, requests
are routed through it over HTTP/1.1 using the built-in proxy support of the
Node.js HTTP agent, otherwise made directly over HTTP/2.
- **Bun** — uses the HTTP/2 transport directly, but its Node HTTP agent doesn't
support proxying, so when a proxy is detected it falls back to the fetch-based
transport and Bun's `fetch` performs the proxying natively.
- **Deno** — the runtime's `fetch` performs the proxying natively.
- **Cloudflare Workers** and other edge runtimes don't support outbound proxy
environment variables, so no proxy is used.

`NO_PROXY` accepts a comma- or space-separated list of host suffixes, each with
an optional leading `.` or `*.` and an optional `:port`, plus `*` to bypass the
proxy for every host. Entries are matched as host names; IP/CIDR ranges (such as
`10.0.0.0/8`) are not supported, the same as
[curl](https://curl.se/docs/manpage.html#--noproxy). On Bun and Deno the
runtime's `fetch` applies `NO_PROXY` itself, so its exact semantics are the
runtime's.

## Runtime support

| Runtime | Minimum version |
Expand Down
16 changes: 8 additions & 8 deletions arcjet-guard/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions arcjet-guard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
"types": "./dist/fetch.d.ts",
"import": "./dist/fetch.js"
},
"deno": {
"types": "./dist/fetch.d.ts",
"import": "./dist/fetch.js"
},
"node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
Expand Down
209 changes: 209 additions & 0 deletions arcjet-guard/src/detect-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";

import { detectProxy } from "./detect-proxy.ts";

// Run `detectProxy` with the given environment while capturing (and silencing)
// the startup log line, returning the resolved proxy and whether it logged.
function detect(
baseUrl: string,
proxyEnv: Record<string, string | undefined>,
): { proxy: string | undefined; logged: boolean } {
const original = console.info;
let logged = false;
console.info = (): void => {
logged = true;
};

try {
return { proxy: detectProxy(new URL(baseUrl), proxyEnv), logged };
} finally {
console.info = original;
}
}

describe("detectProxy", () => {
test("returns undefined and does not log without a proxy", () => {
const { proxy, logged } = detect("https://decide.arcjet.com", {});

assert.equal(proxy, undefined);
assert.equal(logged, false);
});

test("throws on an invalid base URL", () => {
// `detectProxy` takes a parsed `URL`, so an invalid base URL surfaces when
// the caller constructs it (here, via the `detect` helper) rather than
// being swallowed — matching how `createTransport` parses up front.
assert.throws(() => detect("not a url", {}), /Invalid URL/);
});

test("resolves the proxy for HTTPS and HTTP targets", () => {
assert.equal(
detect("https://decide.arcjet.com", {
HTTPS_PROXY: "http://proxy.example.com:3128",
}).proxy,
"http://proxy.example.com:3128",
);

assert.equal(
detect("http://decide.arcjet.com", {
HTTP_PROXY: "http://proxy.example.com:3128",
}).proxy,
"http://proxy.example.com:3128",
);
});

test("logs once when a proxy is in use and the level allows it", () => {
assert.equal(
detect("https://decide.arcjet.com", {
HTTPS_PROXY: "http://proxy.example.com:3128",
ARCJET_LOG_LEVEL: "info",
}).logged,
true,
);
});

test("does not log by default (level below `info`)", () => {
// Matches @arcjet/transport, whose default `warn` level hides this line.
assert.equal(
detect("https://decide.arcjet.com", {
HTTPS_PROXY: "http://proxy.example.com:3128",
}).logged,
false,
);
assert.equal(
detect("https://decide.arcjet.com", {
HTTPS_PROXY: "http://proxy.example.com:3128",
ARCJET_LOG_LEVEL: "warn",
}).logged,
false,
);
});

test("prefers the lowercase proxy variable", () => {
assert.equal(
detect("http://api.example.com/", {
http_proxy: "http://lower.example.com:3128",
HTTP_PROXY: "http://upper.example.com:3128",
}).proxy,
"http://lower.example.com:3128",
);
});

test("never logs the proxy URL or its credentials", () => {
const messages: unknown[] = [];
const original = console.info;
console.info = (...values: unknown[]): void => {
messages.push(...values);
};

try {
detectProxy(new URL("https://decide.arcjet.com"), {
HTTPS_PROXY: "http://user:secret@proxy.example.com:3128",
ARCJET_LOG_LEVEL: "info",
});
} finally {
console.info = original;
}

// Only the fixed message is logged — never the proxy URL, so credentials
// and host can't leak. Asserting the exact output is stronger than checking
// for substrings.
assert.deepEqual(messages, ["Connecting to the Arcjet API through a proxy"]);
});

test("honors `NO_PROXY`", () => {
const proxy = "http://proxy.example.com:3128";

// [NO_PROXY, base URL, expected to be bypassed]
const cases: Array<[string, string, boolean]> = [
["*", "http://api.example.com:8080/", true],
["api.example.com", "http://api.example.com:8080/", true],
["example.com", "http://api.example.com:8080/", true],
["other.com", "http://api.example.com:8080/", false],
["api.example.com:8080", "http://api.example.com:8080/", true],
["api.example.com:9999", "http://api.example.com:8080/", false],
[".example.com", "http://api.example.com:8080/", true],
["*.example.com", "http://api.example.com:8080/", true],
[",other.com", "http://api.example.com:8080/", false],
[".", "http://api.example.com:8080/", false],
["foo:bar", "http://api.example.com:8080/", false],
["api.example.com:80", "http://api.example.com/", true],
["api.example.com:443", "https://api.example.com/", true],
// IPv6 hosts, written with or without brackets and with or without a port.
["::1", "http://[::1]:8080/", true],
["[::1]", "http://[::1]:8080/", true],
["[::1]:8080", "http://[::1]:8080/", true],
["[::1]:9999", "http://[::1]:8080/", false],
["::1", "http://[::2]:8080/", false],
];

for (const [noProxy, baseUrl, bypassed] of cases) {
assert.equal(
detect(baseUrl, {
HTTP_PROXY: proxy,
HTTPS_PROXY: proxy,
NO_PROXY: noProxy,
}).proxy,
bypassed ? undefined : proxy,
`NO_PROXY=${noProxy} for ${baseUrl}`,
);
}
});

test("returns undefined when reading the environment throws", () => {
// Simulate a runtime that gates environment access behind a permission
// (e.g. Deno without `--allow-env`), where reading a variable throws.
const throwing = new Proxy<Record<string, string | undefined>>(
{},
{
get(): never {
throw new Error("permission denied");
},
},
);

const { proxy, logged } = detect("https://decide.arcjet.com", throwing);

assert.equal(proxy, undefined);
assert.equal(logged, false);
});

test("ignores uppercase `HTTP_PROXY` under CGI (httpoxy)", () => {
// With `REQUEST_METHOD` set (a CGI environment), uppercase `HTTP_PROXY` —
// which an inbound `Proxy` header can populate — is ignored for HTTP.
assert.equal(
detect("http://api.example.com/", {
HTTP_PROXY: "http://attacker.example.com:3128",
REQUEST_METHOD: "GET",
}).proxy,
undefined,
);

// Lowercase `http_proxy` is still honored under CGI.
assert.equal(
detect("http://api.example.com/", {
http_proxy: "http://proxy.example.com:3128",
REQUEST_METHOD: "GET",
}).proxy,
"http://proxy.example.com:3128",
);

// HTTPS targets are unaffected (no header maps to `HTTPS_PROXY`).
assert.equal(
detect("https://api.example.com/", {
HTTPS_PROXY: "http://proxy.example.com:3128",
REQUEST_METHOD: "GET",
}).proxy,
"http://proxy.example.com:3128",
);

// Without `REQUEST_METHOD`, uppercase `HTTP_PROXY` is honored as usual.
assert.equal(
detect("http://api.example.com/", {
HTTP_PROXY: "http://proxy.example.com:3128",
}).proxy,
"http://proxy.example.com:3128",
);
});
});
Loading
Loading