@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 +30 -45
- package/lib/index.d.ts +3 -2
- package/lib/proxy.d.ts +0 -2
- package/lib/proxy.js +222 -179
- package/lib/shared.d.ts +2 -0
- package/lib/shared.js +38 -9
- package/package.json +10 -7
package/README.md
CHANGED
|
@@ -1,64 +1,49 @@
|
|
|
1
|
-
# landstrip
|
|
1
|
+
# @landstrip/landstrip-api
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
##
|
|
18
|
+
## Node.js API
|
|
17
19
|
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
24
|
+
execFileSync(binaryPath(), ['run', '-p', 'policy.json', '--', 'cargo', 'test'], {
|
|
25
|
+
stdio: 'inherit',
|
|
26
|
+
});
|
|
27
|
+
```
|
|
34
28
|
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
58
|
-
make ci
|
|
59
|
-
```
|
|
44
|
+
Run `make ci` from the repository root.
|
|
60
45
|
|
|
61
|
-
##
|
|
46
|
+
## License
|
|
62
47
|
|
|
63
|
-
|
|
64
|
-
[
|
|
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
|
|
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
|
|
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
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 {
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
if (
|
|
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
|
-
|
|
71
|
-
const literalFamily = isIP(host);
|
|
52
|
+
const literalFamily = isIP(canonicalHost);
|
|
72
53
|
if (literalFamily === 4 || literalFamily === 6) {
|
|
73
|
-
|
|
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(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
|
132
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
denyProxyRequest(client, '
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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(
|
|
200
|
+
url = new URL(clientRequest.url);
|
|
166
201
|
} catch {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
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://${
|
|
209
|
+
url = new URL(`http://${authority}${clientRequest.url}`);
|
|
178
210
|
} catch {
|
|
179
|
-
|
|
211
|
+
denyHttpRequest(clientResponse, 400, 'Bad Request');
|
|
180
212
|
return;
|
|
181
213
|
}
|
|
182
214
|
}
|
|
183
215
|
|
|
184
|
-
if (
|
|
185
|
-
|
|
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,
|
|
233
|
+
const port = parseProxyPort(url.port || undefined, 80);
|
|
190
234
|
if (port === null) {
|
|
191
|
-
|
|
235
|
+
denyHttpRequest(clientResponse, 400, 'Bad Request');
|
|
192
236
|
return;
|
|
193
237
|
}
|
|
194
238
|
|
|
195
|
-
const
|
|
196
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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 =
|
|
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
|
|
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
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
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.
|
|
41
|
-
"@landstrip/landstrip-darwin-x64": "0.
|
|
42
|
-
"@landstrip/landstrip-linux-x64": "0.
|
|
43
|
-
"@landstrip/landstrip-linux-arm64": "0.
|
|
44
|
-
"@landstrip/landstrip-win32-x64": "0.
|
|
45
|
-
"@landstrip/landstrip-win32-arm64": "0.
|
|
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"
|