sandboxedjs 0.2.6 → 0.2.8
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/README.md +23 -16
- package/bin/sandboxedjs-egress.mjs +117 -0
- package/dist/browser-host.cjs +137 -0
- package/dist/browser-host.d.cts +12 -0
- package/dist/browser-host.d.ts +12 -0
- package/dist/browser-host.js +137 -0
- package/dist/egress.cjs +12 -1
- package/dist/egress.d.cts +33 -1
- package/dist/egress.d.ts +33 -1
- package/dist/egress.js +11 -2
- package/dist/index.cjs +174 -128
- package/dist/index.js +174 -128
- package/dist/worker-entry.js +63 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,38 +51,45 @@ plenty of them answer with 405. A container whose guest calls a real API
|
|
|
51
51
|
therefore works under Node and fails in a browser, for a reason that belongs to
|
|
52
52
|
the page rather than to anything in the container.
|
|
53
53
|
|
|
54
|
-
Give it somewhere to send those requests instead.
|
|
55
|
-
|
|
54
|
+
Give it somewhere to send those requests instead. Add that somewhere to the
|
|
55
|
+
project that is already being deployed, and the container finds it by itself:
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
npx sandboxedjs-egress
|
|
58
|
+
npx sandboxedjs-egress init --allow api.openai.com,ollama.com
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
That writes one file at the path the host platform serves — Cloudflare Pages,
|
|
62
|
+
Vercel and Netlify are detected, `--target` names one — and nothing else
|
|
63
|
+
changes. A container in a page probes its own origin for the proxy before it
|
|
64
|
+
gives up, so no project passes `proxy` and none of them has to be told twice.
|
|
65
|
+
|
|
66
|
+
In development the same proxy runs as a process, on the port the probe checks:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npx sandboxedjs-egress
|
|
65
70
|
```
|
|
66
71
|
|
|
67
|
-
In an app that already has a server, mount
|
|
68
|
-
|
|
69
|
-
itself:
|
|
72
|
+
In an app that already has a server, mount the handler on the well-known path.
|
|
73
|
+
Before any body parser: it reads the request stream itself.
|
|
70
74
|
|
|
71
75
|
```ts
|
|
72
|
-
import { egressNodeHandler } from "sandboxedjs/egress";
|
|
73
|
-
app.
|
|
74
|
-
// createContainer({ network: { allowOutbound: true, proxy: "/egress" } })
|
|
76
|
+
import { egressNodeHandler, EGRESS_PATH } from "sandboxedjs/egress";
|
|
77
|
+
app.use(EGRESS_PATH, egressNodeHandler({ allow: ["ollama.com"] }));
|
|
75
78
|
```
|
|
76
79
|
|
|
77
|
-
A static
|
|
78
|
-
Cloudflare Pages Function, a Worker, a route handler:
|
|
80
|
+
A static host takes the `Request` form — which is what `init` writes:
|
|
79
81
|
|
|
80
82
|
```ts
|
|
81
|
-
// functions/egress.ts
|
|
83
|
+
// functions/__sandboxedjs__/egress.ts, on Cloudflare Pages
|
|
82
84
|
import { handleEgressRequest } from "sandboxedjs/egress";
|
|
83
85
|
export const onRequest = ({ request }) => handleEgressRequest(request, { allow: ["ollama.com"] });
|
|
84
86
|
```
|
|
85
87
|
|
|
88
|
+
`network: { proxy }` still names one explicitly — a proxy somewhere else, or
|
|
89
|
+
one that discovery should not be trusted to find — and it is never overridden.
|
|
90
|
+
`sandboxedjs-serve` mounts the proxy itself, so an app served by it needs none
|
|
91
|
+
of this.
|
|
92
|
+
|
|
86
93
|
Every exit honours it — `curl`, `wget`, a guest's own `fetch`, and Python's
|
|
87
94
|
sockets — so it means the same thing whatever the project is written in.
|
|
88
95
|
Loopback stays inside the container, and the container's outbound policy still
|
|
@@ -21,9 +21,126 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { createServer } from "node:http";
|
|
24
|
+
import { mkdir, writeFile, access } from "node:fs/promises";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
24
26
|
import { egressNodeHandler } from "../dist/egress.js";
|
|
25
27
|
|
|
26
28
|
const args = process.argv.slice(2);
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
* `sandboxedjs-egress init` — the proxy as a file in the project that is
|
|
32
|
+
* already being deployed.
|
|
33
|
+
*
|
|
34
|
+
* Running a process beside a site works on a laptop and nowhere else, and a
|
|
35
|
+
* static host has no server to add one to. What every static host does have is
|
|
36
|
+
* a functions directory, so the proxy goes there: one file, at the path the
|
|
37
|
+
* container already looks for, and a deployment that needed a setting needs
|
|
38
|
+
* nothing.
|
|
39
|
+
*/
|
|
40
|
+
const TARGETS = {
|
|
41
|
+
cloudflare: {
|
|
42
|
+
file: "functions/__sandboxedjs__/egress.ts",
|
|
43
|
+
detect: ["wrangler.toml", "wrangler.json", "wrangler.jsonc", "functions"],
|
|
44
|
+
source: (allow) => `import { handleEgressRequest } from "sandboxedjs/egress";
|
|
45
|
+
${allow}
|
|
46
|
+
/* Cloudflare Pages serves this file at /__sandboxedjs__/egress, which is where
|
|
47
|
+
* a container in this site's pages looks for its way out. */
|
|
48
|
+
export const onRequest = ({ request }) => handleEgressRequest(request, options);
|
|
49
|
+
`,
|
|
50
|
+
},
|
|
51
|
+
vercel: {
|
|
52
|
+
file: "api/__sandboxedjs__/egress.ts",
|
|
53
|
+
detect: ["vercel.json", "api"],
|
|
54
|
+
source: (allow) => `import { handleEgressRequest } from "sandboxedjs/egress";
|
|
55
|
+
${allow}
|
|
56
|
+
export const config = { runtime: "edge" };
|
|
57
|
+
|
|
58
|
+
/* Vercel serves this file at /api/__sandboxedjs__/egress, which is one of the
|
|
59
|
+
* paths a container in this site's pages probes for its way out. */
|
|
60
|
+
export default (request) => handleEgressRequest(request, options);
|
|
61
|
+
`,
|
|
62
|
+
},
|
|
63
|
+
netlify: {
|
|
64
|
+
file: "netlify/functions/sandboxedjs-egress.ts",
|
|
65
|
+
detect: ["netlify.toml", "netlify"],
|
|
66
|
+
source: (allow) => `import { handleEgressRequest } from "sandboxedjs/egress";
|
|
67
|
+
${allow}
|
|
68
|
+
export const config = { path: "/.netlify/functions/sandboxedjs-egress" };
|
|
69
|
+
|
|
70
|
+
export default (request) => handleEgressRequest(request, options);
|
|
71
|
+
`,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const NODE_SNIPPET = ` import { egressNodeHandler, EGRESS_PATH } from "sandboxedjs/egress";
|
|
76
|
+
// Before any body parser: the handler reads the request stream itself.
|
|
77
|
+
app.use(EGRESS_PATH, egressNodeHandler({ allow: ["api.example.com"] }));`;
|
|
78
|
+
|
|
79
|
+
async function exists(path) {
|
|
80
|
+
try { await access(path); return true; } catch { return false; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function detectTarget(root) {
|
|
84
|
+
for (const [name, target] of Object.entries(TARGETS)) {
|
|
85
|
+
for (const marker of target.detect) {
|
|
86
|
+
if (await exists(join(root, marker))) return name;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function init(argv) {
|
|
93
|
+
const root = argv.find((a) => !a.startsWith("-")) ?? process.cwd();
|
|
94
|
+
const at = argv.indexOf("--target");
|
|
95
|
+
const allowAt = argv.indexOf("--allow");
|
|
96
|
+
const allowed = allowAt === -1
|
|
97
|
+
? null
|
|
98
|
+
: (argv[allowAt + 1] ?? "").split(",").map((h) => h.trim()).filter(Boolean);
|
|
99
|
+
const name = at === -1 ? await detectTarget(root) : argv[at + 1];
|
|
100
|
+
if (!name || !TARGETS[name]) {
|
|
101
|
+
console.error(
|
|
102
|
+
name
|
|
103
|
+
? `sandboxedjs-egress init: unknown target ${name}`
|
|
104
|
+
: "sandboxedjs-egress init: could not tell what this project deploys to.",
|
|
105
|
+
);
|
|
106
|
+
console.error(`Targets: ${Object.keys(TARGETS).join(", ")} (--target <name>)`);
|
|
107
|
+
console.error(`A server of your own takes the handler directly:\n\n${NODE_SNIPPET}\n`);
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const target = TARGETS[name];
|
|
112
|
+
const path = join(root, target.file);
|
|
113
|
+
if (await exists(path)) {
|
|
114
|
+
console.log(`${target.file} is already there; leaving it alone.`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
/* An open proxy carries whatever credentials the container holds to wherever
|
|
118
|
+
* it is asked, so the list is the limit and its absence is said out loud. */
|
|
119
|
+
const allow = allowed?.length
|
|
120
|
+
? `\n/* The hosts this proxy will fetch, subdomains included. */\nconst options = { allow: ${JSON.stringify(allowed)} };\n`
|
|
121
|
+
: `\n/* Anything that can reach this route can make a request through it, carrying\n * whatever credentials the container holds. Narrow it before this is public:\n * const options = { allow: ["api.example.com"] };\n */\nconst options = {};\n`;
|
|
122
|
+
await mkdir(dirname(path), { recursive: true });
|
|
123
|
+
await writeFile(path, target.source(allow));
|
|
124
|
+
console.log(`Wrote ${target.file} (${name}).`);
|
|
125
|
+
if (!allowed?.length) console.log("It will fetch any host: set `allow` in that file before deploying it publicly.");
|
|
126
|
+
console.log("Deploy it, and containers in this site's pages find it with no further configuration.");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (args[0] === "init") {
|
|
130
|
+
await init(args.slice(1));
|
|
131
|
+
process.exit(process.exitCode ?? 0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
135
|
+
console.log(`sandboxedjs-egress — outbound HTTP for a container that runs in a browser
|
|
136
|
+
|
|
137
|
+
Usage:
|
|
138
|
+
sandboxedjs-egress [port=4181] [--allow host,host] run the proxy here
|
|
139
|
+
sandboxedjs-egress init [dir] [--target cloudflare|vercel|netlify] [--allow host,host]
|
|
140
|
+
add it to a deployment
|
|
141
|
+
`);
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
27
144
|
const at = args.indexOf("--allow");
|
|
28
145
|
const allow = at === -1 ? undefined : (args[at + 1] ?? "").split(",").map((h) => h.trim()).filter(Boolean);
|
|
29
146
|
const port = Number(args.find((a) => /^\d+$/.test(a)) ?? 4181);
|
package/dist/browser-host.cjs
CHANGED
|
@@ -4,6 +4,138 @@ var http = require('http');
|
|
|
4
4
|
var promises = require('fs/promises');
|
|
5
5
|
var path = require('path');
|
|
6
6
|
|
|
7
|
+
// src/hosting/browser-host.ts
|
|
8
|
+
|
|
9
|
+
// src/net/proxy-fetch.ts
|
|
10
|
+
function toBase64(bytes) {
|
|
11
|
+
let text = "";
|
|
12
|
+
for (let at = 0; at < bytes.length; at += 32768) {
|
|
13
|
+
text += String.fromCharCode(...bytes.subarray(at, at + 32768));
|
|
14
|
+
}
|
|
15
|
+
return btoa(text);
|
|
16
|
+
}
|
|
17
|
+
function fromBase64(text) {
|
|
18
|
+
const binary = atob(text);
|
|
19
|
+
const bytes = new Uint8Array(binary.length);
|
|
20
|
+
for (let at = 0; at < binary.length; at += 1) bytes[at] = binary.charCodeAt(at);
|
|
21
|
+
return bytes;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/net/discover-egress.ts
|
|
25
|
+
var EGRESS_PATH = "/__sandboxedjs__/egress";
|
|
26
|
+
var EGRESS_MARKER = { sandboxedjs: "egress", protocol: 1 };
|
|
27
|
+
|
|
28
|
+
// src/net/egress-handler.ts
|
|
29
|
+
var HOP = /* @__PURE__ */ new Set([
|
|
30
|
+
"connection",
|
|
31
|
+
"keep-alive",
|
|
32
|
+
"proxy-authenticate",
|
|
33
|
+
"proxy-authorization",
|
|
34
|
+
"te",
|
|
35
|
+
"trailer",
|
|
36
|
+
"transfer-encoding",
|
|
37
|
+
"upgrade",
|
|
38
|
+
"host",
|
|
39
|
+
"content-length"
|
|
40
|
+
]);
|
|
41
|
+
var HEADERS = {
|
|
42
|
+
"access-control-allow-origin": "*",
|
|
43
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
44
|
+
"access-control-allow-headers": "content-type",
|
|
45
|
+
"access-control-max-age": "86400",
|
|
46
|
+
"cross-origin-resource-policy": "cross-origin"
|
|
47
|
+
};
|
|
48
|
+
var refuse = (status, message) => new Response(message, { status, headers: { ...HEADERS, "content-type": "text/plain; charset=utf-8" } });
|
|
49
|
+
async function handleEgressRequest(request, options = {}) {
|
|
50
|
+
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: HEADERS });
|
|
51
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
52
|
+
return new Response(request.method === "HEAD" ? null : JSON.stringify(EGRESS_MARKER), {
|
|
53
|
+
headers: { ...HEADERS, "content-type": "application/json" }
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (request.method !== "POST") return refuse(405, "POST a JSON request here.");
|
|
57
|
+
let payload;
|
|
58
|
+
try {
|
|
59
|
+
payload = await request.json();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return refuse(400, `could not read the request: ${error instanceof Error ? error.message : String(error)}`);
|
|
62
|
+
}
|
|
63
|
+
let target;
|
|
64
|
+
try {
|
|
65
|
+
target = new URL(String(payload.url));
|
|
66
|
+
} catch {
|
|
67
|
+
return refuse(400, `not a URL: ${payload.url}`);
|
|
68
|
+
}
|
|
69
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
70
|
+
return refuse(400, `${target.protocol} is not a protocol this proxy speaks`);
|
|
71
|
+
}
|
|
72
|
+
const allow = options.allow;
|
|
73
|
+
if (allow && !allow.some((host) => target.hostname === host || target.hostname.endsWith(`.${host}`))) {
|
|
74
|
+
return refuse(403, `${target.hostname} is not in this proxy's allow list`);
|
|
75
|
+
}
|
|
76
|
+
const headers = {};
|
|
77
|
+
for (const [name, value] of Object.entries(payload.headers ?? {})) {
|
|
78
|
+
if (!HOP.has(name.toLowerCase())) headers[name] = value;
|
|
79
|
+
}
|
|
80
|
+
const perform = options.fetch ?? fetch;
|
|
81
|
+
let answer;
|
|
82
|
+
try {
|
|
83
|
+
answer = await perform(target.toString(), {
|
|
84
|
+
method: payload.method ?? "GET",
|
|
85
|
+
headers,
|
|
86
|
+
...payload.body ? { body: fromBase64(payload.body) } : {},
|
|
87
|
+
redirect: "follow"
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return refuse(502, `the request to ${target.hostname} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
91
|
+
}
|
|
92
|
+
const back = {};
|
|
93
|
+
answer.headers.forEach((value, name) => {
|
|
94
|
+
if (!HOP.has(name.toLowerCase())) back[name] = value;
|
|
95
|
+
});
|
|
96
|
+
const body = new Uint8Array(await answer.arrayBuffer());
|
|
97
|
+
return new Response(
|
|
98
|
+
JSON.stringify({ status: answer.status, statusText: answer.statusText, headers: back, body: toBase64(body) }),
|
|
99
|
+
{ headers: { ...HEADERS, "content-type": "application/json" } }
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
function egressNodeHandler(options = {}) {
|
|
103
|
+
return async (request, response) => {
|
|
104
|
+
const chunks = [];
|
|
105
|
+
await new Promise((done, fail) => {
|
|
106
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
107
|
+
request.on("end", () => done());
|
|
108
|
+
request.on("error", (error) => fail(error));
|
|
109
|
+
});
|
|
110
|
+
let total = 0;
|
|
111
|
+
for (const chunk of chunks) total += chunk.length;
|
|
112
|
+
const body = new Uint8Array(total);
|
|
113
|
+
let at = 0;
|
|
114
|
+
for (const chunk of chunks) {
|
|
115
|
+
body.set(chunk, at);
|
|
116
|
+
at += chunk.length;
|
|
117
|
+
}
|
|
118
|
+
const headers = {};
|
|
119
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
120
|
+
if (typeof value === "string") headers[name] = value;
|
|
121
|
+
}
|
|
122
|
+
const answer = await handleEgressRequest(
|
|
123
|
+
new Request("http://egress.invalid/", {
|
|
124
|
+
method: request.method ?? "GET",
|
|
125
|
+
headers,
|
|
126
|
+
...body.length && request.method !== "GET" && request.method !== "HEAD" ? { body } : {}
|
|
127
|
+
}),
|
|
128
|
+
options
|
|
129
|
+
);
|
|
130
|
+
const out = {};
|
|
131
|
+
answer.headers.forEach((value, name) => {
|
|
132
|
+
out[name] = value;
|
|
133
|
+
});
|
|
134
|
+
response.writeHead(answer.status, out);
|
|
135
|
+
response.end(Buffer.from(await answer.arrayBuffer()));
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
7
139
|
// src/hosting/browser-host.ts
|
|
8
140
|
var browserIsolationHeaders = Object.freeze({
|
|
9
141
|
"Cross-Origin-Opener-Policy": "same-origin",
|
|
@@ -33,7 +165,12 @@ async function serveBrowserApp(options) {
|
|
|
33
165
|
const rel = path.relative(root, path$1);
|
|
34
166
|
return rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
35
167
|
};
|
|
168
|
+
const egress = options.egress === false ? null : egressNodeHandler({ ...options.egress?.allow ? { allow: options.egress.allow } : {} });
|
|
36
169
|
const handle = async (req, res) => {
|
|
170
|
+
if (egress && (req.url ?? "").split("?")[0] === EGRESS_PATH) {
|
|
171
|
+
await egress(req, res);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
37
174
|
for (const [name, value] of Object.entries(browserIsolationHeaders)) res.setHeader(name, value);
|
|
38
175
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
39
176
|
res.setHeader("Cache-Control", "no-cache");
|
package/dist/browser-host.d.cts
CHANGED
|
@@ -16,6 +16,18 @@ declare function serveBrowserApp(options: {
|
|
|
16
16
|
directory: string;
|
|
17
17
|
port?: number;
|
|
18
18
|
hostname?: string;
|
|
19
|
+
/**
|
|
20
|
+
* The outbound proxy this host serves for the containers it hosts.
|
|
21
|
+
*
|
|
22
|
+
* On by default, because a container in a browser cannot reach an API that
|
|
23
|
+
* sends no CORS headers without one, and a host that serves the page is
|
|
24
|
+
* already the right place to put it -- same origin, nothing else to start,
|
|
25
|
+
* and the container finds it without being configured. `allow` narrows what
|
|
26
|
+
* it will fetch; `false` turns it off for a host that wants none.
|
|
27
|
+
*/
|
|
28
|
+
egress?: false | {
|
|
29
|
+
allow?: string[];
|
|
30
|
+
};
|
|
19
31
|
}): Promise<{
|
|
20
32
|
server: Server;
|
|
21
33
|
url: string;
|
package/dist/browser-host.d.ts
CHANGED
|
@@ -16,6 +16,18 @@ declare function serveBrowserApp(options: {
|
|
|
16
16
|
directory: string;
|
|
17
17
|
port?: number;
|
|
18
18
|
hostname?: string;
|
|
19
|
+
/**
|
|
20
|
+
* The outbound proxy this host serves for the containers it hosts.
|
|
21
|
+
*
|
|
22
|
+
* On by default, because a container in a browser cannot reach an API that
|
|
23
|
+
* sends no CORS headers without one, and a host that serves the page is
|
|
24
|
+
* already the right place to put it -- same origin, nothing else to start,
|
|
25
|
+
* and the container finds it without being configured. `allow` narrows what
|
|
26
|
+
* it will fetch; `false` turns it off for a host that wants none.
|
|
27
|
+
*/
|
|
28
|
+
egress?: false | {
|
|
29
|
+
allow?: string[];
|
|
30
|
+
};
|
|
19
31
|
}): Promise<{
|
|
20
32
|
server: Server;
|
|
21
33
|
url: string;
|
package/dist/browser-host.js
CHANGED
|
@@ -2,6 +2,138 @@ import { createServer } from 'http';
|
|
|
2
2
|
import { realpath, stat, readFile } from 'fs/promises';
|
|
3
3
|
import { resolve, extname, relative, sep, isAbsolute } from 'path';
|
|
4
4
|
|
|
5
|
+
// src/hosting/browser-host.ts
|
|
6
|
+
|
|
7
|
+
// src/net/proxy-fetch.ts
|
|
8
|
+
function toBase64(bytes) {
|
|
9
|
+
let text = "";
|
|
10
|
+
for (let at = 0; at < bytes.length; at += 32768) {
|
|
11
|
+
text += String.fromCharCode(...bytes.subarray(at, at + 32768));
|
|
12
|
+
}
|
|
13
|
+
return btoa(text);
|
|
14
|
+
}
|
|
15
|
+
function fromBase64(text) {
|
|
16
|
+
const binary = atob(text);
|
|
17
|
+
const bytes = new Uint8Array(binary.length);
|
|
18
|
+
for (let at = 0; at < binary.length; at += 1) bytes[at] = binary.charCodeAt(at);
|
|
19
|
+
return bytes;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/net/discover-egress.ts
|
|
23
|
+
var EGRESS_PATH = "/__sandboxedjs__/egress";
|
|
24
|
+
var EGRESS_MARKER = { sandboxedjs: "egress", protocol: 1 };
|
|
25
|
+
|
|
26
|
+
// src/net/egress-handler.ts
|
|
27
|
+
var HOP = /* @__PURE__ */ new Set([
|
|
28
|
+
"connection",
|
|
29
|
+
"keep-alive",
|
|
30
|
+
"proxy-authenticate",
|
|
31
|
+
"proxy-authorization",
|
|
32
|
+
"te",
|
|
33
|
+
"trailer",
|
|
34
|
+
"transfer-encoding",
|
|
35
|
+
"upgrade",
|
|
36
|
+
"host",
|
|
37
|
+
"content-length"
|
|
38
|
+
]);
|
|
39
|
+
var HEADERS = {
|
|
40
|
+
"access-control-allow-origin": "*",
|
|
41
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
42
|
+
"access-control-allow-headers": "content-type",
|
|
43
|
+
"access-control-max-age": "86400",
|
|
44
|
+
"cross-origin-resource-policy": "cross-origin"
|
|
45
|
+
};
|
|
46
|
+
var refuse = (status, message) => new Response(message, { status, headers: { ...HEADERS, "content-type": "text/plain; charset=utf-8" } });
|
|
47
|
+
async function handleEgressRequest(request, options = {}) {
|
|
48
|
+
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: HEADERS });
|
|
49
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
50
|
+
return new Response(request.method === "HEAD" ? null : JSON.stringify(EGRESS_MARKER), {
|
|
51
|
+
headers: { ...HEADERS, "content-type": "application/json" }
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (request.method !== "POST") return refuse(405, "POST a JSON request here.");
|
|
55
|
+
let payload;
|
|
56
|
+
try {
|
|
57
|
+
payload = await request.json();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return refuse(400, `could not read the request: ${error instanceof Error ? error.message : String(error)}`);
|
|
60
|
+
}
|
|
61
|
+
let target;
|
|
62
|
+
try {
|
|
63
|
+
target = new URL(String(payload.url));
|
|
64
|
+
} catch {
|
|
65
|
+
return refuse(400, `not a URL: ${payload.url}`);
|
|
66
|
+
}
|
|
67
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
68
|
+
return refuse(400, `${target.protocol} is not a protocol this proxy speaks`);
|
|
69
|
+
}
|
|
70
|
+
const allow = options.allow;
|
|
71
|
+
if (allow && !allow.some((host) => target.hostname === host || target.hostname.endsWith(`.${host}`))) {
|
|
72
|
+
return refuse(403, `${target.hostname} is not in this proxy's allow list`);
|
|
73
|
+
}
|
|
74
|
+
const headers = {};
|
|
75
|
+
for (const [name, value] of Object.entries(payload.headers ?? {})) {
|
|
76
|
+
if (!HOP.has(name.toLowerCase())) headers[name] = value;
|
|
77
|
+
}
|
|
78
|
+
const perform = options.fetch ?? fetch;
|
|
79
|
+
let answer;
|
|
80
|
+
try {
|
|
81
|
+
answer = await perform(target.toString(), {
|
|
82
|
+
method: payload.method ?? "GET",
|
|
83
|
+
headers,
|
|
84
|
+
...payload.body ? { body: fromBase64(payload.body) } : {},
|
|
85
|
+
redirect: "follow"
|
|
86
|
+
});
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return refuse(502, `the request to ${target.hostname} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
89
|
+
}
|
|
90
|
+
const back = {};
|
|
91
|
+
answer.headers.forEach((value, name) => {
|
|
92
|
+
if (!HOP.has(name.toLowerCase())) back[name] = value;
|
|
93
|
+
});
|
|
94
|
+
const body = new Uint8Array(await answer.arrayBuffer());
|
|
95
|
+
return new Response(
|
|
96
|
+
JSON.stringify({ status: answer.status, statusText: answer.statusText, headers: back, body: toBase64(body) }),
|
|
97
|
+
{ headers: { ...HEADERS, "content-type": "application/json" } }
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
function egressNodeHandler(options = {}) {
|
|
101
|
+
return async (request, response) => {
|
|
102
|
+
const chunks = [];
|
|
103
|
+
await new Promise((done, fail) => {
|
|
104
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
105
|
+
request.on("end", () => done());
|
|
106
|
+
request.on("error", (error) => fail(error));
|
|
107
|
+
});
|
|
108
|
+
let total = 0;
|
|
109
|
+
for (const chunk of chunks) total += chunk.length;
|
|
110
|
+
const body = new Uint8Array(total);
|
|
111
|
+
let at = 0;
|
|
112
|
+
for (const chunk of chunks) {
|
|
113
|
+
body.set(chunk, at);
|
|
114
|
+
at += chunk.length;
|
|
115
|
+
}
|
|
116
|
+
const headers = {};
|
|
117
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
118
|
+
if (typeof value === "string") headers[name] = value;
|
|
119
|
+
}
|
|
120
|
+
const answer = await handleEgressRequest(
|
|
121
|
+
new Request("http://egress.invalid/", {
|
|
122
|
+
method: request.method ?? "GET",
|
|
123
|
+
headers,
|
|
124
|
+
...body.length && request.method !== "GET" && request.method !== "HEAD" ? { body } : {}
|
|
125
|
+
}),
|
|
126
|
+
options
|
|
127
|
+
);
|
|
128
|
+
const out = {};
|
|
129
|
+
answer.headers.forEach((value, name) => {
|
|
130
|
+
out[name] = value;
|
|
131
|
+
});
|
|
132
|
+
response.writeHead(answer.status, out);
|
|
133
|
+
response.end(Buffer.from(await answer.arrayBuffer()));
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
5
137
|
// src/hosting/browser-host.ts
|
|
6
138
|
var browserIsolationHeaders = Object.freeze({
|
|
7
139
|
"Cross-Origin-Opener-Policy": "same-origin",
|
|
@@ -31,7 +163,12 @@ async function serveBrowserApp(options) {
|
|
|
31
163
|
const rel = relative(root, path);
|
|
32
164
|
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
33
165
|
};
|
|
166
|
+
const egress = options.egress === false ? null : egressNodeHandler({ ...options.egress?.allow ? { allow: options.egress.allow } : {} });
|
|
34
167
|
const handle = async (req, res) => {
|
|
168
|
+
if (egress && (req.url ?? "").split("?")[0] === EGRESS_PATH) {
|
|
169
|
+
await egress(req, res);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
35
172
|
for (const [name, value] of Object.entries(browserIsolationHeaders)) res.setHeader(name, value);
|
|
36
173
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
37
174
|
res.setHeader("Cache-Control", "no-cache");
|
package/dist/egress.cjs
CHANGED
|
@@ -15,6 +15,10 @@ function fromBase64(text) {
|
|
|
15
15
|
return bytes;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// src/net/discover-egress.ts
|
|
19
|
+
var EGRESS_PATH = "/__sandboxedjs__/egress";
|
|
20
|
+
var EGRESS_MARKER = { sandboxedjs: "egress", protocol: 1 };
|
|
21
|
+
|
|
18
22
|
// src/net/egress-handler.ts
|
|
19
23
|
var HOP = /* @__PURE__ */ new Set([
|
|
20
24
|
"connection",
|
|
@@ -30,7 +34,7 @@ var HOP = /* @__PURE__ */ new Set([
|
|
|
30
34
|
]);
|
|
31
35
|
var HEADERS = {
|
|
32
36
|
"access-control-allow-origin": "*",
|
|
33
|
-
"access-control-allow-methods": "POST, OPTIONS",
|
|
37
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
34
38
|
"access-control-allow-headers": "content-type",
|
|
35
39
|
"access-control-max-age": "86400",
|
|
36
40
|
"cross-origin-resource-policy": "cross-origin"
|
|
@@ -38,6 +42,11 @@ var HEADERS = {
|
|
|
38
42
|
var refuse = (status, message) => new Response(message, { status, headers: { ...HEADERS, "content-type": "text/plain; charset=utf-8" } });
|
|
39
43
|
async function handleEgressRequest(request, options = {}) {
|
|
40
44
|
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: HEADERS });
|
|
45
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
46
|
+
return new Response(request.method === "HEAD" ? null : JSON.stringify(EGRESS_MARKER), {
|
|
47
|
+
headers: { ...HEADERS, "content-type": "application/json" }
|
|
48
|
+
});
|
|
49
|
+
}
|
|
41
50
|
if (request.method !== "POST") return refuse(405, "POST a JSON request here.");
|
|
42
51
|
let payload;
|
|
43
52
|
try {
|
|
@@ -121,6 +130,8 @@ function egressNodeHandler(options = {}) {
|
|
|
121
130
|
};
|
|
122
131
|
}
|
|
123
132
|
|
|
133
|
+
exports.EGRESS_MARKER = EGRESS_MARKER;
|
|
134
|
+
exports.EGRESS_PATH = EGRESS_PATH;
|
|
124
135
|
exports.egressNodeHandler = egressNodeHandler;
|
|
125
136
|
exports.handleEgressRequest = handleEgressRequest;
|
|
126
137
|
//# sourceMappingURL=egress.cjs.map
|
package/dist/egress.d.cts
CHANGED
|
@@ -48,4 +48,36 @@ interface NodeResponse {
|
|
|
48
48
|
*/
|
|
49
49
|
declare function egressNodeHandler(options?: EgressOptions): (request: NodeRequest, response: NodeResponse) => Promise<void>;
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Finding the proxy instead of being told where it is.
|
|
53
|
+
*
|
|
54
|
+
* A page cannot read a response from a host that does not send CORS headers,
|
|
55
|
+
* so a container in a browser needs a proxy to make its outbound requests --
|
|
56
|
+
* and until now every project had to say where that proxy was. That is a
|
|
57
|
+
* setting whose right value is almost always the same, and getting it wrong
|
|
58
|
+
* looks like the container being broken: the guest reports "Failed to fetch"
|
|
59
|
+
* from code that works everywhere else.
|
|
60
|
+
*
|
|
61
|
+
* So a browser container with no proxy configured looks for one before it
|
|
62
|
+
* gives up. A host that mounts `handleEgressRequest` at `EGRESS_PATH` on its
|
|
63
|
+
* own origin, or runs `sandboxedjs-egress` on its default port, is found
|
|
64
|
+
* without being named.
|
|
65
|
+
*
|
|
66
|
+
* A probe is a GET, which the handler answers with a marker. That matters more
|
|
67
|
+
* than it sounds: a single-page host answers every unknown path with its own
|
|
68
|
+
* index.html and status 200, so "the request succeeded" cannot mean "the proxy
|
|
69
|
+
* is here". Only the marker does.
|
|
70
|
+
*
|
|
71
|
+
* Discovery never overrides a proxy the host named, and finding nothing is not
|
|
72
|
+
* an error -- the request is then made directly, which is right on a server
|
|
73
|
+
* and is what produces the explanatory CORS failure in a browser.
|
|
74
|
+
*/
|
|
75
|
+
/** Where a host app mounts the proxy for its own containers to find. */
|
|
76
|
+
declare const EGRESS_PATH = "/__sandboxedjs__/egress";
|
|
77
|
+
/** What a GET to the proxy answers, so a probe can tell it from a 200 page. */
|
|
78
|
+
declare const EGRESS_MARKER: {
|
|
79
|
+
readonly sandboxedjs: "egress";
|
|
80
|
+
readonly protocol: 1;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export { EGRESS_MARKER, EGRESS_PATH, type EgressOptions, egressNodeHandler, handleEgressRequest };
|
package/dist/egress.d.ts
CHANGED
|
@@ -48,4 +48,36 @@ interface NodeResponse {
|
|
|
48
48
|
*/
|
|
49
49
|
declare function egressNodeHandler(options?: EgressOptions): (request: NodeRequest, response: NodeResponse) => Promise<void>;
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Finding the proxy instead of being told where it is.
|
|
53
|
+
*
|
|
54
|
+
* A page cannot read a response from a host that does not send CORS headers,
|
|
55
|
+
* so a container in a browser needs a proxy to make its outbound requests --
|
|
56
|
+
* and until now every project had to say where that proxy was. That is a
|
|
57
|
+
* setting whose right value is almost always the same, and getting it wrong
|
|
58
|
+
* looks like the container being broken: the guest reports "Failed to fetch"
|
|
59
|
+
* from code that works everywhere else.
|
|
60
|
+
*
|
|
61
|
+
* So a browser container with no proxy configured looks for one before it
|
|
62
|
+
* gives up. A host that mounts `handleEgressRequest` at `EGRESS_PATH` on its
|
|
63
|
+
* own origin, or runs `sandboxedjs-egress` on its default port, is found
|
|
64
|
+
* without being named.
|
|
65
|
+
*
|
|
66
|
+
* A probe is a GET, which the handler answers with a marker. That matters more
|
|
67
|
+
* than it sounds: a single-page host answers every unknown path with its own
|
|
68
|
+
* index.html and status 200, so "the request succeeded" cannot mean "the proxy
|
|
69
|
+
* is here". Only the marker does.
|
|
70
|
+
*
|
|
71
|
+
* Discovery never overrides a proxy the host named, and finding nothing is not
|
|
72
|
+
* an error -- the request is then made directly, which is right on a server
|
|
73
|
+
* and is what produces the explanatory CORS failure in a browser.
|
|
74
|
+
*/
|
|
75
|
+
/** Where a host app mounts the proxy for its own containers to find. */
|
|
76
|
+
declare const EGRESS_PATH = "/__sandboxedjs__/egress";
|
|
77
|
+
/** What a GET to the proxy answers, so a probe can tell it from a 200 page. */
|
|
78
|
+
declare const EGRESS_MARKER: {
|
|
79
|
+
readonly sandboxedjs: "egress";
|
|
80
|
+
readonly protocol: 1;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export { EGRESS_MARKER, EGRESS_PATH, type EgressOptions, egressNodeHandler, handleEgressRequest };
|