@mmerterden/multi-agent-toolkit-mcp 3.13.1 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +192 -5
- package/README.md +80 -8
- package/README.tr.md +104 -8
- package/index.js +526 -162
- package/package.json +4 -4
- package/tools/context/index.js +34 -18
- package/tools/design-check/component-walk.js +359 -0
- package/tools/design-check/content-cardinality.js +3 -3
- package/tools/design-check/index.js +78 -11
- package/tools/design-check/report.js +152 -10
- package/tools/design-check/scan.js +1 -1
- package/tools/design-check/scenario-inventory.js +404 -50
- package/tools/design-check/visual-compare.js +17 -2
- package/tools/ios-app-store-audit/context.js +3 -3
- package/tools/ios-app-store-audit/exec.js +17 -0
- package/tools/ios-app-store-audit/index.js +0 -15
- package/tools/ios-app-store-audit/rules/code-signing.js +2 -2
- package/tools/ios-app-store-audit/rules/dead-reference.js +2 -2
- package/tools/ios-app-store-audit/rules/debug-tool-leak.js +2 -2
- package/tools/ios-app-store-audit/rules/embedded-sdk.js +3 -3
- package/tools/ios-app-store-audit/rules/extension-signing.js +2 -2
- package/tools/ios-app-store-audit/rules/ipv6-compliance.js +2 -2
- package/tools/ios-app-store-audit/rules/production-hygiene.js +2 -2
- package/tools/ios-app-store-audit/rules/provisioning-profile.js +3 -3
- package/tools/ios-app-store-audit/rules/required-reason-api.js +2 -2
- package/tools/offload/index.js +7 -3
- package/tools/policy/egress-proxy.js +268 -0
- package/tools/policy/index.js +283 -0
- package/tools/security/cvss.js +108 -0
- package/tools/security/deps.js +0 -0
- package/tools/security/index.js +115 -0
- package/tools/spawn-collect/index.js +141 -0
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* deliberately non-compliant fixture.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import { execBounded } from "./exec.js";
|
|
21
21
|
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
22
22
|
import { shq } from "./models.js";
|
|
23
23
|
import { join, basename } from "path";
|
|
@@ -25,7 +25,7 @@ import { join, basename } from "path";
|
|
|
25
25
|
/** Run a shell command, return trimmed stdout or empty string on failure. */
|
|
26
26
|
function run(cmd) {
|
|
27
27
|
try {
|
|
28
|
-
return
|
|
28
|
+
return execBounded(cmd).trim();
|
|
29
29
|
} catch {
|
|
30
30
|
return "";
|
|
31
31
|
}
|
|
@@ -233,7 +233,7 @@ export function extractEntitlements(executablePath) {
|
|
|
233
233
|
}
|
|
234
234
|
// Pipe XML into plutil for JSON conversion.
|
|
235
235
|
try {
|
|
236
|
-
const json =
|
|
236
|
+
const json = execBounded(`plutil -convert json -o - -`, {
|
|
237
237
|
input: xml,
|
|
238
238
|
encoding: "utf-8",
|
|
239
239
|
}).trim();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exec.js - the one way this audit runs a command.
|
|
3
|
+
*
|
|
4
|
+
* codesign --deep on a large bundle, strings over a multi-hundred-megabyte
|
|
5
|
+
* binary and plutil on a hostile plist can each run for a long time. execSync
|
|
6
|
+
* with no timeout blocks the server's event loop for as long as the child
|
|
7
|
+
* lives, so every command here gets a bound on both time and output.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync } from "child_process";
|
|
11
|
+
|
|
12
|
+
export const EXEC_TIMEOUT_MS = 120000;
|
|
13
|
+
export const EXEC_MAX_BUFFER = 64 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
export function execBounded(cmd, opts = {}) {
|
|
16
|
+
return execSync(cmd, { encoding: "utf-8", timeout: EXEC_TIMEOUT_MS, maxBuffer: EXEC_MAX_BUFFER, ...opts });
|
|
17
|
+
}
|
|
@@ -184,18 +184,3 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
|
|
|
184
184
|
violations,
|
|
185
185
|
};
|
|
186
186
|
}
|
|
187
|
-
|
|
188
|
-
// ---------- MCP tool descriptor (consumed by main index.js) ---------------
|
|
189
|
-
|
|
190
|
-
export const TOOL_DESCRIPTOR = {
|
|
191
|
-
name: "ios_app_store_audit",
|
|
192
|
-
description: "Deep App Store Review compliance audit for .xcarchive bundles. 18-rule catalog covering privacy manifest, code signing, embedded SDKs, entitlements, asset hygiene, IPv6 compliance, debug-tool leak detection, Swift ABI compatibility, and more. Returns structured JSON with severity-ranked violations and ITMS error code mappings. Replaces the lighter ios_archive_audit (deprecated). Use --rules=core for the 6 baseline checks, --rules=deep|all for the full scan.",
|
|
193
|
-
inputSchema: {
|
|
194
|
-
type: "object",
|
|
195
|
-
properties: {
|
|
196
|
-
archive_path: { type: "string", description: "Absolute path to the .xcarchive bundle" },
|
|
197
|
-
rules: { type: "string", description: "'all' (default) | 'core' | 'deep' | comma-separated ruleIDs" },
|
|
198
|
-
},
|
|
199
|
-
required: ["archive_path"],
|
|
200
|
-
},
|
|
201
|
-
};
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* 5. Main executable passes `codesign --verify --deep --strict`
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { execBounded } from "../exec.js";
|
|
16
16
|
import { existsSync } from "fs";
|
|
17
17
|
import { Severity, makeViolation, shq } from "../models.js";
|
|
18
18
|
|
|
@@ -22,7 +22,7 @@ export const description = "Checks entitlements, provisioning, and signing for A
|
|
|
22
22
|
|
|
23
23
|
function isCodeSigned(executablePath) {
|
|
24
24
|
try {
|
|
25
|
-
|
|
25
|
+
execBounded(`/usr/bin/codesign --verify --deep --strict ${shq(executablePath)}`, {
|
|
26
26
|
stdio: "ignore",
|
|
27
27
|
});
|
|
28
28
|
return true;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Severity is .warning (images) / .info (empty nibs); never .error.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { execBounded } from "../exec.js";
|
|
12
12
|
import { existsSync, readdirSync, statSync } from "fs";
|
|
13
13
|
import { join, extname, parse as parsePath } from "path";
|
|
14
14
|
import { Severity, makeViolation, shq } from "../models.js";
|
|
@@ -29,7 +29,7 @@ const SYSTEM_PREFIXES = [
|
|
|
29
29
|
/** Return all printable strings in a binary as a Set. */
|
|
30
30
|
function binaryStrings(executablePath) {
|
|
31
31
|
try {
|
|
32
|
-
const out =
|
|
32
|
+
const out = execBounded(`/usr/bin/strings ${shq(executablePath)}`, {
|
|
33
33
|
encoding: "utf-8",
|
|
34
34
|
maxBuffer: 256 * 1024 * 1024,
|
|
35
35
|
});
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* 5. Generic name-pattern catch-all
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { execBounded } from "../exec.js";
|
|
19
19
|
import { existsSync, readFileSync, statSync } from "fs";
|
|
20
20
|
import { dirname, basename, join } from "path";
|
|
21
21
|
import { fileURLToPath } from "url";
|
|
@@ -47,7 +47,7 @@ function runStringsGrep(executablePath, patterns) {
|
|
|
47
47
|
if (patterns.length === 0) return "";
|
|
48
48
|
const grepPattern = patterns.map(escapeForGrep).join("|");
|
|
49
49
|
try {
|
|
50
|
-
return
|
|
50
|
+
return execBounded(
|
|
51
51
|
`/usr/bin/strings ${shq(executablePath)} | grep -E '${grepPattern}' 2>/dev/null | head -200`,
|
|
52
52
|
{ encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 }
|
|
53
53
|
);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Source list: https://developer.apple.com/support/third-party-SDK-requirements/
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { execBounded } from "../exec.js";
|
|
12
12
|
import { existsSync, readFileSync } from "fs";
|
|
13
13
|
import { dirname, basename, join } from "path";
|
|
14
14
|
import { fileURLToPath } from "url";
|
|
@@ -46,7 +46,7 @@ function isAppleRequiredSDK(frameworkName) {
|
|
|
46
46
|
|
|
47
47
|
function isCodeSigned(path) {
|
|
48
48
|
try {
|
|
49
|
-
|
|
49
|
+
execBounded(`/usr/bin/codesign --verify --deep --strict ${shq(path)}`, { stdio: "ignore" });
|
|
50
50
|
return true;
|
|
51
51
|
} catch {
|
|
52
52
|
return false;
|
|
@@ -57,7 +57,7 @@ function simulatorArchitectures(binaryPath) {
|
|
|
57
57
|
if (!existsSync(binaryPath)) return [];
|
|
58
58
|
let archs = "";
|
|
59
59
|
try {
|
|
60
|
-
archs =
|
|
60
|
+
archs = execBounded(`/usr/bin/lipo -archs ${shq(binaryPath)} 2>/dev/null`, {
|
|
61
61
|
encoding: "utf-8",
|
|
62
62
|
}).trim();
|
|
63
63
|
} catch {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* ITMS error codes: 90035, 90046, 90283. Apple Guideline 5.1.1.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { execBounded } from "../exec.js";
|
|
11
11
|
import { existsSync } from "fs";
|
|
12
12
|
import { join, basename } from "path";
|
|
13
13
|
import { Severity, makeViolation, shq } from "../models.js";
|
|
@@ -19,7 +19,7 @@ export const description = "Checks embedded extensions for signing, bundle ID pr
|
|
|
19
19
|
|
|
20
20
|
function isCodeSigned(path) {
|
|
21
21
|
try {
|
|
22
|
-
|
|
22
|
+
execBounded(`/usr/bin/codesign --verify --deep --strict ${shq(path)}`, { stdio: "ignore" });
|
|
23
23
|
return true;
|
|
24
24
|
} catch {
|
|
25
25
|
return false;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - Raw socket APIs that don't auto-handle IPv6
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { execBounded } from "../exec.js";
|
|
12
12
|
import { existsSync } from "fs";
|
|
13
13
|
import { Severity, makeViolation, shq } from "../models.js";
|
|
14
14
|
|
|
@@ -44,7 +44,7 @@ export function check(ctx) {
|
|
|
44
44
|
|
|
45
45
|
let stringsOutput = "";
|
|
46
46
|
try {
|
|
47
|
-
stringsOutput =
|
|
47
|
+
stringsOutput = execBounded(`/usr/bin/strings ${shq(ctx.executablePath)}`, {
|
|
48
48
|
encoding: "utf-8",
|
|
49
49
|
maxBuffer: 256 * 1024 * 1024,
|
|
50
50
|
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Apple Guidelines: 2.1 (App Completeness), 2.5.1 (Software Requirements).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { execBounded } from "../exec.js";
|
|
11
11
|
import { existsSync, readdirSync, statSync } from "fs";
|
|
12
12
|
import { join, basename, extname } from "path";
|
|
13
13
|
import { Severity, makeViolation, shq } from "../models.js";
|
|
@@ -126,7 +126,7 @@ function checkLoggingSymbols(ctx) {
|
|
|
126
126
|
const grepPattern = escapedPatterns.join("|");
|
|
127
127
|
let output = "";
|
|
128
128
|
try {
|
|
129
|
-
output =
|
|
129
|
+
output = execBounded(
|
|
130
130
|
`/usr/bin/strings ${shq(ctx.executablePath)} | grep -E '${grepPattern}' 2>/dev/null | head -100`,
|
|
131
131
|
{ encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 }
|
|
132
132
|
);
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* binary / XML plists across Xcode versions.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { execBounded } from "../exec.js";
|
|
14
14
|
import { existsSync } from "fs";
|
|
15
15
|
import { join } from "path";
|
|
16
16
|
import { Severity, makeViolation, shq } from "../models.js";
|
|
@@ -24,13 +24,13 @@ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
24
24
|
function decodeProfile(profilePath) {
|
|
25
25
|
try {
|
|
26
26
|
// CMS decode → produces plist bytes on stdout (XML/binary depending on Xcode).
|
|
27
|
-
const cmsXml =
|
|
27
|
+
const cmsXml = execBounded(`/usr/bin/security cms -D -i ${shq(profilePath)} 2>/dev/null`, {
|
|
28
28
|
encoding: "utf-8",
|
|
29
29
|
maxBuffer: 16 * 1024 * 1024,
|
|
30
30
|
});
|
|
31
31
|
if (!cmsXml.trim()) return null;
|
|
32
32
|
// Convert to JSON for predictable typed access.
|
|
33
|
-
const json =
|
|
33
|
+
const json = execBounded(`plutil -convert json -o - -`, {
|
|
34
34
|
input: cmsXml,
|
|
35
35
|
encoding: "utf-8",
|
|
36
36
|
}).trim();
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* is present, App Review rejects with ITMS-91065 / 91066 errors.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { execBounded } from "../exec.js";
|
|
12
12
|
import { existsSync } from "fs";
|
|
13
13
|
import { Severity, makeViolation, shq } from "../models.js";
|
|
14
14
|
|
|
@@ -59,7 +59,7 @@ const CATEGORIES = [
|
|
|
59
59
|
|
|
60
60
|
function binaryStringsBlob(executablePath) {
|
|
61
61
|
try {
|
|
62
|
-
return
|
|
62
|
+
return execBounded(`/usr/bin/strings ${shq(executablePath)}`, {
|
|
63
63
|
encoding: "utf-8",
|
|
64
64
|
maxBuffer: 256 * 1024 * 1024,
|
|
65
65
|
});
|
package/tools/offload/index.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// outputSchema answers with JSON the host parses as structuredContent, and
|
|
16
16
|
// replacing that with a summary would break the parse.
|
|
17
17
|
|
|
18
|
-
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, rmSync } from "fs";
|
|
18
|
+
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, rmSync, chmodSync } from "fs";
|
|
19
19
|
import { join, resolve, sep } from "path";
|
|
20
20
|
import { homedir } from "os";
|
|
21
21
|
|
|
@@ -202,11 +202,15 @@ export function offloadLargeText(tool, text, opts = {}) {
|
|
|
202
202
|
const stamp = opts.stamp ?? new Date().toISOString().replace(/[:.]/g, "-");
|
|
203
203
|
if (text.length < minChars) return { text, offloaded: false };
|
|
204
204
|
|
|
205
|
+
// Owner-only. A payload is whatever the tool returned - a logcat with tokens
|
|
206
|
+
// in it, a keychain listing, a page's text - and the default umask would
|
|
207
|
+
// leave it readable by every account on the machine.
|
|
205
208
|
let path;
|
|
206
209
|
try {
|
|
207
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
210
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
211
|
+
chmodSync(dir, 0o700);
|
|
208
212
|
path = join(dir, `${tool}-${stamp}.txt`);
|
|
209
|
-
writeFileSync(path, text);
|
|
213
|
+
writeFileSync(path, text, { mode: 0o600 });
|
|
210
214
|
} catch {
|
|
211
215
|
// A write failure must not lose the payload: fall back to returning it
|
|
212
216
|
// whole. Spending context is recoverable; dropping the tail is not.
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Strict-mode egress proxy for the headless browser.
|
|
2
|
+
//
|
|
3
|
+
// A browser-context route sees the first request of a redirect chain and never
|
|
4
|
+
// the hops after it, so a page on an allowed host can answer 302 to a refused
|
|
5
|
+
// one and the browser follows. Every hop does pass through a proxy, though, so
|
|
6
|
+
// under the strict policy the browser is launched with this one: each plain
|
|
7
|
+
// HTTP request, each CONNECT tunnel and each upgrade is checked against the
|
|
8
|
+
// URL policy before a socket is opened, and the connection goes to the address
|
|
9
|
+
// the check resolved, so a DNS answer cannot change between the two.
|
|
10
|
+
//
|
|
11
|
+
// It listens on 127.0.0.1 only, is unref'd so it never keeps the process
|
|
12
|
+
// alive, and answers a refused request with 403 and the reason as the body.
|
|
13
|
+
// An upstream that does not accept the connection within the connect timeout
|
|
14
|
+
// (MCP_TOOLKIT_PROXY_CONNECT_TIMEOUT_MS, 15 s by default) is answered with 504.
|
|
15
|
+
|
|
16
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
17
|
+
import { connect, isIP } from "node:net";
|
|
18
|
+
import { pipeline } from "node:stream";
|
|
19
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
|
|
22
|
+
|
|
23
|
+
export function proxyConnectTimeoutMs(env = process.env, warn = (m) => process.stderr.write(m)) {
|
|
24
|
+
const raw = String(env.MCP_TOOLKIT_PROXY_CONNECT_TIMEOUT_MS ?? "").trim();
|
|
25
|
+
if (!raw) return DEFAULT_CONNECT_TIMEOUT_MS;
|
|
26
|
+
if (/^\d+$/.test(raw) && Number(raw) > 0) return Number(raw);
|
|
27
|
+
warn(`multi-agent-toolkit: ignoring MCP_TOOLKIT_PROXY_CONNECT_TIMEOUT_MS value "${raw}" (not a positive whole number of milliseconds), using ${DEFAULT_CONNECT_TIMEOUT_MS}\n`);
|
|
28
|
+
return DEFAULT_CONNECT_TIMEOUT_MS;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const HOP_BY_HOP = ["proxy-connection", "proxy-authorization", "proxy-authenticate", "keep-alive", "te", "trailer", "transfer-encoding", "upgrade"];
|
|
32
|
+
|
|
33
|
+
// Runs the policy check through a lookup that remembers its answers, so the
|
|
34
|
+
// connection can be pinned to exactly what was judged.
|
|
35
|
+
async function judge(url, check, resolve) {
|
|
36
|
+
let answers = null;
|
|
37
|
+
const lookup = async (host, opts) => {
|
|
38
|
+
answers = await resolve(host, opts);
|
|
39
|
+
return answers;
|
|
40
|
+
};
|
|
41
|
+
const refused = await check(url, lookup);
|
|
42
|
+
return { refused, answers };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A net/http `lookup` option that hands back the checked answers instead of
|
|
46
|
+
// resolving again. Without answers (an IP literal, or a host the allow list
|
|
47
|
+
// named) the ordinary resolver is used.
|
|
48
|
+
function pinnedLookup(answers) {
|
|
49
|
+
if (!answers || !answers.length) return undefined;
|
|
50
|
+
return (hostname, options, callback) => {
|
|
51
|
+
const cb = typeof options === "function" ? options : callback;
|
|
52
|
+
const opts = typeof options === "function" ? {} : options || {};
|
|
53
|
+
if (opts.all) return cb(null, answers);
|
|
54
|
+
return cb(null, answers[0].address, answers[0].family);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function splitHostPort(authority, defaultPort) {
|
|
59
|
+
const m = /^\[([^\]]+)\]:(\d+)$/.exec(authority) || /^([^:]+):(\d+)$/.exec(authority);
|
|
60
|
+
if (m) return { host: m[1], port: Number(m[2]) };
|
|
61
|
+
return { host: authority.replace(/^\[|\]$/g, ""), port: defaultPort };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const validPort = (port) => Number.isInteger(port) && port >= 1 && port <= 65535;
|
|
65
|
+
|
|
66
|
+
const hostForUrl = (host) => (isIP(host) === 6 ? `[${host}]` : host);
|
|
67
|
+
|
|
68
|
+
const bare = (hostname) => hostname.replace(/^\[|\]$/g, "");
|
|
69
|
+
|
|
70
|
+
const gone = (socket) => socket.destroyed || !socket.writable;
|
|
71
|
+
|
|
72
|
+
// Each side of a tunnel is torn down when the other closes, so neither end is
|
|
73
|
+
// left holding a descriptor for a peer that has gone.
|
|
74
|
+
function splice(client, upstream) {
|
|
75
|
+
upstream.pipe(client);
|
|
76
|
+
client.pipe(upstream);
|
|
77
|
+
client.on("close", () => upstream.destroy());
|
|
78
|
+
upstream.on("close", () => client.destroy());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rawReply(socket, status, body = "") {
|
|
82
|
+
if (gone(socket)) return;
|
|
83
|
+
const head = `HTTP/1.1 ${status}\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: ${Buffer.byteLength(body)}\r\nconnection: close\r\n\r\n`;
|
|
84
|
+
socket.end(`${head}${body}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Opens a TCP connection to the judged host through the pinned lookup. If it is
|
|
88
|
+
// not established within `timeoutMs` the socket is destroyed and `onTimeout`
|
|
89
|
+
// runs instead of `onOpen`.
|
|
90
|
+
function dial({ host, port, answers, timeoutMs }, onOpen, onTimeout) {
|
|
91
|
+
let open = false;
|
|
92
|
+
const upstream = connect({ host, port, lookup: pinnedLookup(answers) }, () => {
|
|
93
|
+
open = true;
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
onOpen();
|
|
96
|
+
});
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
if (open) return;
|
|
99
|
+
upstream.destroy();
|
|
100
|
+
onTimeout();
|
|
101
|
+
}, timeoutMs);
|
|
102
|
+
upstream.on("close", () => clearTimeout(timer));
|
|
103
|
+
return { upstream, isOpen: () => open };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Starts the proxy. `check(url, lookup)` resolves to null when the URL may be
|
|
108
|
+
* fetched and to the refusal reason otherwise; `onRefuse(url, reason)` hears
|
|
109
|
+
* about every refusal. `resolve` is the DNS lookup the check and the pinned
|
|
110
|
+
* connection share. `connectTimeoutMs` bounds how long an upstream connect may
|
|
111
|
+
* take. Resolves to { url, port, close }.
|
|
112
|
+
*
|
|
113
|
+
* Error handlers go on the client socket before anything is awaited: a client
|
|
114
|
+
* that resets while its request is being judged would otherwise raise an
|
|
115
|
+
* unhandled 'error' and take the process with it. Every handler is wrapped so
|
|
116
|
+
* no rejection escapes, and nothing is opened upstream for a client that has
|
|
117
|
+
* already gone.
|
|
118
|
+
*/
|
|
119
|
+
export async function startEgressProxy({ check, onRefuse = () => {}, resolve = dnsLookup, connectTimeoutMs = proxyConnectTimeoutMs() }) {
|
|
120
|
+
const refuse = (url, reason) => {
|
|
121
|
+
try { onRefuse(url, reason); } catch { /* reporting must not break the proxy */ }
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const server = createServer(async (req, res) => {
|
|
125
|
+
req.on("error", () => {});
|
|
126
|
+
res.on("error", () => {});
|
|
127
|
+
try {
|
|
128
|
+
let target;
|
|
129
|
+
try {
|
|
130
|
+
target = new URL(req.url);
|
|
131
|
+
} catch {
|
|
132
|
+
res.writeHead(400, { "content-type": "text/plain" });
|
|
133
|
+
res.end("absolute URL required");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const { refused, answers } = await judge(target.href, check, resolve);
|
|
137
|
+
if (res.destroyed || req.socket.destroyed) return;
|
|
138
|
+
if (refused) {
|
|
139
|
+
refuse(target.href, refused);
|
|
140
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
141
|
+
res.end(refused);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const headers = { ...req.headers };
|
|
145
|
+
for (const h of HOP_BY_HOP) delete headers[h];
|
|
146
|
+
let connectTimedOut = false;
|
|
147
|
+
const upstream = httpRequest({
|
|
148
|
+
host: bare(target.hostname),
|
|
149
|
+
port: target.port || 80,
|
|
150
|
+
method: req.method,
|
|
151
|
+
path: `${target.pathname}${target.search}`,
|
|
152
|
+
headers,
|
|
153
|
+
lookup: pinnedLookup(answers),
|
|
154
|
+
}, (up) => {
|
|
155
|
+
res.writeHead(up.statusCode || 502, up.headers);
|
|
156
|
+
pipeline(up, res, (err) => { if (err) res.destroy(); });
|
|
157
|
+
});
|
|
158
|
+
const timer = setTimeout(() => {
|
|
159
|
+
connectTimedOut = true;
|
|
160
|
+
upstream.destroy(new Error("upstream connect timed out"));
|
|
161
|
+
}, connectTimeoutMs);
|
|
162
|
+
upstream.on("socket", (socket) => {
|
|
163
|
+
if (!socket.connecting) clearTimeout(timer);
|
|
164
|
+
else socket.once("connect", () => clearTimeout(timer));
|
|
165
|
+
});
|
|
166
|
+
upstream.on("close", () => clearTimeout(timer));
|
|
167
|
+
upstream.on("error", (e) => {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
if (res.destroyed) return;
|
|
170
|
+
if (res.headersSent) { res.destroy(); return; }
|
|
171
|
+
res.writeHead(connectTimedOut ? 504 : 502, { "content-type": "text/plain" });
|
|
172
|
+
res.end(connectTimedOut ? "upstream connect timed out" : `upstream error: ${e.code || e.message}`);
|
|
173
|
+
});
|
|
174
|
+
res.on("close", () => upstream.destroy());
|
|
175
|
+
req.pipe(upstream);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
if (res.destroyed) return;
|
|
178
|
+
if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain" });
|
|
179
|
+
res.end(`proxy error: ${e?.code || e?.message || e}`);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// HTTPS and WebSocket tunnels. Only the authority is visible here, which is
|
|
184
|
+
// all the policy judges.
|
|
185
|
+
server.on("connect", async (req, client, head) => {
|
|
186
|
+
client.on("error", () => client.destroy());
|
|
187
|
+
try {
|
|
188
|
+
const { host, port } = splitHostPort(req.url, 443);
|
|
189
|
+
if (!host || !validPort(port)) {
|
|
190
|
+
rawReply(client, "400 Bad Request", "invalid CONNECT authority");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const url = `https://${hostForUrl(host)}:${port}/`;
|
|
194
|
+
let judged;
|
|
195
|
+
try {
|
|
196
|
+
judged = new URL(url);
|
|
197
|
+
} catch {
|
|
198
|
+
rawReply(client, "400 Bad Request", "invalid CONNECT authority");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const { refused, answers } = await judge(url, check, resolve);
|
|
202
|
+
if (gone(client)) return;
|
|
203
|
+
if (refused) {
|
|
204
|
+
refuse(url, refused);
|
|
205
|
+
rawReply(client, "403 Forbidden", refused);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const { upstream, isOpen } = dial({ host: bare(judged.hostname), port, answers, timeoutMs: connectTimeoutMs }, () => {
|
|
209
|
+
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
210
|
+
if (head && head.length) upstream.write(head);
|
|
211
|
+
splice(client, upstream);
|
|
212
|
+
}, () => rawReply(client, "504 Gateway Timeout", "upstream connect timed out"));
|
|
213
|
+
upstream.on("error", () => (isOpen() ? client.destroy() : rawReply(client, "502 Bad Gateway")));
|
|
214
|
+
client.on("close", () => upstream.destroy());
|
|
215
|
+
} catch (e) {
|
|
216
|
+
rawReply(client, "502 Bad Gateway", `proxy error: ${e?.code || e?.message || e}`);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// A plain ws:// request sent to the proxy as an absolute-URI upgrade.
|
|
221
|
+
server.on("upgrade", async (req, client, head) => {
|
|
222
|
+
client.on("error", () => client.destroy());
|
|
223
|
+
try {
|
|
224
|
+
let target;
|
|
225
|
+
try {
|
|
226
|
+
target = new URL(req.url);
|
|
227
|
+
} catch {
|
|
228
|
+
rawReply(client, "400 Bad Request");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const httpUrl = target.href.replace(/^ws(s?):/i, "http$1:");
|
|
232
|
+
const { refused, answers } = await judge(httpUrl, check, resolve);
|
|
233
|
+
if (gone(client)) return;
|
|
234
|
+
if (refused) {
|
|
235
|
+
refuse(target.href, refused);
|
|
236
|
+
rawReply(client, "403 Forbidden");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const { upstream, isOpen } = dial({ host: bare(target.hostname), port: Number(target.port) || 80, answers, timeoutMs: connectTimeoutMs }, () => {
|
|
240
|
+
const lines = [`${req.method} ${target.pathname}${target.search} HTTP/1.1`];
|
|
241
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
242
|
+
if (req.rawHeaders[i].toLowerCase() === "proxy-connection") continue;
|
|
243
|
+
lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
|
|
244
|
+
}
|
|
245
|
+
upstream.write(`${lines.join("\r\n")}\r\n\r\n`);
|
|
246
|
+
if (head && head.length) upstream.write(head);
|
|
247
|
+
splice(client, upstream);
|
|
248
|
+
}, () => rawReply(client, "504 Gateway Timeout", "upstream connect timed out"));
|
|
249
|
+
upstream.on("error", () => (isOpen() ? client.destroy() : rawReply(client, "502 Bad Gateway")));
|
|
250
|
+
client.on("close", () => upstream.destroy());
|
|
251
|
+
} catch (e) {
|
|
252
|
+
rawReply(client, "502 Bad Gateway", `proxy error: ${e?.code || e?.message || e}`);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
server.on("clientError", (_e, socket) => socket.destroy());
|
|
257
|
+
await new Promise((resolve, reject) => {
|
|
258
|
+
server.once("error", reject);
|
|
259
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
260
|
+
});
|
|
261
|
+
server.unref();
|
|
262
|
+
const { port } = server.address();
|
|
263
|
+
return {
|
|
264
|
+
url: `http://127.0.0.1:${port}`,
|
|
265
|
+
port,
|
|
266
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
267
|
+
};
|
|
268
|
+
}
|