@landstrip/landstrip-api 0.18.42 → 0.18.43
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/lib/proxy.d.ts +0 -2
- package/lib/proxy.js +215 -179
- package/lib/shared.d.ts +2 -0
- package/lib/shared.js +38 -9
- package/package.json +10 -7
package/lib/proxy.d.ts
CHANGED
package/lib/proxy.js
CHANGED
|
@@ -2,34 +2,10 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const { lookup } = require('node:dns/promises');
|
|
5
|
-
const {
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
|
|
8
|
+
const { canonicalizeHost } = require('./shared');
|
|
33
9
|
|
|
34
10
|
function parseProxyPort(value, defaultPort) {
|
|
35
11
|
const rawPort = value ?? String(defaultPort);
|
|
@@ -39,56 +15,58 @@ function parseProxyPort(value, defaultPort) {
|
|
|
39
15
|
return port >= 1 && port <= 65535 ? port : null;
|
|
40
16
|
}
|
|
41
17
|
|
|
42
|
-
function splitHostPort(target
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
18
|
+
function splitHostPort(target) {
|
|
19
|
+
if (/\s|[\u0000-\u001f\u007f]/.test(target) || /[/?#\\]/.test(target)) return null;
|
|
20
|
+
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(`connect://${target}`);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
48
26
|
}
|
|
49
27
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const port = parseProxyPort(target.slice(lastColon + 1), defaultPort);
|
|
53
|
-
return port === null ? null : { host: target.slice(0, lastColon), port };
|
|
28
|
+
if (url.username || url.password || url.pathname || url.search || url.hash || !url.port) {
|
|
29
|
+
return null;
|
|
54
30
|
}
|
|
55
31
|
|
|
56
|
-
|
|
32
|
+
const host = canonicalizeHost(url.hostname);
|
|
33
|
+
const port = parseProxyPort(url.port, 0);
|
|
34
|
+
if (!host || port === null) return null;
|
|
35
|
+
return { host, port };
|
|
57
36
|
}
|
|
58
37
|
|
|
59
|
-
|
|
60
|
-
|
|
38
|
+
const proxyAuthenticateChallenge = 'Basic realm="landstrip"';
|
|
39
|
+
|
|
40
|
+
function denyProxyRequest(client, status = '403 Forbidden', headers = {}) {
|
|
41
|
+
let response = `HTTP/1.1 ${status}\r\nContent-Length: 0\r\n`;
|
|
42
|
+
for (const [name, value] of Object.entries(headers)) response += `${name}: ${value}\r\n`;
|
|
43
|
+
client.write(`${response}\r\n`);
|
|
61
44
|
client.end();
|
|
62
45
|
}
|
|
63
46
|
|
|
64
|
-
function
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
47
|
+
async function resolveProxyEndpoints(host) {
|
|
48
|
+
const canonicalHost = canonicalizeHost(host);
|
|
49
|
+
if (!canonicalHost) throw new Error('Proxy destination has an invalid host');
|
|
69
50
|
|
|
70
|
-
|
|
71
|
-
const literalFamily = isIP(host);
|
|
51
|
+
const literalFamily = isIP(canonicalHost);
|
|
72
52
|
if (literalFamily === 4 || literalFamily === 6) {
|
|
73
|
-
|
|
74
|
-
throw new Error(`Proxy destination is not public: ${host}`);
|
|
75
|
-
}
|
|
76
|
-
return { address: host, family: literalFamily };
|
|
53
|
+
return [{ address: canonicalHost, family: literalFamily }];
|
|
77
54
|
}
|
|
78
55
|
|
|
79
|
-
const addresses = await lookup(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
56
|
+
const addresses = await lookup(canonicalHost, { all: true, verbatim: true });
|
|
57
|
+
const endpoints = [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
for (const { address } of addresses) {
|
|
60
|
+
const canonicalAddress = canonicalizeHost(address);
|
|
61
|
+
const family = canonicalAddress ? isIP(canonicalAddress) : 0;
|
|
62
|
+
if ((family !== 4 && family !== 6) || seen.has(canonicalAddress)) continue;
|
|
63
|
+
seen.add(canonicalAddress);
|
|
64
|
+
endpoints.push({ address: canonicalAddress, family });
|
|
85
65
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (endpoint.family !== 4 && endpoint.family !== 6) {
|
|
89
|
-
throw new Error(`Proxy could not resolve destination: ${host}`);
|
|
66
|
+
if (endpoints.length === 0) {
|
|
67
|
+
throw new Error(`Proxy could not resolve destination: ${canonicalHost}`);
|
|
90
68
|
}
|
|
91
|
-
return
|
|
69
|
+
return endpoints;
|
|
92
70
|
}
|
|
93
71
|
|
|
94
72
|
function pipeSockets(client, upstream, initialData) {
|
|
@@ -104,166 +82,225 @@ function pipeSockets(client, upstream, initialData) {
|
|
|
104
82
|
function startFilterProxy(options) {
|
|
105
83
|
const { isDomainAllowed, authorization, portRange } = options;
|
|
106
84
|
const sockets = new Set();
|
|
85
|
+
const hopByHopHeaders = [
|
|
86
|
+
'connection',
|
|
87
|
+
'keep-alive',
|
|
88
|
+
'proxy-authenticate',
|
|
89
|
+
'proxy-authorization',
|
|
90
|
+
'proxy-connection',
|
|
91
|
+
'te',
|
|
92
|
+
'trailer',
|
|
93
|
+
'transfer-encoding',
|
|
94
|
+
'upgrade',
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
function trackSocket(socket) {
|
|
98
|
+
sockets.add(socket);
|
|
99
|
+
socket.once('close', () => sockets.delete(socket));
|
|
100
|
+
}
|
|
107
101
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
102
|
+
async function connectProxyEndpoints(endpoints, port, client) {
|
|
103
|
+
let lastError;
|
|
104
|
+
for (const endpoint of endpoints) {
|
|
105
|
+
if (stopped || client.destroyed) throw new Error('Proxy connection was cancelled');
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
return await new Promise((resolve, reject) => {
|
|
109
|
+
const upstream = connect({ host: endpoint.address, port, family: endpoint.family });
|
|
110
|
+
let connected = false;
|
|
111
|
+
const destroyUpstream = () => upstream.destroy();
|
|
112
|
+
trackSocket(upstream);
|
|
113
|
+
client.once('close', destroyUpstream);
|
|
114
|
+
upstream.once('connect', () => {
|
|
115
|
+
connected = true;
|
|
116
|
+
resolve(upstream);
|
|
117
|
+
});
|
|
118
|
+
upstream.once('error', reject);
|
|
119
|
+
upstream.once('close', () => {
|
|
120
|
+
client.off('close', destroyUpstream);
|
|
121
|
+
if (!connected) reject(new Error('Proxy connection closed'));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
lastError = error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
throw lastError ?? new Error('Proxy could not connect to destination');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isAuthorized(headers) {
|
|
132
|
+
return !authorization || headers['proxy-authorization'] === authorization;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function stripHopByHopHeaders(headers) {
|
|
136
|
+
const forwarded = { ...headers };
|
|
137
|
+
const connection = forwarded.connection;
|
|
138
|
+
const connectionValue = Array.isArray(connection) ? connection.join(',') : connection;
|
|
139
|
+
for (const name of connectionValue?.split(',') ?? []) {
|
|
140
|
+
delete forwarded[name.trim().toLowerCase()];
|
|
141
|
+
}
|
|
142
|
+
for (const name of hopByHopHeaders) delete forwarded[name];
|
|
143
|
+
return forwarded;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function denyHttpRequest(response, statusCode, statusMessage, headers = {}) {
|
|
147
|
+
if (!response.headersSent) {
|
|
148
|
+
response.writeHead(statusCode, statusMessage, {
|
|
149
|
+
Connection: 'close',
|
|
150
|
+
'Content-Length': '0',
|
|
151
|
+
...headers,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
response.end();
|
|
117
155
|
}
|
|
118
156
|
|
|
119
157
|
async function handleConnect(client, target, rest) {
|
|
120
|
-
const endpoint = splitHostPort(target
|
|
158
|
+
const endpoint = splitHostPort(target);
|
|
121
159
|
if (!endpoint) {
|
|
122
160
|
denyProxyRequest(client, '400 Bad Request');
|
|
123
161
|
return;
|
|
124
162
|
}
|
|
125
163
|
|
|
126
164
|
if (!(await isDomainAllowed(endpoint.host))) {
|
|
127
|
-
denyProxyRequest(client);
|
|
165
|
+
if (!client.destroyed) denyProxyRequest(client);
|
|
128
166
|
return;
|
|
129
167
|
}
|
|
168
|
+
if (stopped || client.destroyed) return;
|
|
130
169
|
|
|
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
|
-
}
|
|
170
|
+
const endpoints = await resolveProxyEndpoints(endpoint.host);
|
|
171
|
+
if (stopped || client.destroyed) return;
|
|
148
172
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
denyProxyRequest(client, '
|
|
173
|
+
let upstreamSocket;
|
|
174
|
+
try {
|
|
175
|
+
upstreamSocket = await connectProxyEndpoints(endpoints, endpoint.port, client);
|
|
176
|
+
} catch {
|
|
177
|
+
if (!stopped && !client.destroyed) denyProxyRequest(client, '502 Bad Gateway');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (stopped || client.destroyed) {
|
|
181
|
+
upstreamSocket.destroy();
|
|
154
182
|
return;
|
|
155
183
|
}
|
|
156
184
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
185
|
+
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
186
|
+
pipeSockets(client, upstreamSocket, rest);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function handleHttp(clientRequest, clientResponse) {
|
|
190
|
+
if (!isAuthorized(clientRequest.headers)) {
|
|
191
|
+
denyHttpRequest(clientResponse, 407, 'Proxy Authentication Required', {
|
|
192
|
+
'Proxy-Authenticate': proxyAuthenticateChallenge,
|
|
193
|
+
});
|
|
160
194
|
return;
|
|
161
195
|
}
|
|
162
196
|
|
|
163
197
|
let url;
|
|
164
198
|
try {
|
|
165
|
-
url = new URL(
|
|
199
|
+
url = new URL(clientRequest.url);
|
|
166
200
|
} catch {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
.trim();
|
|
171
|
-
if (!host) {
|
|
172
|
-
denyProxyRequest(client, '400 Bad Request');
|
|
201
|
+
const authority = clientRequest.headers.host;
|
|
202
|
+
if (!authority) {
|
|
203
|
+
denyHttpRequest(clientResponse, 400, 'Bad Request');
|
|
173
204
|
return;
|
|
174
205
|
}
|
|
175
206
|
|
|
176
207
|
try {
|
|
177
|
-
url = new URL(`http://${
|
|
208
|
+
url = new URL(`http://${authority}${clientRequest.url}`);
|
|
178
209
|
} catch {
|
|
179
|
-
|
|
210
|
+
denyHttpRequest(clientResponse, 400, 'Bad Request');
|
|
180
211
|
return;
|
|
181
212
|
}
|
|
182
213
|
}
|
|
183
214
|
|
|
184
|
-
if (
|
|
185
|
-
|
|
215
|
+
if (url.protocol !== 'http:' || url.username || url.password) {
|
|
216
|
+
denyHttpRequest(clientResponse, 400, 'Bad Request');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const host = canonicalizeHost(url.hostname);
|
|
221
|
+
if (!host) {
|
|
222
|
+
denyHttpRequest(clientResponse, 400, 'Bad Request');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (!(await isDomainAllowed(host))) {
|
|
226
|
+
if (!clientResponse.destroyed) denyHttpRequest(clientResponse, 403, 'Forbidden');
|
|
186
227
|
return;
|
|
187
228
|
}
|
|
229
|
+
const clientSocket = clientRequest.socket;
|
|
230
|
+
if (stopped || clientSocket.destroyed || clientResponse.destroyed) return;
|
|
188
231
|
|
|
189
|
-
const port = parseProxyPort(url.port || undefined,
|
|
232
|
+
const port = parseProxyPort(url.port || undefined, 80);
|
|
190
233
|
if (port === null) {
|
|
191
|
-
|
|
234
|
+
denyHttpRequest(clientResponse, 400, 'Bad Request');
|
|
192
235
|
return;
|
|
193
236
|
}
|
|
194
237
|
|
|
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
|
-
}
|
|
238
|
+
const endpoints = await resolveProxyEndpoints(host);
|
|
239
|
+
if (stopped || clientSocket.destroyed || clientResponse.destroyed) return;
|
|
220
240
|
|
|
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
|
-
}
|
|
241
|
+
const forwardedHeaders = stripHopByHopHeaders(clientRequest.headers);
|
|
242
|
+
forwardedHeaders.host = url.host;
|
|
239
243
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
244
|
+
const agent = new Agent();
|
|
245
|
+
agent.createConnection = (_options, callback) => {
|
|
246
|
+
connectProxyEndpoints(endpoints, port, clientSocket).then(
|
|
247
|
+
(socket) => callback(null, socket),
|
|
248
|
+
(error) => callback(error),
|
|
249
|
+
);
|
|
250
|
+
};
|
|
251
|
+
const upstreamRequest = requestHttp({
|
|
252
|
+
host,
|
|
253
|
+
port,
|
|
254
|
+
method: clientRequest.method,
|
|
255
|
+
path: `${url.pathname}${url.search}` || '/',
|
|
256
|
+
headers: forwardedHeaders,
|
|
257
|
+
agent,
|
|
258
|
+
});
|
|
259
|
+
upstreamRequest.once('response', (upstreamResponse) => {
|
|
260
|
+
clientResponse.writeHead(
|
|
261
|
+
upstreamResponse.statusCode ?? 502,
|
|
262
|
+
upstreamResponse.statusMessage,
|
|
263
|
+
stripHopByHopHeaders(upstreamResponse.headers),
|
|
264
|
+
);
|
|
265
|
+
upstreamResponse.pipe(clientResponse);
|
|
266
|
+
});
|
|
267
|
+
upstreamRequest.once('error', () => {
|
|
268
|
+
if (!clientResponse.headersSent) {
|
|
269
|
+
denyHttpRequest(clientResponse, 502, 'Bad Gateway');
|
|
270
|
+
} else {
|
|
271
|
+
clientResponse.destroy();
|
|
255
272
|
}
|
|
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
273
|
});
|
|
274
|
+
clientRequest.once('aborted', () => upstreamRequest.destroy());
|
|
275
|
+
clientResponse.once('close', () => {
|
|
276
|
+
if (!clientResponse.writableEnded) upstreamRequest.destroy();
|
|
277
|
+
});
|
|
278
|
+
clientRequest.pipe(upstreamRequest);
|
|
264
279
|
}
|
|
265
280
|
|
|
266
|
-
const server =
|
|
281
|
+
const server = createHttpServer({ maxHeaderSize: 65536 }, (request, response) => {
|
|
282
|
+
handleHttp(request, response).catch(() => denyHttpRequest(response, 502, 'Bad Gateway'));
|
|
283
|
+
});
|
|
284
|
+
server.on('connection', trackSocket);
|
|
285
|
+
server.on('connect', (request, client, head) => {
|
|
286
|
+
if (!isAuthorized(request.headers)) {
|
|
287
|
+
denyProxyRequest(client, '407 Proxy Authentication Required', {
|
|
288
|
+
'Proxy-Authenticate': proxyAuthenticateChallenge,
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
handleConnect(client, request.url, head).catch(() =>
|
|
293
|
+
denyProxyRequest(client, '502 Bad Gateway'),
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
server.on('clientError', (error, client) => {
|
|
297
|
+
const status =
|
|
298
|
+
error.code === 'HPE_HEADER_OVERFLOW'
|
|
299
|
+
? '431 Request Header Fields Too Large'
|
|
300
|
+
: '400 Bad Request';
|
|
301
|
+
denyProxyRequest(client, status);
|
|
302
|
+
});
|
|
303
|
+
|
|
267
304
|
let stopped = false;
|
|
268
305
|
|
|
269
306
|
return new Promise((resolve, reject) => {
|
|
@@ -299,6 +336,5 @@ function startFilterProxy(options) {
|
|
|
299
336
|
}
|
|
300
337
|
|
|
301
338
|
module.exports = {
|
|
302
|
-
isPublicProxyAddress,
|
|
303
339
|
startFilterProxy,
|
|
304
340
|
};
|
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
|
+
const bracketed = host.startsWith('[');
|
|
72
|
+
if (bracketed !== host.endsWith(']')) return null;
|
|
73
|
+
|
|
68
74
|
// 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
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// past a literal deny entry; strip a single trailing dot and reject the rest.
|
|
76
|
+
const value = bracketed ? host.slice(1, -1) : host.replace(/\.$/, '');
|
|
77
|
+
if (!value || value.endsWith('.')) return null;
|
|
78
|
+
|
|
79
|
+
if (ipaddr.isValid(value)) {
|
|
80
|
+
if (bracketed && ipaddr.parse(value).kind() !== 'ipv6') return null;
|
|
81
|
+
return ipaddr.process(value).toString();
|
|
82
|
+
}
|
|
83
|
+
if (bracketed) return null;
|
|
84
|
+
|
|
85
|
+
const ascii = domainToASCII(value);
|
|
86
|
+
if (!ascii) 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.
|
|
3
|
+
"version": "0.18.43",
|
|
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.
|
|
41
|
-
"@landstrip/landstrip-darwin-x64": "0.18.
|
|
42
|
-
"@landstrip/landstrip-linux-x64": "0.18.
|
|
43
|
-
"@landstrip/landstrip-linux-arm64": "0.18.
|
|
44
|
-
"@landstrip/landstrip-win32-x64": "0.18.
|
|
45
|
-
"@landstrip/landstrip-win32-arm64": "0.18.
|
|
43
|
+
"@landstrip/landstrip-darwin-arm64": "0.18.43",
|
|
44
|
+
"@landstrip/landstrip-darwin-x64": "0.18.43",
|
|
45
|
+
"@landstrip/landstrip-linux-x64": "0.18.43",
|
|
46
|
+
"@landstrip/landstrip-linux-arm64": "0.18.43",
|
|
47
|
+
"@landstrip/landstrip-win32-x64": "0.18.43",
|
|
48
|
+
"@landstrip/landstrip-win32-arm64": "0.18.43"
|
|
46
49
|
},
|
|
47
50
|
"publishConfig": {
|
|
48
51
|
"access": "public"
|