@landstrip/landstrip-api 0.18.42 → 0.19.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/README.md CHANGED
@@ -1,64 +1,49 @@
1
- # landstrip
1
+ # @landstrip/landstrip-api
2
2
 
3
- `landstrip` runs commands in an OS-level sandbox using Landlock on Linux,
4
- Seatbelt on macOS, and AppContainer or restricted users on Windows.
3
+ Node.js wrapper, native binaries, and TypeScript types for the Landstrip sandbox.
4
+ Requires Node.js 18 or newer; binaries are provided for Linux, macOS, and Windows
5
+ on x64 and arm64.
5
6
 
6
- ## Install
7
+ ## Install and run
7
8
 
8
9
  ```sh
9
10
  npm install --save-dev @landstrip/landstrip-api
10
11
  npx landstrip run -p policy.json -- cargo test
11
12
  ```
12
13
 
13
- The npm package installs a Node.js wrapper and a native binary for the current
14
- platform.
14
+ See the [quick start](../../README.md#quick-start) for an example policy and
15
+ [landstrip(1)](../landstrip/man/man1/landstrip.1) for CLI options, policy rules,
16
+ and platform limits.
15
17
 
16
- ## Usage
18
+ ## Node.js API
17
19
 
18
- ```sh
19
- landstrip run -p policy.json -- cargo test
20
- landstrip policy validate -p policy.json
21
- landstrip policy resolve -p policy.json
22
- landstrip doctor
23
- ```
24
-
25
- Run `landstrip --help` or see [landstrip(1)](../landstrip/man/man1/landstrip.1) for the full
26
- command reference.
27
-
28
- ## Agent extensions
20
+ ```js
21
+ const { execFileSync } = require('node:child_process');
22
+ const { binaryPath } = require('@landstrip/landstrip-api');
29
23
 
30
- | Integration | Package | Documentation |
31
- | ----------- | ---------------------- | -------------------------------------------------------------------- |
32
- | OpenCode | `opencode-landstrip` | [OpenCode](packages/opencode-landstrip/README.md) |
33
- | Pi | `pi-landstrip` | [Pi](packages/pi-landstrip/README.md) |
24
+ execFileSync(binaryPath(), ['run', '-p', 'policy.json', '--', 'cargo', 'test'], {
25
+ stdio: 'inherit',
26
+ });
27
+ ```
34
28
 
35
- ## Policy
29
+ `binaryPath()` returns the installed native binary's path and throws if the
30
+ platform is unsupported or its binary package is missing. `packageName()` returns
31
+ the platform's binary package name. Both accept optional `platform` and `arch`
32
+ arguments, defaulting to the current process.
36
33
 
37
- ```json
38
- {
39
- "filesystem": {
40
- "allowWrite": ["."],
41
- "denyWrite": ["**/.env", "**/*.pem"],
42
- "denyRead": ["~/.ssh"],
43
- "allowRead": ["~/.ssh/config"]
44
- },
45
- "network": {
46
- "allowNetwork": false,
47
- "allowLocalBinding": false
48
- }
49
- }
50
- ```
34
+ The package exports [`LandstripTrap` and `LandstripControlResponse`](lib/index.d.ts)
35
+ for structured events and Linux broker replies. Runtime traps and approval
36
+ queries are Linux-only; macOS's `--trap` reports setup and launch failures.
37
+ Descriptor lifetimes and the reply protocol are covered in the manual.
51
38
 
52
- Pass policies with `-p, --policy <FILE>`. Later policies merge over earlier
53
- ones. Platform behavior and limits are documented in the manual.
39
+ Integration helpers are exported through [`/shared`](lib/shared.d.ts) and
40
+ [`/proxy`](lib/proxy.d.ts).
54
41
 
55
42
  ## Development
56
43
 
57
- ```sh
58
- make ci
59
- ```
44
+ Run `make ci` from the repository root.
60
45
 
61
- ## Licensing
46
+ ## License
62
47
 
63
- `@landstrip/landstrip-api` is licensed under the Apache 2.0 license. See
64
- [LICENSE](LICENSE) for more information.
48
+ [Apache-2.0](LICENSE). The bundled native binary is
49
+ [LGPL-3.0-or-later](../landstrip/LICENSE).
package/lib/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export type LandstripMechanism =
10
10
  /**
11
11
  * `query` holds the syscall until the launcher answers with a
12
12
  * {@link LandstripControlResponse}; `info` is terminal. Queries need a socket on
13
- * `--trap-fd`, and only the Linux broker raises them.
13
+ * `--trap`, and only the Linux broker raises them.
14
14
  */
15
15
  export type LandstripTrapState = 'query' | 'info';
16
16
 
@@ -83,6 +83,7 @@ export type LandstripPolicyErrorCode =
83
83
  | 'POLICY_PARSE_FAILED'
84
84
  | 'POLICY_IO_FAILED'
85
85
  | 'POLICY_UNRESTRICTED_READ'
86
+ | 'POLICY_DENY_READ_ALWAYS_UNSUPPORTED'
86
87
  | 'POLICY_TCP_BIND_UNSUPPORTED'
87
88
  | 'POLICY_UNIX_SOCKET_UNSUPPORTED'
88
89
  | 'POLICY_UNIX_SOCKET_PATH'
@@ -118,7 +119,7 @@ export type LandstripInternalTrap =
118
119
 
119
120
  /**
120
121
  * One landstrip event. Failure and completed-denial events are written to
121
- * stderr; pending Linux query events are written only to `--trap-fd`.
122
+ * stderr; pending Linux query events are written only to `--trap`.
122
123
  */
123
124
  export type LandstripTrap =
124
125
  | LandstripFilesystemTrap
package/lib/proxy.d.ts CHANGED
@@ -19,6 +19,4 @@ export interface FilterProxyHandle {
19
19
  stop(): Promise<void>;
20
20
  }
21
21
 
22
- export function isPublicProxyAddress(address: string, family?: number): boolean;
23
-
24
22
  export function startFilterProxy(options: FilterProxyOptions): Promise<FilterProxyHandle>;
package/lib/proxy.js CHANGED
@@ -2,34 +2,11 @@
2
2
  'use strict';
3
3
 
4
4
  const { lookup } = require('node:dns/promises');
5
- const { BlockList, connect, createServer, isIP } = require('node:net');
6
-
7
- const prohibitedProxyAddresses = new BlockList();
8
-
9
- for (const [network, prefix] of [
10
- ['0.0.0.0', 8],
11
- ['10.0.0.0', 8],
12
- ['100.64.0.0', 10],
13
- ['127.0.0.0', 8],
14
- ['169.254.0.0', 16],
15
- ['172.16.0.0', 12],
16
- ['192.0.0.0', 24],
17
- ['192.168.0.0', 16],
18
- ['198.18.0.0', 15],
19
- ['224.0.0.0', 4],
20
- ['240.0.0.0', 4],
21
- ]) {
22
- prohibitedProxyAddresses.addSubnet(network, prefix, 'ipv4');
23
- }
24
- for (const [network, prefix] of [
25
- ['::', 128],
26
- ['::1', 128],
27
- ['fc00::', 7],
28
- ['fe80::', 10],
29
- ['ff00::', 8],
30
- ]) {
31
- prohibitedProxyAddresses.addSubnet(network, prefix, 'ipv6');
32
- }
5
+ const { Agent, createServer: createHttpServer, request: requestHttp } = require('node:http');
6
+ const { connect, isIP } = require('node:net');
7
+ const { pipeline } = require('node:stream');
8
+
9
+ const { canonicalizeHost } = require('./shared');
33
10
 
34
11
  function parseProxyPort(value, defaultPort) {
35
12
  const rawPort = value ?? String(defaultPort);
@@ -39,56 +16,58 @@ function parseProxyPort(value, defaultPort) {
39
16
  return port >= 1 && port <= 65535 ? port : null;
40
17
  }
41
18
 
42
- function splitHostPort(target, defaultPort) {
43
- const bracketMatch = target.match(/^\[([^\]]+)\](?::(.*))?$/);
44
- const host = bracketMatch?.[1];
45
- if (host) {
46
- const port = parseProxyPort(bracketMatch[2], defaultPort);
47
- return port === null ? null : { host, port };
19
+ function splitHostPort(target) {
20
+ if (/\s|[\u0000-\u001f\u007f]/.test(target) || /[/?#\\]/.test(target)) return null;
21
+
22
+ let url;
23
+ try {
24
+ url = new URL(`connect://${target}`);
25
+ } catch {
26
+ return null;
48
27
  }
49
28
 
50
- const lastColon = target.lastIndexOf(':');
51
- if (lastColon > -1 && target.indexOf(':') === lastColon) {
52
- const port = parseProxyPort(target.slice(lastColon + 1), defaultPort);
53
- return port === null ? null : { host: target.slice(0, lastColon), port };
29
+ if (url.username || url.password || url.pathname || url.search || url.hash || !url.port) {
30
+ return null;
54
31
  }
55
32
 
56
- return { host: target, port: defaultPort };
33
+ const host = canonicalizeHost(url.hostname);
34
+ const port = parseProxyPort(url.port, 0);
35
+ if (!host || port === null) return null;
36
+ return { host, port };
57
37
  }
58
38
 
59
- function denyProxyRequest(client, status = '403 Forbidden') {
60
- client.write(`HTTP/1.1 ${status}\r\nContent-Length: 0\r\n\r\n`);
39
+ const proxyAuthenticateChallenge = 'Basic realm="landstrip"';
40
+
41
+ function denyProxyRequest(client, status = '403 Forbidden', headers = {}) {
42
+ let response = `HTTP/1.1 ${status}\r\nContent-Length: 0\r\n`;
43
+ for (const [name, value] of Object.entries(headers)) response += `${name}: ${value}\r\n`;
44
+ client.write(`${response}\r\n`);
61
45
  client.end();
62
46
  }
63
47
 
64
- function isPublicProxyAddress(address, family = isIP(address)) {
65
- if (family === 4) return !prohibitedProxyAddresses.check(address, 'ipv4');
66
- if (family === 6) return !prohibitedProxyAddresses.check(address, 'ipv6');
67
- return false;
68
- }
48
+ async function resolveProxyEndpoints(host) {
49
+ const canonicalHost = canonicalizeHost(host);
50
+ if (!canonicalHost) throw new Error('Proxy destination has an invalid host');
69
51
 
70
- async function resolveProxyEndpoint(host) {
71
- const literalFamily = isIP(host);
52
+ const literalFamily = isIP(canonicalHost);
72
53
  if (literalFamily === 4 || literalFamily === 6) {
73
- if (!isPublicProxyAddress(host, literalFamily)) {
74
- throw new Error(`Proxy destination is not public: ${host}`);
75
- }
76
- return { address: host, family: literalFamily };
54
+ return [{ address: canonicalHost, family: literalFamily }];
77
55
  }
78
56
 
79
- const addresses = await lookup(host, { all: true, verbatim: true });
80
- if (
81
- addresses.length === 0 ||
82
- addresses.some(({ address, family }) => !isPublicProxyAddress(address, family))
83
- ) {
84
- throw new Error(`Proxy destination resolves to a non-public address: ${host}`);
57
+ const addresses = await lookup(canonicalHost, { all: true, verbatim: true });
58
+ const endpoints = [];
59
+ const seen = new Set();
60
+ for (const { address } of addresses) {
61
+ const canonicalAddress = canonicalizeHost(address);
62
+ const family = canonicalAddress ? isIP(canonicalAddress) : 0;
63
+ if ((family !== 4 && family !== 6) || seen.has(canonicalAddress)) continue;
64
+ seen.add(canonicalAddress);
65
+ endpoints.push({ address: canonicalAddress, family });
85
66
  }
86
-
87
- const endpoint = addresses[0];
88
- if (endpoint.family !== 4 && endpoint.family !== 6) {
89
- throw new Error(`Proxy could not resolve destination: ${host}`);
67
+ if (endpoints.length === 0) {
68
+ throw new Error(`Proxy could not resolve destination: ${canonicalHost}`);
90
69
  }
91
- return { address: endpoint.address, family: endpoint.family };
70
+ return endpoints;
92
71
  }
93
72
 
94
73
  function pipeSockets(client, upstream, initialData) {
@@ -104,166 +83,231 @@ function pipeSockets(client, upstream, initialData) {
104
83
  function startFilterProxy(options) {
105
84
  const { isDomainAllowed, authorization, portRange } = options;
106
85
  const sockets = new Set();
86
+ const hopByHopHeaders = [
87
+ 'connection',
88
+ 'keep-alive',
89
+ 'proxy-authenticate',
90
+ 'proxy-authorization',
91
+ 'proxy-connection',
92
+ 'te',
93
+ 'trailer',
94
+ 'transfer-encoding',
95
+ 'upgrade',
96
+ ];
97
+
98
+ function trackSocket(socket) {
99
+ sockets.add(socket);
100
+ socket.once('close', () => sockets.delete(socket));
101
+ }
107
102
 
108
- // Track the upstream socket so stop() tears it down, and abandon a still-
109
- // connecting upstream when its client goes away — otherwise a connect to a
110
- // black-holed host lingers in SYN-retry (~2 min), leaking an fd per request.
111
- function trackUpstream(upstream, client, settled) {
112
- sockets.add(upstream);
113
- upstream.once('close', () => sockets.delete(upstream));
114
- client.once('close', () => {
115
- if (!settled()) upstream.destroy();
116
- });
103
+ async function connectProxyEndpoints(endpoints, port, client) {
104
+ let lastError;
105
+ for (const endpoint of endpoints) {
106
+ if (stopped || client.destroyed) throw new Error('Proxy connection was cancelled');
107
+
108
+ try {
109
+ return await new Promise((resolve, reject) => {
110
+ const upstream = connect({ host: endpoint.address, port, family: endpoint.family });
111
+ let connected = false;
112
+ const destroyUpstream = () => upstream.destroy();
113
+ trackSocket(upstream);
114
+ client.once('close', destroyUpstream);
115
+ upstream.once('connect', () => {
116
+ connected = true;
117
+ resolve(upstream);
118
+ });
119
+ upstream.once('error', reject);
120
+ upstream.once('close', () => {
121
+ client.off('close', destroyUpstream);
122
+ if (!connected) reject(new Error('Proxy connection closed'));
123
+ });
124
+ });
125
+ } catch (error) {
126
+ lastError = error;
127
+ }
128
+ }
129
+ throw lastError ?? new Error('Proxy could not connect to destination');
130
+ }
131
+
132
+ function isAuthorized(headers) {
133
+ return !authorization || headers['proxy-authorization'] === authorization;
134
+ }
135
+
136
+ function stripHopByHopHeaders(headers) {
137
+ const forwarded = { ...headers };
138
+ const connection = forwarded.connection;
139
+ const connectionValue = Array.isArray(connection) ? connection.join(',') : connection;
140
+ for (const name of connectionValue?.split(',') ?? []) {
141
+ delete forwarded[name.trim().toLowerCase()];
142
+ }
143
+ for (const name of hopByHopHeaders) delete forwarded[name];
144
+ return forwarded;
145
+ }
146
+
147
+ function denyHttpRequest(response, statusCode, statusMessage, headers = {}) {
148
+ if (!response.headersSent) {
149
+ response.writeHead(statusCode, statusMessage, {
150
+ Connection: 'close',
151
+ 'Content-Length': '0',
152
+ ...headers,
153
+ });
154
+ }
155
+ response.end();
117
156
  }
118
157
 
119
158
  async function handleConnect(client, target, rest) {
120
- const endpoint = splitHostPort(target, 443);
159
+ const endpoint = splitHostPort(target);
121
160
  if (!endpoint) {
122
161
  denyProxyRequest(client, '400 Bad Request');
123
162
  return;
124
163
  }
125
164
 
126
165
  if (!(await isDomainAllowed(endpoint.host))) {
127
- denyProxyRequest(client);
166
+ if (!client.destroyed) denyProxyRequest(client);
128
167
  return;
129
168
  }
169
+ if (stopped || client.destroyed) return;
130
170
 
131
- const resolved = await resolveProxyEndpoint(endpoint.host);
132
- let settled = false;
133
- const upstream = connect(
134
- { host: resolved.address, port: endpoint.port, family: resolved.family },
135
- () => {
136
- settled = true;
137
- client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
138
- pipeSockets(client, upstream, rest);
139
- },
140
- );
141
- trackUpstream(upstream, client, () => settled);
142
- upstream.once('error', () => {
143
- if (settled) return;
144
- settled = true;
145
- denyProxyRequest(client, '502 Bad Gateway');
146
- });
147
- }
171
+ const endpoints = await resolveProxyEndpoints(endpoint.host);
172
+ if (stopped || client.destroyed) return;
148
173
 
149
- async function handleHttp(client, headerText, rest) {
150
- const lines = headerText.split(/\r?\n/);
151
- const requestLine = lines[0];
152
- if (!requestLine) {
153
- denyProxyRequest(client, '400 Bad Request');
174
+ let upstreamSocket;
175
+ try {
176
+ upstreamSocket = await connectProxyEndpoints(endpoints, endpoint.port, client);
177
+ } catch {
178
+ if (!stopped && !client.destroyed) denyProxyRequest(client, '502 Bad Gateway');
179
+ return;
180
+ }
181
+ if (stopped || client.destroyed) {
182
+ upstreamSocket.destroy();
154
183
  return;
155
184
  }
156
185
 
157
- const [method, rawTarget, version] = requestLine.split(' ');
158
- if (!method || !rawTarget || !version) {
159
- denyProxyRequest(client, '400 Bad Request');
186
+ client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
187
+ pipeSockets(client, upstreamSocket, rest);
188
+ }
189
+
190
+ async function handleHttp(clientRequest, clientResponse) {
191
+ if (!isAuthorized(clientRequest.headers)) {
192
+ denyHttpRequest(clientResponse, 407, 'Proxy Authentication Required', {
193
+ 'Proxy-Authenticate': proxyAuthenticateChallenge,
194
+ });
160
195
  return;
161
196
  }
162
197
 
163
198
  let url;
164
199
  try {
165
- url = new URL(rawTarget);
200
+ url = new URL(clientRequest.url);
166
201
  } catch {
167
- const host = lines
168
- .find((line) => line.toLowerCase().startsWith('host:'))
169
- ?.slice(5)
170
- .trim();
171
- if (!host) {
172
- denyProxyRequest(client, '400 Bad Request');
202
+ const authority = clientRequest.headers.host;
203
+ if (!authority) {
204
+ denyHttpRequest(clientResponse, 400, 'Bad Request');
173
205
  return;
174
206
  }
175
207
 
176
208
  try {
177
- url = new URL(`http://${host}${rawTarget}`);
209
+ url = new URL(`http://${authority}${clientRequest.url}`);
178
210
  } catch {
179
- denyProxyRequest(client, '400 Bad Request');
211
+ denyHttpRequest(clientResponse, 400, 'Bad Request');
180
212
  return;
181
213
  }
182
214
  }
183
215
 
184
- if (!(await isDomainAllowed(url.hostname))) {
185
- denyProxyRequest(client);
216
+ if (url.protocol !== 'http:' || url.username || url.password) {
217
+ denyHttpRequest(clientResponse, 400, 'Bad Request');
218
+ return;
219
+ }
220
+
221
+ const host = canonicalizeHost(url.hostname);
222
+ if (!host) {
223
+ denyHttpRequest(clientResponse, 400, 'Bad Request');
224
+ return;
225
+ }
226
+ if (!(await isDomainAllowed(host))) {
227
+ if (!clientResponse.destroyed) denyHttpRequest(clientResponse, 403, 'Forbidden');
186
228
  return;
187
229
  }
230
+ const clientSocket = clientRequest.socket;
231
+ if (stopped || clientSocket.destroyed || clientResponse.destroyed) return;
188
232
 
189
- const port = parseProxyPort(url.port || undefined, url.protocol === 'https:' ? 443 : 80);
233
+ const port = parseProxyPort(url.port || undefined, 80);
190
234
  if (port === null) {
191
- denyProxyRequest(client, '400 Bad Request');
235
+ denyHttpRequest(clientResponse, 400, 'Bad Request');
192
236
  return;
193
237
  }
194
238
 
195
- const path = `${url.pathname}${url.search}` || '/';
196
- lines[0] = `${method} ${path} ${version}`;
197
-
198
- const rewrittenHeader = lines
199
- .filter(
200
- (line) =>
201
- !line.toLowerCase().startsWith('proxy-connection:') &&
202
- !line.toLowerCase().startsWith('proxy-authorization:'),
203
- )
204
- .map((line) => (line.toLowerCase().startsWith('host:') ? `Host: ${url.host}` : line))
205
- .join('\r\n');
206
- const resolved = await resolveProxyEndpoint(url.hostname);
207
- let settled = false;
208
- const upstream = connect({ host: resolved.address, port, family: resolved.family }, () => {
209
- settled = true;
210
- upstream.write(`${rewrittenHeader}\r\n\r\n`);
211
- pipeSockets(client, upstream, rest);
212
- });
213
- trackUpstream(upstream, client, () => settled);
214
- upstream.once('error', () => {
215
- if (settled) return;
216
- settled = true;
217
- denyProxyRequest(client, '502 Bad Gateway');
218
- });
219
- }
239
+ const endpoints = await resolveProxyEndpoints(host);
240
+ if (stopped || clientSocket.destroyed || clientResponse.destroyed) return;
220
241
 
221
- function handleClient(client) {
222
- sockets.add(client);
223
- client.on('close', () => sockets.delete(client));
224
- client.on('error', () => sockets.delete(client));
225
-
226
- let buffered = Buffer.alloc(0);
227
-
228
- client.on('data', (chunk) => {
229
- buffered = Buffer.concat([buffered, chunk]);
230
- const headerEnd = buffered.indexOf('\r\n\r\n');
231
- if (headerEnd === -1) {
232
- if (buffered.length > 65536) {
233
- client.removeAllListeners('data');
234
- client.pause();
235
- denyProxyRequest(client, '431 Request Header Fields Too Large');
236
- }
237
- return;
238
- }
242
+ const forwardedHeaders = stripHopByHopHeaders(clientRequest.headers);
243
+ forwardedHeaders.host = url.host;
239
244
 
240
- client.pause();
241
- client.removeAllListeners('data');
242
-
243
- const header = buffered.subarray(0, headerEnd).toString('utf-8');
244
- const rest = buffered.subarray(headerEnd + 4);
245
- if (authorization) {
246
- const supplied = header
247
- .split(/\r?\n/)
248
- .find((line) => line.toLowerCase().startsWith('proxy-authorization:'))
249
- ?.slice('proxy-authorization:'.length)
250
- .trim();
251
- if (supplied !== authorization) {
252
- denyProxyRequest(client, '407 Proxy Authentication Required');
253
- return;
254
- }
245
+ const agent = new Agent();
246
+ agent.createConnection = (_options, callback) => {
247
+ connectProxyEndpoints(endpoints, port, clientSocket).then(
248
+ (socket) => callback(null, socket),
249
+ (error) => callback(error),
250
+ );
251
+ };
252
+ const upstreamRequest = requestHttp({
253
+ host,
254
+ port,
255
+ method: clientRequest.method,
256
+ path: `${url.pathname}${url.search}` || '/',
257
+ headers: forwardedHeaders,
258
+ agent,
259
+ });
260
+ upstreamRequest.once('response', (upstreamResponse) => {
261
+ clientResponse.writeHead(
262
+ upstreamResponse.statusCode ?? 502,
263
+ upstreamResponse.statusMessage,
264
+ stripHopByHopHeaders(upstreamResponse.headers),
265
+ );
266
+ pipeline(upstreamResponse, clientResponse, (error) => {
267
+ if (error) upstreamRequest.destroy();
268
+ });
269
+ });
270
+ upstreamRequest.once('error', () => {
271
+ if (!clientResponse.headersSent) {
272
+ denyHttpRequest(clientResponse, 502, 'Bad Gateway');
273
+ } else {
274
+ clientResponse.destroy();
255
275
  }
256
- const [method, target] = header.split(/\r?\n/, 1)[0].split(' ');
257
-
258
- const task =
259
- method?.toUpperCase() === 'CONNECT' && target
260
- ? handleConnect(client, target, rest)
261
- : handleHttp(client, header, rest);
262
- task.catch(() => denyProxyRequest(client, '502 Bad Gateway'));
263
276
  });
277
+ clientRequest.once('aborted', () => upstreamRequest.destroy());
278
+ clientRequest.once('error', () => upstreamRequest.destroy());
279
+ clientResponse.once('error', () => upstreamRequest.destroy());
280
+ clientResponse.once('close', () => {
281
+ clientRequest.unpipe(upstreamRequest);
282
+ upstreamRequest.destroy();
283
+ agent.destroy();
284
+ });
285
+ clientRequest.pipe(upstreamRequest);
264
286
  }
265
287
 
266
- const server = createServer(handleClient);
288
+ const server = createHttpServer({ maxHeaderSize: 65536 }, (request, response) => {
289
+ handleHttp(request, response).catch(() => denyHttpRequest(response, 502, 'Bad Gateway'));
290
+ });
291
+ server.on('connection', trackSocket);
292
+ server.on('connect', (request, client, head) => {
293
+ if (!isAuthorized(request.headers)) {
294
+ denyProxyRequest(client, '407 Proxy Authentication Required', {
295
+ 'Proxy-Authenticate': proxyAuthenticateChallenge,
296
+ });
297
+ return;
298
+ }
299
+ handleConnect(client, request.url, head).catch(() =>
300
+ denyProxyRequest(client, '502 Bad Gateway'),
301
+ );
302
+ });
303
+ server.on('clientError', (error, client) => {
304
+ const status =
305
+ error.code === 'HPE_HEADER_OVERFLOW'
306
+ ? '431 Request Header Fields Too Large'
307
+ : '400 Bad Request';
308
+ denyProxyRequest(client, status);
309
+ });
310
+
267
311
  let stopped = false;
268
312
 
269
313
  return new Promise((resolve, reject) => {
@@ -299,6 +343,5 @@ function startFilterProxy(options) {
299
343
  }
300
344
 
301
345
  module.exports = {
302
- isPublicProxyAddress,
303
346
  startFilterProxy,
304
347
  };
package/lib/shared.d.ts CHANGED
@@ -29,6 +29,8 @@ export function sessionAllows(prefixes: Set<string>, filePath: string): boolean;
29
29
 
30
30
  export function sessionScopeFor(filePath: string, baseDirectory: string): string;
31
31
 
32
+ export function canonicalizeHost(host: string): string | null;
33
+
32
34
  export function domainMatchesPattern(domain: string, pattern: string): boolean;
33
35
 
34
36
  export function domainMatchesAny(domain: string, patterns: string[]): boolean;
package/lib/shared.js CHANGED
@@ -4,6 +4,9 @@
4
4
  const { lstatSync, readlinkSync, realpathSync } = require('node:fs');
5
5
  const { homedir } = require('node:os');
6
6
  const { basename, dirname, isAbsolute, join, relative, resolve, sep } = require('node:path');
7
+ const { domainToASCII } = require('node:url');
8
+
9
+ const ipaddr = require('ipaddr.js');
7
10
 
8
11
  function isRecord(value) {
9
12
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -64,18 +67,43 @@ function sessionScopeFor(filePath, baseDirectory) {
64
67
  return dir;
65
68
  }
66
69
 
67
- function domainMatchesPattern(domain, pattern) {
70
+ function canonicalizeHost(host) {
71
+ if (/[\s\p{Cc}/\\@?#]/u.test(host)) return null;
72
+
73
+ const bracketed = host.startsWith('[');
74
+ if (bracketed !== host.endsWith(']')) return null;
75
+ const value = bracketed ? host.slice(1, -1) : host;
76
+
77
+ if (ipaddr.isValid(value)) {
78
+ if (bracketed && ipaddr.parse(value).kind() !== 'ipv6') return null;
79
+ return ipaddr.process(value).toString();
80
+ }
81
+ if (bracketed || /[:%]/.test(value)) return null;
82
+
68
83
  // A trailing dot ("pastebin.com.") is the same host to DNS but would slip
69
- // past a literal deny entry; strip it from both sides before matching.
70
- const normalizedDomain = domain.toLowerCase().replace(/\.+$/, '');
71
- const normalizedPattern = pattern.toLowerCase().replace(/\.+$/, '');
72
-
73
- if (normalizedPattern === '*') return true;
74
- if (normalizedPattern.startsWith('*.')) {
75
- const base = normalizedPattern.slice(2);
76
- return normalizedDomain === base || normalizedDomain.endsWith(`.${base}`);
84
+ // past a literal deny entry; strip a single trailing dot and reject the rest.
85
+ const ascii = domainToASCII(value).replace(/\.$/, '');
86
+ if (!ascii || ascii.endsWith('.')) return null;
87
+
88
+ try {
89
+ const parsed = new URL(`http://${ascii}/`);
90
+ return parsed.hostname === ascii.toLowerCase() ? parsed.hostname : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function domainMatchesPattern(domain, pattern) {
97
+ const normalizedDomain = canonicalizeHost(domain);
98
+ if (!normalizedDomain) return false;
99
+ if (pattern === '*') return true;
100
+
101
+ if (pattern.startsWith('*.')) {
102
+ const base = canonicalizeHost(pattern.slice(2));
103
+ return base !== null && (normalizedDomain === base || normalizedDomain.endsWith(`.${base}`));
77
104
  }
78
105
 
106
+ const normalizedPattern = canonicalizeHost(pattern);
79
107
  return normalizedDomain === normalizedPattern;
80
108
  }
81
109
 
@@ -330,6 +358,7 @@ module.exports = {
330
358
  pathUnderDirectory,
331
359
  sessionAllows,
332
360
  sessionScopeFor,
361
+ canonicalizeHost,
333
362
  domainMatchesPattern,
334
363
  domainMatchesAny,
335
364
  allowsAllDomains,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@landstrip/landstrip-api",
3
- "version": "0.18.42",
3
+ "version": "0.19.0",
4
4
  "description": "Sandbox runner using Landlock, Seatbelt, and AppContainer",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/landstrip/landstrip#readme",
@@ -36,13 +36,16 @@
36
36
  "README.md",
37
37
  "LICENSE"
38
38
  ],
39
+ "dependencies": {
40
+ "ipaddr.js": "^2.5.0"
41
+ },
39
42
  "optionalDependencies": {
40
- "@landstrip/landstrip-darwin-arm64": "0.18.42",
41
- "@landstrip/landstrip-darwin-x64": "0.18.42",
42
- "@landstrip/landstrip-linux-x64": "0.18.42",
43
- "@landstrip/landstrip-linux-arm64": "0.18.42",
44
- "@landstrip/landstrip-win32-x64": "0.18.42",
45
- "@landstrip/landstrip-win32-arm64": "0.18.42"
43
+ "@landstrip/landstrip-darwin-arm64": "0.19.0",
44
+ "@landstrip/landstrip-darwin-x64": "0.19.0",
45
+ "@landstrip/landstrip-linux-x64": "0.19.0",
46
+ "@landstrip/landstrip-linux-arm64": "0.19.0",
47
+ "@landstrip/landstrip-win32-x64": "0.19.0",
48
+ "@landstrip/landstrip-win32-arm64": "0.19.0"
46
49
  },
47
50
  "publishConfig": {
48
51
  "access": "public"