teleportxr 1.0.89 → 1.0.90
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/client/avatar_service.js +87 -23
- package/client/avatar_validator.js +332 -0
- package/package.json +2 -1
- package/test/test_avatar_service.js +75 -0
- package/test/test_avatar_validator.js +330 -0
package/client/avatar_service.js
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Per-client server-side state for avatar negotiation. Phase
|
|
3
|
-
// implementation in plans/avatars_implementation.md:
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// Per-client server-side state for avatar negotiation. Phase 3 of the
|
|
3
|
+
// implementation in plans/avatars_implementation.md: hand an offered
|
|
4
|
+
// URL to an IAvatarValidator and surface its verdict. With no
|
|
5
|
+
// validator wired the service falls back to the Phase-2 behaviour
|
|
6
|
+
// (always reply using_default), so existing deployments keep working.
|
|
7
7
|
//
|
|
8
8
|
// One AvatarService is owned by each Client; messages are dispatched in
|
|
9
9
|
// from the signaling layer.
|
|
10
10
|
|
|
11
11
|
const avatars = require('../protocol/avatars.js');
|
|
12
12
|
|
|
13
|
+
// Threshold above which a 'pending' frame is sent so the client can
|
|
14
|
+
// show progress (plan §4.1 / §9). Picked to be just under one second
|
|
15
|
+
// so the UX never feels stuck.
|
|
16
|
+
const PENDING_DELAY_MS = 750;
|
|
17
|
+
|
|
13
18
|
function envelope(type, content) {
|
|
14
19
|
return JSON.stringify({ 'teleport-signal-type': type, content });
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
class AvatarService {
|
|
18
|
-
constructor(clientID, sigSend) {
|
|
23
|
+
constructor(clientID, sigSend, opts) {
|
|
19
24
|
this.clientID = clientID;
|
|
20
25
|
this.sigSend = sigSend;
|
|
21
26
|
this.currentPolicy = null;
|
|
22
27
|
this.lastOffer = null;
|
|
23
28
|
this.lastResult = null;
|
|
29
|
+
// Optional IAvatarValidator. When null the service keeps its
|
|
30
|
+
// Phase-2 behaviour: any offer is answered with using_default.
|
|
31
|
+
this.validator = (opts && opts.validator) || null;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
// Send (or re-send) the policy to the owning client. The client is
|
|
@@ -36,17 +44,17 @@ class AvatarService {
|
|
|
36
44
|
this.sigSend(envelope(avatars.TELEPORT_SIGNAL_TYPE_AVATAR_POLICY, content));
|
|
37
45
|
}
|
|
38
46
|
|
|
39
|
-
// Handle an incoming avatar-offer.
|
|
40
|
-
|
|
47
|
+
// Handle an incoming avatar-offer. With no validator wired the
|
|
48
|
+
// service replies using_default exactly as Phase 2 did; with a
|
|
49
|
+
// validator the offered URL is fetched, hashed and measured, and
|
|
50
|
+
// the verdict is reported back.
|
|
51
|
+
async handleOffer(offerJson) {
|
|
41
52
|
const offer = avatars.parseAvatarOffer(offerJson);
|
|
42
53
|
this.lastOffer = offer;
|
|
43
54
|
console.log('avatar-offer ← client ' + this.clientID +
|
|
44
55
|
' policy_id=' + offer.policy_id +
|
|
45
56
|
' have_avatar=' + offer.have_avatar);
|
|
46
57
|
|
|
47
|
-
// If we have not sent a policy, or the offer references a different
|
|
48
|
-
// policy_id, reject so the client knows it is talking about something
|
|
49
|
-
// the server does not currently care about.
|
|
50
58
|
if (!this.currentPolicy ||
|
|
51
59
|
BigInt(offer.policy_id || 0n) !== BigInt(this.currentPolicy.policy_id))
|
|
52
60
|
{
|
|
@@ -61,16 +69,72 @@ class AvatarService {
|
|
|
61
69
|
return;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
//
|
|
65
|
-
// default avatar
|
|
66
|
-
this.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
// Without a validator, or without an offered URL, fall straight
|
|
73
|
+
// back to the default avatar (this is also the Phase-2 path).
|
|
74
|
+
if (!this.validator || !offer.have_avatar || !offer.url) {
|
|
75
|
+
this._reply({
|
|
76
|
+
policy_id: offer.policy_id,
|
|
77
|
+
status: 'using_default',
|
|
78
|
+
node_uid: 0n,
|
|
79
|
+
using_default: true,
|
|
80
|
+
delivery: 'import',
|
|
81
|
+
reasons: [],
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Long-running validation gets a 'pending' status so the client
|
|
87
|
+
// can show progress instead of appearing to hang (plan §4.1).
|
|
88
|
+
let pendingSent = false;
|
|
89
|
+
const pendingTimer = setTimeout(() => {
|
|
90
|
+
pendingSent = true;
|
|
91
|
+
this._reply({
|
|
92
|
+
policy_id: offer.policy_id,
|
|
93
|
+
status: 'pending',
|
|
94
|
+
node_uid: 0n,
|
|
95
|
+
using_default: false,
|
|
96
|
+
delivery: 'import',
|
|
97
|
+
reasons: [],
|
|
98
|
+
}, /*record*/ false);
|
|
99
|
+
}, PENDING_DELAY_MS);
|
|
100
|
+
|
|
101
|
+
let result;
|
|
102
|
+
try {
|
|
103
|
+
result = await this.validator.validate(offer, this.currentPolicy.requirements || {});
|
|
104
|
+
} catch (err) {
|
|
105
|
+
result = { ok: false, reasons: ['validator_error'], bytes: 0, contentHash: '', format: '' };
|
|
106
|
+
}
|
|
107
|
+
clearTimeout(pendingTimer);
|
|
108
|
+
|
|
109
|
+
if (result.ok) {
|
|
110
|
+
this._reply({
|
|
111
|
+
policy_id: offer.policy_id,
|
|
112
|
+
status: 'accepted',
|
|
113
|
+
node_uid: 0n,
|
|
114
|
+
using_default: false,
|
|
115
|
+
delivery: 'import',
|
|
116
|
+
reasons: [],
|
|
117
|
+
});
|
|
118
|
+
} else if (this.currentPolicy.default_available) {
|
|
119
|
+
this._reply({
|
|
120
|
+
policy_id: offer.policy_id,
|
|
121
|
+
status: 'using_default',
|
|
122
|
+
node_uid: 0n,
|
|
123
|
+
using_default: true,
|
|
124
|
+
delivery: 'import',
|
|
125
|
+
reasons: result.reasons || [],
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
this._reply({
|
|
129
|
+
policy_id: offer.policy_id,
|
|
130
|
+
status: 'rejected',
|
|
131
|
+
node_uid: 0n,
|
|
132
|
+
using_default: false,
|
|
133
|
+
delivery: 'import',
|
|
134
|
+
reasons: result.reasons || ['validation_failed'],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
void pendingSent;
|
|
74
138
|
}
|
|
75
139
|
|
|
76
140
|
// Handle a client-initiated revoke (rare in Phase 2; provided for
|
|
@@ -86,9 +150,9 @@ class AvatarService {
|
|
|
86
150
|
this.lastResult = null;
|
|
87
151
|
}
|
|
88
152
|
|
|
89
|
-
_reply(result) {
|
|
153
|
+
_reply(result, record = true) {
|
|
90
154
|
const content = avatars.encodeAvatarResult(result);
|
|
91
|
-
this.lastResult = content;
|
|
155
|
+
if (record) this.lastResult = content;
|
|
92
156
|
console.log('avatar-result → client ' + this.clientID +
|
|
93
157
|
' status=' + content.status +
|
|
94
158
|
' delivery=' + content.delivery +
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Phase 3 of the avatar implementation plan: server-side validation of
|
|
3
|
+
// avatar URLs supplied in avatar-offer. Goal is real download, real
|
|
4
|
+
// measurement, real rejection reasons. Bytes never leave this process
|
|
5
|
+
// — Phase 3 still has no peer visibility.
|
|
6
|
+
//
|
|
7
|
+
// IAvatarValidator is the pluggable interface; DefaultAvatarValidator is
|
|
8
|
+
// a node-only implementation that uses node:https for full control over
|
|
9
|
+
// the transport (so SSRF and byte/time limits can be enforced before any
|
|
10
|
+
// peer protocol code sees the bytes). Tests substitute a fake fetcher
|
|
11
|
+
// via the constructor so they don't have to hit the network.
|
|
12
|
+
|
|
13
|
+
const crypto = require('node:crypto');
|
|
14
|
+
const dns = require('node:dns');
|
|
15
|
+
const https = require('node:https');
|
|
16
|
+
const http = require('node:http');
|
|
17
|
+
const net = require('node:net');
|
|
18
|
+
const { URL } = require('node:url');
|
|
19
|
+
|
|
20
|
+
// SSRF blocklist ------------------------------------------------------
|
|
21
|
+
// Mirrors plans/avatars_plan.md §7 (SSRF row) and §5 V3. Refuses
|
|
22
|
+
// loopback, link-local, private, broadcast, metadata-service and the
|
|
23
|
+
// "this network" range. IPv6: refuses loopback, link-local, ULA, and
|
|
24
|
+
// IPv4-mapped addresses (which would re-enter the v4 blocklist).
|
|
25
|
+
function isBlockedIp(ip)
|
|
26
|
+
{
|
|
27
|
+
if (typeof ip !== 'string' || !ip.length)
|
|
28
|
+
return true;
|
|
29
|
+
const family = net.isIP(ip);
|
|
30
|
+
if (family === 4)
|
|
31
|
+
{
|
|
32
|
+
const o = ip.split('.').map(Number);
|
|
33
|
+
if (o.length !== 4 || o.some(b => !Number.isFinite(b) || b < 0 || b > 255))
|
|
34
|
+
return true;
|
|
35
|
+
if (o[0] === 0) return true; // "this" net
|
|
36
|
+
if (o[0] === 10) return true; // RFC1918
|
|
37
|
+
if (o[0] === 127) return true; // loopback
|
|
38
|
+
if (o[0] === 169 && o[1] === 254) return true; // link-local + AWS metadata
|
|
39
|
+
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true; // RFC1918
|
|
40
|
+
if (o[0] === 192 && o[1] === 168) return true; // RFC1918
|
|
41
|
+
if (o[0] === 192 && o[1] === 0 && o[2] === 0) return true; // IETF protocol
|
|
42
|
+
if (o[0] === 198 && (o[1] === 18 || o[1] === 19)) return true; // benchmarking
|
|
43
|
+
if (o[0] >= 224) return true; // multicast + reserved
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (family === 6)
|
|
47
|
+
{
|
|
48
|
+
const lo = ip.toLowerCase();
|
|
49
|
+
if (lo === '::1' || lo === '::') return true;
|
|
50
|
+
if (lo.startsWith('fe80:') || lo.startsWith('fe80::')) return true; // link-local
|
|
51
|
+
if (lo.startsWith('fc') || lo.startsWith('fd')) return true; // ULA fc00::/7
|
|
52
|
+
if (lo.startsWith('ff')) return true; // multicast
|
|
53
|
+
if (lo.startsWith('::ffff:')) return true; // v4-mapped
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Resolve a hostname to a single IP and check it against the blocklist.
|
|
60
|
+
// Returns the resolved address on success; throws with a code that maps
|
|
61
|
+
// to one of the avatar-result reason codes on failure.
|
|
62
|
+
function resolveAndCheck(hostname)
|
|
63
|
+
{
|
|
64
|
+
return new Promise((resolve, reject) =>
|
|
65
|
+
{
|
|
66
|
+
// If the hostname is already a literal IP, validate directly.
|
|
67
|
+
if (net.isIP(hostname))
|
|
68
|
+
{
|
|
69
|
+
if (isBlockedIp(hostname))
|
|
70
|
+
return reject(Object.assign(new Error('ssrf_blocked'), { code: 'ssrf_blocked' }));
|
|
71
|
+
return resolve(hostname);
|
|
72
|
+
}
|
|
73
|
+
dns.lookup(hostname, { verbatim: true }, (err, address) =>
|
|
74
|
+
{
|
|
75
|
+
if (err)
|
|
76
|
+
return reject(Object.assign(new Error('dns_failed'), { code: 'download_failed' }));
|
|
77
|
+
if (isBlockedIp(address))
|
|
78
|
+
return reject(Object.assign(new Error('ssrf_blocked'), { code: 'ssrf_blocked' }));
|
|
79
|
+
resolve(address);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Default fetcher --------------------------------------------------
|
|
85
|
+
// Streams the response body, hashing as it goes and aborting the
|
|
86
|
+
// connection as soon as `maxBytes` would be exceeded so that a hostile
|
|
87
|
+
// origin cannot trickle gigabytes. Honours `maxRedirects` and re-runs
|
|
88
|
+
// the SSRF check at every hop. Returns { ok, status, body, sha256,
|
|
89
|
+
// finalUrl, reason } where `body` is a Buffer trimmed to maxBytes.
|
|
90
|
+
function defaultFetcher(opts)
|
|
91
|
+
{
|
|
92
|
+
const { url, maxBytes, timeoutMs, maxRedirects, allowedSchemes } = opts;
|
|
93
|
+
// Tests can inject a permissive resolver so the loopback-bound
|
|
94
|
+
// scratch server they spin up isn't rejected by the SSRF guard.
|
|
95
|
+
const resolver = opts.resolver || resolveAndCheck;
|
|
96
|
+
return new Promise((resolve) =>
|
|
97
|
+
{
|
|
98
|
+
const visited = new Set();
|
|
99
|
+
const start = Date.now();
|
|
100
|
+
|
|
101
|
+
const attempt = (currentUrl, hopsLeft) =>
|
|
102
|
+
{
|
|
103
|
+
let u;
|
|
104
|
+
try { u = new URL(currentUrl); }
|
|
105
|
+
catch (e) { return resolve({ ok: false, reason: 'invalid_url' }); }
|
|
106
|
+
|
|
107
|
+
if (!allowedSchemes.includes(u.protocol))
|
|
108
|
+
return resolve({ ok: false, reason: 'scheme_not_allowed' });
|
|
109
|
+
if (visited.has(u.href))
|
|
110
|
+
return resolve({ ok: false, reason: 'redirect_loop' });
|
|
111
|
+
visited.add(u.href);
|
|
112
|
+
|
|
113
|
+
resolver(u.hostname).then((ip) =>
|
|
114
|
+
{
|
|
115
|
+
const lib = u.protocol === 'http:' ? http : https;
|
|
116
|
+
const port = u.port ? Number(u.port) : (u.protocol === 'http:' ? 80 : 443);
|
|
117
|
+
const remaining = Math.max(1, timeoutMs - (Date.now() - start));
|
|
118
|
+
const req = lib.request({
|
|
119
|
+
host: ip,
|
|
120
|
+
port: port,
|
|
121
|
+
path: u.pathname + u.search,
|
|
122
|
+
method: 'GET',
|
|
123
|
+
servername: u.hostname, // SNI/SAN check still uses real host
|
|
124
|
+
headers: { host: u.hostname, 'user-agent': 'teleportxr-avatar-validator/1' },
|
|
125
|
+
timeout: remaining,
|
|
126
|
+
}, (res) =>
|
|
127
|
+
{
|
|
128
|
+
// Follow redirects with the same SSRF check at every hop.
|
|
129
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location)
|
|
130
|
+
{
|
|
131
|
+
res.resume();
|
|
132
|
+
if (hopsLeft <= 0)
|
|
133
|
+
return resolve({ ok: false, reason: 'too_many_redirects' });
|
|
134
|
+
const next = new URL(res.headers.location, u).href;
|
|
135
|
+
return attempt(next, hopsLeft - 1);
|
|
136
|
+
}
|
|
137
|
+
if (res.statusCode !== 200)
|
|
138
|
+
{
|
|
139
|
+
res.resume();
|
|
140
|
+
return resolve({ ok: false, reason: 'http_' + res.statusCode });
|
|
141
|
+
}
|
|
142
|
+
const hash = crypto.createHash('sha256');
|
|
143
|
+
const chunks = [];
|
|
144
|
+
let total = 0;
|
|
145
|
+
let aborted = false;
|
|
146
|
+
res.on('data', (chunk) =>
|
|
147
|
+
{
|
|
148
|
+
if (aborted) return;
|
|
149
|
+
total += chunk.length;
|
|
150
|
+
if (total > maxBytes)
|
|
151
|
+
{
|
|
152
|
+
aborted = true;
|
|
153
|
+
req.destroy();
|
|
154
|
+
return resolve({ ok: false, reason: 'file_too_large' });
|
|
155
|
+
}
|
|
156
|
+
hash.update(chunk);
|
|
157
|
+
chunks.push(chunk);
|
|
158
|
+
});
|
|
159
|
+
res.on('end', () =>
|
|
160
|
+
{
|
|
161
|
+
if (aborted) return;
|
|
162
|
+
resolve({
|
|
163
|
+
ok: true,
|
|
164
|
+
status: 200,
|
|
165
|
+
body: Buffer.concat(chunks, total),
|
|
166
|
+
sha256: hash.digest('hex'),
|
|
167
|
+
finalUrl: u.href,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
res.on('error', () =>
|
|
171
|
+
{
|
|
172
|
+
if (!aborted) resolve({ ok: false, reason: 'download_failed' });
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
req.on('timeout', () => { req.destroy(); resolve({ ok: false, reason: 'fetch_timeout' }); });
|
|
176
|
+
req.on('error', () => resolve({ ok: false, reason: 'download_failed' }));
|
|
177
|
+
req.end();
|
|
178
|
+
}).catch((err) =>
|
|
179
|
+
{
|
|
180
|
+
resolve({ ok: false, reason: err.code || 'download_failed' });
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
attempt(url, maxRedirects);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// LRU cache --------------------------------------------------------
|
|
189
|
+
// Bounded by both entry count and total bytes. Maps content_hash → a
|
|
190
|
+
// frozen validation result so two clients submitting the same URL do
|
|
191
|
+
// not cause two fetches (plan §5 V5, §9). Map preserves insertion order
|
|
192
|
+
// so we can evict the oldest by simply re-inserting on access.
|
|
193
|
+
class LruCache
|
|
194
|
+
{
|
|
195
|
+
constructor(maxEntries, maxBytes)
|
|
196
|
+
{
|
|
197
|
+
this.maxEntries = Math.max(1, maxEntries | 0);
|
|
198
|
+
this.maxBytes = Math.max(0, maxBytes | 0);
|
|
199
|
+
this.map = new Map();
|
|
200
|
+
this.bytes = 0;
|
|
201
|
+
}
|
|
202
|
+
get(key)
|
|
203
|
+
{
|
|
204
|
+
if (!this.map.has(key)) return undefined;
|
|
205
|
+
const v = this.map.get(key);
|
|
206
|
+
this.map.delete(key); this.map.set(key, v);
|
|
207
|
+
return v;
|
|
208
|
+
}
|
|
209
|
+
set(key, value, bytes)
|
|
210
|
+
{
|
|
211
|
+
if (this.map.has(key))
|
|
212
|
+
{
|
|
213
|
+
this.bytes -= this.map.get(key)._bytes || 0;
|
|
214
|
+
this.map.delete(key);
|
|
215
|
+
}
|
|
216
|
+
const stored = Object.assign({}, value, { _bytes: bytes });
|
|
217
|
+
this.map.set(key, stored);
|
|
218
|
+
this.bytes += bytes;
|
|
219
|
+
while (this.map.size > this.maxEntries || this.bytes > this.maxBytes)
|
|
220
|
+
{
|
|
221
|
+
const first = this.map.keys().next().value;
|
|
222
|
+
if (first === undefined) break;
|
|
223
|
+
this.bytes -= this.map.get(first)._bytes || 0;
|
|
224
|
+
this.map.delete(first);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
get size() { return this.map.size; }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Sniff the leading bytes to confirm the declared format. Phase 3
|
|
231
|
+
// supports GLB (binary glTF) by magic + glTF (JSON) by leading brace.
|
|
232
|
+
// Anything else is passed through as ok=true; the host-supplied
|
|
233
|
+
// validator can do stricter checking. Returns the canonical short name.
|
|
234
|
+
function sniffFormat(buf)
|
|
235
|
+
{
|
|
236
|
+
if (!buf || buf.length < 4) return '';
|
|
237
|
+
if (buf[0] === 0x67 && buf[1] === 0x6C && buf[2] === 0x54 && buf[3] === 0x46)
|
|
238
|
+
return 'glb';
|
|
239
|
+
// Skip BOM, whitespace.
|
|
240
|
+
let i = 0;
|
|
241
|
+
if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) i = 3;
|
|
242
|
+
while (i < buf.length && (buf[i] === 0x20 || buf[i] === 0x09 || buf[i] === 0x0A || buf[i] === 0x0D)) i++;
|
|
243
|
+
if (i < buf.length && buf[i] === 0x7B) return 'gltf';
|
|
244
|
+
return '';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// IAvatarValidator -------------------------------------------------
|
|
248
|
+
// Reference interface; subclass and override `validate`. The default
|
|
249
|
+
// implementation below is sufficient for the Phase 3 exit criteria;
|
|
250
|
+
// host applications that want a stricter pipeline (e.g. real glTF
|
|
251
|
+
// parse, triangle count, proof check) supply their own.
|
|
252
|
+
class IAvatarValidator
|
|
253
|
+
{
|
|
254
|
+
async validate(/* offer, requirements */)
|
|
255
|
+
{
|
|
256
|
+
throw new Error('IAvatarValidator.validate is abstract');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
class DefaultAvatarValidator extends IAvatarValidator
|
|
261
|
+
{
|
|
262
|
+
constructor(opts = {})
|
|
263
|
+
{
|
|
264
|
+
super();
|
|
265
|
+
this.allowedSchemes = opts.allowedSchemes || ['https:'];
|
|
266
|
+
this.maxRedirects = opts.maxRedirects ?? 3;
|
|
267
|
+
this.defaultMaxBytes = opts.defaultMaxBytes || 10 * 1024 * 1024;
|
|
268
|
+
this.defaultTimeoutMs = opts.defaultTimeoutMs || 15000;
|
|
269
|
+
this.fetcher = opts.fetcher || defaultFetcher;
|
|
270
|
+
this.cache = opts.cache || new LruCache(opts.cacheEntries || 256, opts.cacheBytes || 256 * 1024 * 1024);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async validate(offer, requirements)
|
|
274
|
+
{
|
|
275
|
+
const reasons = [];
|
|
276
|
+
const req = requirements || {};
|
|
277
|
+
const formats = Array.isArray(req.formats) ? req.formats.map(s => String(s).toLowerCase()) : [];
|
|
278
|
+
const maxBytes = Number.isFinite(req.max_file_bytes) ? Math.min(req.max_file_bytes, this.defaultMaxBytes) : this.defaultMaxBytes;
|
|
279
|
+
const timeoutMs = Number.isFinite(req.fetch_timeout_ms) ? req.fetch_timeout_ms : this.defaultTimeoutMs;
|
|
280
|
+
|
|
281
|
+
// Cheap pre-flight checks before spending a TCP connection.
|
|
282
|
+
if (!offer || !offer.url || typeof offer.url !== 'string')
|
|
283
|
+
return { ok: false, reasons: ['no_url'], bytes: 0, contentHash: '', format: '' };
|
|
284
|
+
const declared = offer.declared || {};
|
|
285
|
+
if (formats.length && declared.format && !formats.includes(String(declared.format).toLowerCase()))
|
|
286
|
+
return { ok: false, reasons: ['format_not_allowed'], bytes: 0, contentHash: '', format: declared.format };
|
|
287
|
+
if (declared.file_bytes && declared.file_bytes > maxBytes)
|
|
288
|
+
return { ok: false, reasons: ['file_too_large'], bytes: declared.file_bytes, contentHash: '', format: declared.format || '' };
|
|
289
|
+
|
|
290
|
+
// Cache hit on declared content_hash short-circuits the fetch.
|
|
291
|
+
if (offer.content_hash && this.cache.get(offer.content_hash))
|
|
292
|
+
{
|
|
293
|
+
const cached = this.cache.get(offer.content_hash);
|
|
294
|
+
return { ok: cached.ok, reasons: cached.reasons.slice(), bytes: cached.bytes, contentHash: offer.content_hash, format: cached.format, fromCache: true };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const fetched = await this.fetcher({
|
|
298
|
+
url: offer.url,
|
|
299
|
+
maxBytes: maxBytes,
|
|
300
|
+
timeoutMs: timeoutMs,
|
|
301
|
+
maxRedirects: this.maxRedirects,
|
|
302
|
+
allowedSchemes: this.allowedSchemes,
|
|
303
|
+
});
|
|
304
|
+
if (!fetched.ok)
|
|
305
|
+
return { ok: false, reasons: [fetched.reason || 'download_failed'], bytes: 0, contentHash: '', format: '' };
|
|
306
|
+
|
|
307
|
+
const bytes = fetched.body.length;
|
|
308
|
+
const contentHash = 'sha256:' + fetched.sha256;
|
|
309
|
+
const sniffed = sniffFormat(fetched.body);
|
|
310
|
+
const format = sniffed || (declared.format || '').toLowerCase();
|
|
311
|
+
|
|
312
|
+
if (offer.content_hash && offer.content_hash !== contentHash)
|
|
313
|
+
reasons.push('hash_mismatch');
|
|
314
|
+
if (formats.length && format && !formats.includes(format))
|
|
315
|
+
reasons.push('format_not_allowed');
|
|
316
|
+
if (bytes > maxBytes)
|
|
317
|
+
reasons.push('file_too_large');
|
|
318
|
+
|
|
319
|
+
const ok = reasons.length === 0;
|
|
320
|
+
const result = { ok, reasons: reasons.slice(), bytes, contentHash, format };
|
|
321
|
+
this.cache.set(contentHash, result, bytes);
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
module.exports.isBlockedIp = isBlockedIp;
|
|
327
|
+
module.exports.resolveAndCheck = resolveAndCheck;
|
|
328
|
+
module.exports.defaultFetcher = defaultFetcher;
|
|
329
|
+
module.exports.LruCache = LruCache;
|
|
330
|
+
module.exports.sniffFormat = sniffFormat;
|
|
331
|
+
module.exports.IAvatarValidator = IAvatarValidator;
|
|
332
|
+
module.exports.DefaultAvatarValidator = DefaultAvatarValidator;
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"name": "teleportxr",
|
|
18
18
|
"description": "Teleport Spatial Server on node.js",
|
|
19
|
-
"version": "1.0.
|
|
19
|
+
"version": "1.0.90",
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
22
|
"url": "git+https://github.com/simul/teleport-nodejs.git"
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"./client/client": "./client/client.js",
|
|
34
34
|
"./client/client_manager": "./client/client_manager.js",
|
|
35
35
|
"./client/avatar_service": "./client/avatar_service.js",
|
|
36
|
+
"./client/avatar_validator": "./client/avatar_validator.js",
|
|
36
37
|
"./connections/webrtcconnectionmanager": "./connections/webrtcconnectionmanager.js",
|
|
37
38
|
"./protocol/avatars": "./protocol/avatars.js",
|
|
38
39
|
"./scene/scene": "./scene/scene.js",
|
|
@@ -103,6 +103,81 @@ test('handleRevoke clears cached offer state without emitting anything', () => {
|
|
|
103
103
|
assert.strictEqual(svc.lastResult, null);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
// Phase-3 integration: when a validator is wired in, the service runs
|
|
107
|
+
// the offered URL through it and reports the verdict instead of always
|
|
108
|
+
// falling back to using_default.
|
|
109
|
+
|
|
110
|
+
function makeValidator(verdict) {
|
|
111
|
+
return { validate: async () => verdict };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
test('handleOffer with validator: accepted result emits status=accepted', async () => {
|
|
115
|
+
const sink = makeSink();
|
|
116
|
+
const svc = new avatar_service.AvatarService(42n, sink.send, {
|
|
117
|
+
validator: makeValidator({ ok: true, reasons: [], bytes: 100, contentHash: 'sha256:aa', format: 'glb' }),
|
|
118
|
+
});
|
|
119
|
+
svc.sendPolicy(new avatars.AvatarPolicy({ policy_id: 11n, default_available: true }));
|
|
120
|
+
sink.sent.length = 0;
|
|
121
|
+
await svc.handleOffer({ policy_id: 11, have_avatar: true, url: 'https://x/a.glb', declared: { format: 'glb' } });
|
|
122
|
+
const msg = sink.sent[sink.sent.length - 1];
|
|
123
|
+
assert.strictEqual(msg.content.status, 'accepted');
|
|
124
|
+
assert.strictEqual(msg.content.using_default, false);
|
|
125
|
+
assert.deepStrictEqual(msg.content.reasons, []);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('handleOffer with validator: failure falls back to using_default when default_available', async () => {
|
|
129
|
+
const sink = makeSink();
|
|
130
|
+
const svc = new avatar_service.AvatarService(42n, sink.send, {
|
|
131
|
+
validator: makeValidator({ ok: false, reasons: ['file_too_large'], bytes: 0, contentHash: '', format: '' }),
|
|
132
|
+
});
|
|
133
|
+
svc.sendPolicy(new avatars.AvatarPolicy({ policy_id: 12n, default_available: true }));
|
|
134
|
+
sink.sent.length = 0;
|
|
135
|
+
await svc.handleOffer({ policy_id: 12, have_avatar: true, url: 'https://x/big.glb', declared: { format: 'glb' } });
|
|
136
|
+
const msg = sink.sent[sink.sent.length - 1];
|
|
137
|
+
assert.strictEqual(msg.content.status, 'using_default');
|
|
138
|
+
assert.strictEqual(msg.content.using_default, true);
|
|
139
|
+
assert.deepStrictEqual(msg.content.reasons, ['file_too_large']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('handleOffer with validator: failure becomes rejected when default_available=false', async () => {
|
|
143
|
+
const sink = makeSink();
|
|
144
|
+
const svc = new avatar_service.AvatarService(42n, sink.send, {
|
|
145
|
+
validator: makeValidator({ ok: false, reasons: ['ssrf_blocked'], bytes: 0, contentHash: '', format: '' }),
|
|
146
|
+
});
|
|
147
|
+
svc.sendPolicy(new avatars.AvatarPolicy({ policy_id: 13n, default_available: false }));
|
|
148
|
+
sink.sent.length = 0;
|
|
149
|
+
await svc.handleOffer({ policy_id: 13, have_avatar: true, url: 'https://x/local', declared: { format: 'glb' } });
|
|
150
|
+
const msg = sink.sent[sink.sent.length - 1];
|
|
151
|
+
assert.strictEqual(msg.content.status, 'rejected');
|
|
152
|
+
assert.deepStrictEqual(msg.content.reasons, ['ssrf_blocked']);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('handleOffer with validator: have_avatar=false still replies using_default without calling validator', async () => {
|
|
156
|
+
const sink = makeSink();
|
|
157
|
+
let called = false;
|
|
158
|
+
const svc = new avatar_service.AvatarService(42n, sink.send, {
|
|
159
|
+
validator: { validate: async () => { called = true; return { ok: true }; } },
|
|
160
|
+
});
|
|
161
|
+
svc.sendPolicy(new avatars.AvatarPolicy({ policy_id: 14n, default_available: true }));
|
|
162
|
+
sink.sent.length = 0;
|
|
163
|
+
await svc.handleOffer({ policy_id: 14, have_avatar: false });
|
|
164
|
+
assert.strictEqual(called, false);
|
|
165
|
+
assert.strictEqual(sink.sent[0].content.status, 'using_default');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('handleOffer with validator: thrown validator error reports validator_error', async () => {
|
|
169
|
+
const sink = makeSink();
|
|
170
|
+
const svc = new avatar_service.AvatarService(42n, sink.send, {
|
|
171
|
+
validator: { validate: async () => { throw new Error('boom'); } },
|
|
172
|
+
});
|
|
173
|
+
svc.sendPolicy(new avatars.AvatarPolicy({ policy_id: 15n, default_available: false }));
|
|
174
|
+
sink.sent.length = 0;
|
|
175
|
+
await svc.handleOffer({ policy_id: 15, have_avatar: true, url: 'https://x/a.glb', declared: { format: 'glb' } });
|
|
176
|
+
const msg = sink.sent[sink.sent.length - 1];
|
|
177
|
+
assert.strictEqual(msg.content.status, 'rejected');
|
|
178
|
+
assert.deepStrictEqual(msg.content.reasons, ['validator_error']);
|
|
179
|
+
});
|
|
180
|
+
|
|
106
181
|
test('signaling dispatch: avatar-offer routed to handleAvatarOffer', () => {
|
|
107
182
|
// Round-trip the dispatch path: build a SignalingClient stub, attach a
|
|
108
183
|
// handler, and feed it a JSON frame. Reaching into the module the same
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Tests for the Phase-3 avatar validator. The SSRF blocklist and LRU
|
|
3
|
+
// cache are exercised directly; the DefaultAvatarValidator is driven
|
|
4
|
+
// with an injected fake fetcher so the network is never touched. This
|
|
5
|
+
// matches the rest of teleport-nodejs/test/, which uses node:test
|
|
6
|
+
// rather than Jest.
|
|
7
|
+
|
|
8
|
+
const test = require('node:test');
|
|
9
|
+
const assert = require('node:assert');
|
|
10
|
+
|
|
11
|
+
const validator = require('../client/avatar_validator.js');
|
|
12
|
+
|
|
13
|
+
// SSRF blocklist ---------------------------------------------------
|
|
14
|
+
|
|
15
|
+
test('isBlockedIp: refuses loopback and private v4 ranges', () => {
|
|
16
|
+
assert.strictEqual(validator.isBlockedIp('127.0.0.1'), true);
|
|
17
|
+
assert.strictEqual(validator.isBlockedIp('10.1.2.3'), true);
|
|
18
|
+
assert.strictEqual(validator.isBlockedIp('172.16.0.1'), true);
|
|
19
|
+
assert.strictEqual(validator.isBlockedIp('172.31.255.255'), true);
|
|
20
|
+
assert.strictEqual(validator.isBlockedIp('192.168.0.1'), true);
|
|
21
|
+
assert.strictEqual(validator.isBlockedIp('169.254.169.254'), true);
|
|
22
|
+
assert.strictEqual(validator.isBlockedIp('0.0.0.0'), true);
|
|
23
|
+
assert.strictEqual(validator.isBlockedIp('224.0.0.1'), true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('isBlockedIp: allows public v4 addresses', () => {
|
|
27
|
+
assert.strictEqual(validator.isBlockedIp('8.8.8.8'), false);
|
|
28
|
+
assert.strictEqual(validator.isBlockedIp('1.1.1.1'), false);
|
|
29
|
+
assert.strictEqual(validator.isBlockedIp('172.15.0.1'), false);
|
|
30
|
+
assert.strictEqual(validator.isBlockedIp('172.32.0.1'), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('isBlockedIp: refuses loopback, link-local and ULA v6', () => {
|
|
34
|
+
assert.strictEqual(validator.isBlockedIp('::1'), true);
|
|
35
|
+
assert.strictEqual(validator.isBlockedIp('fe80::1'), true);
|
|
36
|
+
assert.strictEqual(validator.isBlockedIp('fd00::1'), true);
|
|
37
|
+
assert.strictEqual(validator.isBlockedIp('ff02::1'), true);
|
|
38
|
+
assert.strictEqual(validator.isBlockedIp('::ffff:127.0.0.1'), true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('isBlockedIp: rejects garbage', () => {
|
|
42
|
+
assert.strictEqual(validator.isBlockedIp(''), true);
|
|
43
|
+
assert.strictEqual(validator.isBlockedIp('not-an-ip'), true);
|
|
44
|
+
assert.strictEqual(validator.isBlockedIp(null), true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// LRU cache --------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
test('LruCache evicts the oldest entry beyond maxEntries', () => {
|
|
50
|
+
const c = new validator.LruCache(2, 1 << 20);
|
|
51
|
+
c.set('a', { ok: true }, 1);
|
|
52
|
+
c.set('b', { ok: true }, 1);
|
|
53
|
+
c.set('c', { ok: true }, 1);
|
|
54
|
+
assert.strictEqual(c.size, 2);
|
|
55
|
+
assert.strictEqual(c.get('a'), undefined);
|
|
56
|
+
assert.ok(c.get('b'));
|
|
57
|
+
assert.ok(c.get('c'));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('LruCache evicts to honour maxBytes', () => {
|
|
61
|
+
const c = new validator.LruCache(100, 10);
|
|
62
|
+
c.set('a', { ok: true }, 8);
|
|
63
|
+
c.set('b', { ok: true }, 5);
|
|
64
|
+
assert.strictEqual(c.get('a'), undefined);
|
|
65
|
+
assert.ok(c.get('b'));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('LruCache get() refreshes recency', () => {
|
|
69
|
+
const c = new validator.LruCache(2, 1 << 20);
|
|
70
|
+
c.set('a', { ok: true }, 1);
|
|
71
|
+
c.set('b', { ok: true }, 1);
|
|
72
|
+
c.get('a'); // promote 'a'
|
|
73
|
+
c.set('c', { ok: true }, 1); // evicts 'b', not 'a'
|
|
74
|
+
assert.ok(c.get('a'));
|
|
75
|
+
assert.strictEqual(c.get('b'), undefined);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// sniffFormat ------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
test('sniffFormat recognises GLB magic', () => {
|
|
81
|
+
const glb = Buffer.from([0x67, 0x6C, 0x54, 0x46, 0x02, 0, 0, 0]);
|
|
82
|
+
assert.strictEqual(validator.sniffFormat(glb), 'glb');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('sniffFormat recognises JSON glTF', () => {
|
|
86
|
+
assert.strictEqual(validator.sniffFormat(Buffer.from(' {\n"asset":{}}')), 'gltf');
|
|
87
|
+
assert.strictEqual(validator.sniffFormat(Buffer.from('\uFEFF{"a":1}')), 'gltf');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('sniffFormat returns empty for unknown payloads', () => {
|
|
91
|
+
assert.strictEqual(validator.sniffFormat(Buffer.from('NOPE')), '');
|
|
92
|
+
assert.strictEqual(validator.sniffFormat(Buffer.alloc(0)), '');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// DefaultAvatarValidator with an injected fetcher ----------------
|
|
96
|
+
// The fetcher contract is just `({ url, maxBytes, ... }) → { ok, body,
|
|
97
|
+
// sha256, ... }`. By injecting one we keep the tests offline and
|
|
98
|
+
// deterministic.
|
|
99
|
+
function makeBody(text) {
|
|
100
|
+
const crypto = require('node:crypto');
|
|
101
|
+
const body = Buffer.from(text);
|
|
102
|
+
const sha256 = crypto.createHash('sha256').update(body).digest('hex');
|
|
103
|
+
return { body, sha256 };
|
|
104
|
+
}
|
|
105
|
+
function fakeFetcher(map) {
|
|
106
|
+
return async (opts) => {
|
|
107
|
+
const next = map[opts.url];
|
|
108
|
+
if (typeof next === 'function') return next(opts);
|
|
109
|
+
if (!next) return { ok: false, reason: 'download_failed' };
|
|
110
|
+
return next;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
test('DefaultAvatarValidator: happy path returns ok with the computed hash', async () => {
|
|
115
|
+
const { body, sha256 } = makeBody(
|
|
116
|
+
String.fromCharCode(0x67, 0x6C, 0x54, 0x46) + 'binary-glb-body');
|
|
117
|
+
const v = new validator.DefaultAvatarValidator({
|
|
118
|
+
fetcher: fakeFetcher({
|
|
119
|
+
'https://x/avatar.glb': { ok: true, body, sha256, finalUrl: 'https://x/avatar.glb' },
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
const r = await v.validate(
|
|
123
|
+
{ url: 'https://x/avatar.glb', declared: { format: 'glb' } },
|
|
124
|
+
{ formats: ['glb'], max_file_bytes: 1 << 20 });
|
|
125
|
+
assert.strictEqual(r.ok, true);
|
|
126
|
+
assert.strictEqual(r.format, 'glb');
|
|
127
|
+
assert.strictEqual(r.contentHash, 'sha256:' + sha256);
|
|
128
|
+
assert.deepStrictEqual(r.reasons, []);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('DefaultAvatarValidator: rejects declared format outside allow-list before fetching', async () => {
|
|
132
|
+
let fetched = false;
|
|
133
|
+
const v = new validator.DefaultAvatarValidator({
|
|
134
|
+
fetcher: async () => { fetched = true; return { ok: true, body: Buffer.alloc(0), sha256: '' }; },
|
|
135
|
+
});
|
|
136
|
+
const r = await v.validate(
|
|
137
|
+
{ url: 'https://x/a.fbx', declared: { format: 'fbx' } },
|
|
138
|
+
{ formats: ['glb', 'vrm'] });
|
|
139
|
+
assert.strictEqual(r.ok, false);
|
|
140
|
+
assert.deepStrictEqual(r.reasons, ['format_not_allowed']);
|
|
141
|
+
assert.strictEqual(fetched, false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('DefaultAvatarValidator: rejects oversized declared file before fetching', async () => {
|
|
145
|
+
let fetched = false;
|
|
146
|
+
const v = new validator.DefaultAvatarValidator({
|
|
147
|
+
fetcher: async () => { fetched = true; return { ok: true, body: Buffer.alloc(0), sha256: '' }; },
|
|
148
|
+
});
|
|
149
|
+
const r = await v.validate(
|
|
150
|
+
{ url: 'https://x/a.glb', declared: { format: 'glb', file_bytes: 1_000_000 } },
|
|
151
|
+
{ formats: ['glb'], max_file_bytes: 100 });
|
|
152
|
+
assert.strictEqual(r.ok, false);
|
|
153
|
+
assert.deepStrictEqual(r.reasons, ['file_too_large']);
|
|
154
|
+
assert.strictEqual(fetched, false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('DefaultAvatarValidator: surfaces a content_hash mismatch', async () => {
|
|
158
|
+
const { body, sha256 } = makeBody(
|
|
159
|
+
String.fromCharCode(0x67, 0x6C, 0x54, 0x46) + 'real-bytes');
|
|
160
|
+
const v = new validator.DefaultAvatarValidator({
|
|
161
|
+
fetcher: fakeFetcher({ 'https://x/a.glb': { ok: true, body, sha256 } }),
|
|
162
|
+
});
|
|
163
|
+
const r = await v.validate(
|
|
164
|
+
{ url: 'https://x/a.glb', declared: { format: 'glb' }, content_hash: 'sha256:deadbeef' },
|
|
165
|
+
{ formats: ['glb'] });
|
|
166
|
+
assert.strictEqual(r.ok, false);
|
|
167
|
+
assert.ok(r.reasons.includes('hash_mismatch'));
|
|
168
|
+
// Hash on the result is the actual computed hash, not the claimed one.
|
|
169
|
+
assert.strictEqual(r.contentHash, 'sha256:' + sha256);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('DefaultAvatarValidator: maps fetcher failure reasons through', async () => {
|
|
173
|
+
const v = new validator.DefaultAvatarValidator({
|
|
174
|
+
fetcher: fakeFetcher({ 'https://x/a.glb': { ok: false, reason: 'ssrf_blocked' } }),
|
|
175
|
+
});
|
|
176
|
+
const r = await v.validate({ url: 'https://x/a.glb', declared: { format: 'glb' } }, { formats: ['glb'] });
|
|
177
|
+
assert.strictEqual(r.ok, false);
|
|
178
|
+
assert.deepStrictEqual(r.reasons, ['ssrf_blocked']);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('DefaultAvatarValidator: cache hit on content_hash skips the fetcher', async () => {
|
|
182
|
+
const { body, sha256 } = makeBody(
|
|
183
|
+
String.fromCharCode(0x67, 0x6C, 0x54, 0x46) + 'cached');
|
|
184
|
+
let fetches = 0;
|
|
185
|
+
const v = new validator.DefaultAvatarValidator({
|
|
186
|
+
fetcher: async () => { fetches++; return { ok: true, body, sha256 }; },
|
|
187
|
+
});
|
|
188
|
+
const offer = { url: 'https://x/c.glb', declared: { format: 'glb' } };
|
|
189
|
+
const first = await v.validate(offer, { formats: ['glb'] });
|
|
190
|
+
assert.strictEqual(first.ok, true);
|
|
191
|
+
assert.strictEqual(fetches, 1);
|
|
192
|
+
const second = await v.validate(
|
|
193
|
+
{ url: 'https://x/c.glb', declared: { format: 'glb' }, content_hash: first.contentHash },
|
|
194
|
+
{ formats: ['glb'] });
|
|
195
|
+
assert.strictEqual(second.ok, true);
|
|
196
|
+
assert.strictEqual(second.fromCache, true);
|
|
197
|
+
assert.strictEqual(fetches, 1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
// defaultFetcher against a local http server -----------------------
|
|
202
|
+
// Exercises the real transport path (node:http with the byte cap,
|
|
203
|
+
// timeout and redirect follower) by binding a server to 127.0.0.1.
|
|
204
|
+
// The defaultFetcher's SSRF guard is bypassed for these tests via the
|
|
205
|
+
// `resolver` injection point — see the dedicated SSRF guard test below
|
|
206
|
+
// for the real-blocklist path.
|
|
207
|
+
|
|
208
|
+
const http = require('node:http');
|
|
209
|
+
|
|
210
|
+
function withServer(handler, fn) {
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
const srv = http.createServer(handler);
|
|
213
|
+
srv.listen(0, '127.0.0.1', async () => {
|
|
214
|
+
const port = srv.address().port;
|
|
215
|
+
try {
|
|
216
|
+
const out = await fn(port);
|
|
217
|
+
srv.close(() => resolve(out));
|
|
218
|
+
} catch (err) {
|
|
219
|
+
srv.close(() => reject(err));
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Permissive resolver used for the loopback tests: returns whatever it
|
|
226
|
+
// was asked for without consulting DNS or the blocklist.
|
|
227
|
+
const permissiveResolver = (host) => Promise.resolve(host);
|
|
228
|
+
|
|
229
|
+
test('fetcher: happy-path GET returns body + sha256', async () => {
|
|
230
|
+
const body = Buffer.from([0x67, 0x6C, 0x54, 0x46, 1, 2, 3, 4, 5, 6, 7, 8]);
|
|
231
|
+
await withServer((req, res) => { res.writeHead(200, { 'content-type': 'model/gltf-binary' }); res.end(body); }, async (port) => {
|
|
232
|
+
const r = await validator.defaultFetcher({
|
|
233
|
+
url: 'http://127.0.0.1:' + port + '/a.glb', maxBytes: 1 << 16,
|
|
234
|
+
timeoutMs: 5000, maxRedirects: 0, allowedSchemes: ['http:'],
|
|
235
|
+
resolver: permissiveResolver,
|
|
236
|
+
});
|
|
237
|
+
assert.strictEqual(r.ok, true);
|
|
238
|
+
assert.strictEqual(r.body.length, body.length);
|
|
239
|
+
assert.strictEqual(r.sha256, require('node:crypto').createHash('sha256').update(body).digest('hex'));
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('fetcher: aborts mid-stream when body exceeds maxBytes', async () => {
|
|
244
|
+
await withServer((req, res) => {
|
|
245
|
+
res.writeHead(200, { 'content-type': 'application/octet-stream' });
|
|
246
|
+
let n = 0;
|
|
247
|
+
const iv = setInterval(() => {
|
|
248
|
+
n++;
|
|
249
|
+
res.write(Buffer.alloc(1024, 0xAB));
|
|
250
|
+
if (n > 200) { clearInterval(iv); res.end(); }
|
|
251
|
+
}, 1);
|
|
252
|
+
}, async (port) => {
|
|
253
|
+
const r = await validator.defaultFetcher({
|
|
254
|
+
url: 'http://127.0.0.1:' + port + '/big', maxBytes: 4096,
|
|
255
|
+
timeoutMs: 5000, maxRedirects: 0, allowedSchemes: ['http:'],
|
|
256
|
+
resolver: permissiveResolver,
|
|
257
|
+
});
|
|
258
|
+
assert.strictEqual(r.ok, false);
|
|
259
|
+
assert.strictEqual(r.reason, 'file_too_large');
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('fetcher: surfaces an HTTP timeout', async () => {
|
|
264
|
+
await withServer((req, res) => { /* never respond */ }, async (port) => {
|
|
265
|
+
const r = await validator.defaultFetcher({
|
|
266
|
+
url: 'http://127.0.0.1:' + port + '/hang', maxBytes: 1 << 16,
|
|
267
|
+
timeoutMs: 100, maxRedirects: 0, allowedSchemes: ['http:'],
|
|
268
|
+
resolver: permissiveResolver,
|
|
269
|
+
});
|
|
270
|
+
assert.strictEqual(r.ok, false);
|
|
271
|
+
assert.strictEqual(r.reason, 'fetch_timeout');
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('fetcher: follows a redirect up to maxRedirects', async () => {
|
|
276
|
+
const body = Buffer.from([0x67, 0x6C, 0x54, 0x46, 0, 0, 0, 0]);
|
|
277
|
+
await withServer((req, res) => {
|
|
278
|
+
if (req.url === '/start') { res.writeHead(302, { location: '/final' }); res.end(); return; }
|
|
279
|
+
if (req.url === '/final') { res.writeHead(200); res.end(body); return; }
|
|
280
|
+
res.writeHead(404); res.end();
|
|
281
|
+
}, async (port) => {
|
|
282
|
+
const r = await validator.defaultFetcher({
|
|
283
|
+
url: 'http://127.0.0.1:' + port + '/start', maxBytes: 1 << 16,
|
|
284
|
+
timeoutMs: 5000, maxRedirects: 2, allowedSchemes: ['http:'],
|
|
285
|
+
resolver: permissiveResolver,
|
|
286
|
+
});
|
|
287
|
+
assert.strictEqual(r.ok, true);
|
|
288
|
+
assert.strictEqual(r.body.length, body.length);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('fetcher: refuses a redirect to loopback (SSRF guard runs per hop)', async () => {
|
|
293
|
+
// The first hop is on a stubbed public host (192.0.2.1, TEST-NET-1)
|
|
294
|
+
// served by our loopback http server; it 302s to 127.0.0.1, which
|
|
295
|
+
// the real resolveAndCheck guard must refuse.
|
|
296
|
+
await withServer((req, res) => {
|
|
297
|
+
res.writeHead(302, { location: 'http://127.0.0.1:1/never' }); res.end();
|
|
298
|
+
}, async (port) => {
|
|
299
|
+
const stubbedResolver = (host) => host === '127.0.0.1'
|
|
300
|
+
? validator.resolveAndCheck(host) // real check, will reject
|
|
301
|
+
: Promise.resolve('127.0.0.1'); // pretend the first hop resolves to loopback
|
|
302
|
+
const r = await validator.defaultFetcher({
|
|
303
|
+
url: 'http://example.invalid:' + port + '/start', maxBytes: 1 << 16,
|
|
304
|
+
timeoutMs: 5000, maxRedirects: 3, allowedSchemes: ['http:'],
|
|
305
|
+
resolver: stubbedResolver,
|
|
306
|
+
});
|
|
307
|
+
assert.strictEqual(r.ok, false);
|
|
308
|
+
assert.strictEqual(r.reason, 'ssrf_blocked');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('fetcher: defaultFetcher refuses an http://127.0.0.1 URL (SSRF guard)', async () => {
|
|
313
|
+
// Production resolveAndCheck runs; isBlockedIp must reject loopback.
|
|
314
|
+
const r = await validator.defaultFetcher({
|
|
315
|
+
url: 'http://127.0.0.1:1/anything', maxBytes: 1024,
|
|
316
|
+
timeoutMs: 1000, maxRedirects: 0, allowedSchemes: ['http:'],
|
|
317
|
+
});
|
|
318
|
+
assert.strictEqual(r.ok, false);
|
|
319
|
+
assert.strictEqual(r.reason, 'ssrf_blocked');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('fetcher: refuses a non-allow-listed scheme', async () => {
|
|
323
|
+
const r = await validator.defaultFetcher({
|
|
324
|
+
url: 'file:///etc/passwd', maxBytes: 1024,
|
|
325
|
+
timeoutMs: 1000, maxRedirects: 0, allowedSchemes: ['https:'],
|
|
326
|
+
});
|
|
327
|
+
assert.strictEqual(r.ok, false);
|
|
328
|
+
assert.strictEqual(r.reason, 'scheme_not_allowed');
|
|
329
|
+
});
|
|
330
|
+
|