sandboxedjs 0.2.5 → 0.2.7

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.
@@ -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");
@@ -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;
@@ -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;
@@ -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
- export { type EgressOptions, egressNodeHandler, handleEgressRequest };
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
- export { type EgressOptions, egressNodeHandler, handleEgressRequest };
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.js CHANGED
@@ -13,6 +13,10 @@ function fromBase64(text) {
13
13
  return bytes;
14
14
  }
15
15
 
16
+ // src/net/discover-egress.ts
17
+ var EGRESS_PATH = "/__sandboxedjs__/egress";
18
+ var EGRESS_MARKER = { sandboxedjs: "egress", protocol: 1 };
19
+
16
20
  // src/net/egress-handler.ts
17
21
  var HOP = /* @__PURE__ */ new Set([
18
22
  "connection",
@@ -28,7 +32,7 @@ var HOP = /* @__PURE__ */ new Set([
28
32
  ]);
29
33
  var HEADERS = {
30
34
  "access-control-allow-origin": "*",
31
- "access-control-allow-methods": "POST, OPTIONS",
35
+ "access-control-allow-methods": "GET, POST, OPTIONS",
32
36
  "access-control-allow-headers": "content-type",
33
37
  "access-control-max-age": "86400",
34
38
  "cross-origin-resource-policy": "cross-origin"
@@ -36,6 +40,11 @@ var HEADERS = {
36
40
  var refuse = (status, message) => new Response(message, { status, headers: { ...HEADERS, "content-type": "text/plain; charset=utf-8" } });
37
41
  async function handleEgressRequest(request, options = {}) {
38
42
  if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: HEADERS });
43
+ if (request.method === "GET" || request.method === "HEAD") {
44
+ return new Response(request.method === "HEAD" ? null : JSON.stringify(EGRESS_MARKER), {
45
+ headers: { ...HEADERS, "content-type": "application/json" }
46
+ });
47
+ }
39
48
  if (request.method !== "POST") return refuse(405, "POST a JSON request here.");
40
49
  let payload;
41
50
  try {
@@ -119,6 +128,6 @@ function egressNodeHandler(options = {}) {
119
128
  };
120
129
  }
121
130
 
122
- export { egressNodeHandler, handleEgressRequest };
131
+ export { EGRESS_MARKER, EGRESS_PATH, egressNodeHandler, handleEgressRequest };
123
132
  //# sourceMappingURL=egress.js.map
124
133
  //# sourceMappingURL=egress.js.map