@peac/protocol 0.11.0 → 0.11.2
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/dist/discovery.d.ts.map +1 -1
- package/dist/index.cjs +1276 -1179
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +1277 -1181
- package/dist/index.mjs.map +1 -1
- package/dist/jwks-resolver.d.ts +88 -0
- package/dist/jwks-resolver.d.ts.map +1 -0
- package/dist/verifier-core.d.ts +0 -8
- package/dist/verifier-core.d.ts.map +1 -1
- package/dist/verify.d.ts +8 -1
- package/dist/verify.d.ts.map +1 -1
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -179,1234 +179,1390 @@ async function issueJws(options) {
|
|
|
179
179
|
const result = await issue(options);
|
|
180
180
|
return result.jws;
|
|
181
181
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
182
|
+
function parseIssuerConfig(json) {
|
|
183
|
+
let config;
|
|
184
|
+
if (typeof json === "string") {
|
|
185
|
+
const bytes = new TextEncoder().encode(json).length;
|
|
186
|
+
if (bytes > schema.PEAC_ISSUER_CONFIG_MAX_BYTES) {
|
|
187
|
+
throw new Error(`Issuer config exceeds ${schema.PEAC_ISSUER_CONFIG_MAX_BYTES} bytes (got ${bytes})`);
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
config = JSON.parse(json);
|
|
191
|
+
} catch {
|
|
192
|
+
throw new Error("Issuer config is not valid JSON");
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
config = json;
|
|
196
|
+
}
|
|
197
|
+
if (typeof config !== "object" || config === null) {
|
|
198
|
+
throw new Error("Issuer config must be an object");
|
|
199
|
+
}
|
|
200
|
+
const obj = config;
|
|
201
|
+
if (typeof obj.version !== "string" || !obj.version) {
|
|
202
|
+
throw new Error("Missing required field: version");
|
|
203
|
+
}
|
|
204
|
+
if (typeof obj.issuer !== "string" || !obj.issuer) {
|
|
205
|
+
throw new Error("Missing required field: issuer");
|
|
206
|
+
}
|
|
207
|
+
if (typeof obj.jwks_uri !== "string" || !obj.jwks_uri) {
|
|
208
|
+
throw new Error("Missing required field: jwks_uri");
|
|
209
|
+
}
|
|
210
|
+
if (!obj.issuer.startsWith("https://")) {
|
|
211
|
+
throw new Error("issuer must be an HTTPS URL");
|
|
187
212
|
}
|
|
188
|
-
const discoveryUrl = `${issuerUrl}/.well-known/peac.txt`;
|
|
189
213
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
throw new Error(
|
|
214
|
+
new URL(obj.jwks_uri);
|
|
215
|
+
} catch {
|
|
216
|
+
throw new Error("jwks_uri must be a valid URL");
|
|
217
|
+
}
|
|
218
|
+
if (obj.verify_endpoint !== void 0) {
|
|
219
|
+
if (typeof obj.verify_endpoint !== "string") {
|
|
220
|
+
throw new Error("verify_endpoint must be a string");
|
|
221
|
+
}
|
|
222
|
+
if (!obj.verify_endpoint.startsWith("https://")) {
|
|
223
|
+
throw new Error("verify_endpoint must be an HTTPS URL");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (obj.receipt_versions !== void 0) {
|
|
227
|
+
if (!Array.isArray(obj.receipt_versions)) {
|
|
228
|
+
throw new Error("receipt_versions must be an array");
|
|
197
229
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (!
|
|
201
|
-
throw new Error("
|
|
230
|
+
}
|
|
231
|
+
if (obj.algorithms !== void 0) {
|
|
232
|
+
if (!Array.isArray(obj.algorithms)) {
|
|
233
|
+
throw new Error("algorithms must be an array");
|
|
202
234
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
235
|
+
}
|
|
236
|
+
if (obj.payment_rails !== void 0) {
|
|
237
|
+
if (!Array.isArray(obj.payment_rails)) {
|
|
238
|
+
throw new Error("payment_rails must be an array");
|
|
206
239
|
}
|
|
207
|
-
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
version: obj.version,
|
|
243
|
+
issuer: obj.issuer,
|
|
244
|
+
jwks_uri: obj.jwks_uri,
|
|
245
|
+
verify_endpoint: obj.verify_endpoint,
|
|
246
|
+
receipt_versions: obj.receipt_versions,
|
|
247
|
+
algorithms: obj.algorithms,
|
|
248
|
+
payment_rails: obj.payment_rails,
|
|
249
|
+
security_contact: obj.security_contact
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
async function fetchIssuerConfig(issuerUrl) {
|
|
253
|
+
if (!issuerUrl.startsWith("https://")) {
|
|
254
|
+
throw new Error("Issuer URL must be https://");
|
|
255
|
+
}
|
|
256
|
+
const baseUrl = issuerUrl.replace(/\/$/, "");
|
|
257
|
+
const configUrl = `${baseUrl}${schema.PEAC_ISSUER_CONFIG_PATH}`;
|
|
258
|
+
try {
|
|
259
|
+
const resp = await fetch(configUrl, {
|
|
208
260
|
headers: { Accept: "application/json" },
|
|
209
|
-
signal: AbortSignal.timeout(
|
|
261
|
+
signal: AbortSignal.timeout(1e4)
|
|
210
262
|
});
|
|
211
|
-
if (!
|
|
212
|
-
throw new Error(`
|
|
263
|
+
if (!resp.ok) {
|
|
264
|
+
throw new Error(`Issuer config fetch failed: ${resp.status}`);
|
|
265
|
+
}
|
|
266
|
+
const text = await resp.text();
|
|
267
|
+
const config = parseIssuerConfig(text);
|
|
268
|
+
const normalizedExpected = baseUrl.replace(/\/$/, "");
|
|
269
|
+
const normalizedActual = config.issuer.replace(/\/$/, "");
|
|
270
|
+
if (normalizedActual !== normalizedExpected) {
|
|
271
|
+
throw new Error(`Issuer mismatch: expected ${normalizedExpected}, got ${normalizedActual}`);
|
|
213
272
|
}
|
|
214
|
-
|
|
215
|
-
return jwks;
|
|
273
|
+
return config;
|
|
216
274
|
} catch (err) {
|
|
217
|
-
throw new Error(
|
|
218
|
-
|
|
219
|
-
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Failed to fetch issuer config from ${issuerUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
277
|
+
{ cause: err }
|
|
278
|
+
);
|
|
220
279
|
}
|
|
221
280
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const jwks = await fetchJWKS(issuerUrl);
|
|
229
|
-
jwksCache.set(issuerUrl, {
|
|
230
|
-
keys: jwks,
|
|
231
|
-
expiresAt: now + CACHE_TTL_MS
|
|
232
|
-
});
|
|
233
|
-
return { jwks, fromCache: false };
|
|
281
|
+
function isJsonContent(text, contentType) {
|
|
282
|
+
if (contentType?.includes("application/json")) {
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
const firstChar = text.trimStart()[0];
|
|
286
|
+
return firstChar === "{";
|
|
234
287
|
}
|
|
235
|
-
function
|
|
236
|
-
|
|
237
|
-
|
|
288
|
+
function parsePolicyManifest(text, contentType) {
|
|
289
|
+
const bytes = new TextEncoder().encode(text).length;
|
|
290
|
+
if (bytes > schema.PEAC_POLICY_MAX_BYTES) {
|
|
291
|
+
throw new Error(`Policy manifest exceeds ${schema.PEAC_POLICY_MAX_BYTES} bytes (got ${bytes})`);
|
|
238
292
|
}
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
293
|
+
let manifest;
|
|
294
|
+
if (isJsonContent(text, contentType)) {
|
|
295
|
+
try {
|
|
296
|
+
manifest = JSON.parse(text);
|
|
297
|
+
} catch {
|
|
298
|
+
throw new Error("Policy manifest is not valid JSON");
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
manifest = parseSimpleYaml(text);
|
|
242
302
|
}
|
|
243
|
-
|
|
303
|
+
if (typeof manifest.version !== "string" || !manifest.version) {
|
|
304
|
+
throw new Error("Missing required field: version");
|
|
305
|
+
}
|
|
306
|
+
if (!manifest.version.startsWith("peac-policy/")) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`Invalid version format: "${manifest.version}". Must start with "peac-policy/" (e.g., "peac-policy/0.1")`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
if (manifest.usage !== "open" && manifest.usage !== "conditional") {
|
|
312
|
+
throw new Error('Missing or invalid field: usage (must be "open" or "conditional")');
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
version: manifest.version,
|
|
316
|
+
usage: manifest.usage,
|
|
317
|
+
purposes: manifest.purposes,
|
|
318
|
+
receipts: manifest.receipts,
|
|
319
|
+
attribution: manifest.attribution,
|
|
320
|
+
rate_limit: manifest.rate_limit,
|
|
321
|
+
daily_limit: manifest.daily_limit,
|
|
322
|
+
negotiate: manifest.negotiate,
|
|
323
|
+
contact: manifest.contact,
|
|
324
|
+
license: manifest.license,
|
|
325
|
+
price: manifest.price,
|
|
326
|
+
currency: manifest.currency,
|
|
327
|
+
payment_methods: manifest.payment_methods,
|
|
328
|
+
payment_endpoint: manifest.payment_endpoint
|
|
329
|
+
};
|
|
244
330
|
}
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
331
|
+
function parseSimpleYaml(text) {
|
|
332
|
+
const lines = text.split("\n");
|
|
333
|
+
const result = {};
|
|
334
|
+
if (text.includes("<<:")) {
|
|
335
|
+
throw new Error("YAML merge keys are not allowed");
|
|
336
|
+
}
|
|
337
|
+
if (text.includes("&") || text.includes("*")) {
|
|
338
|
+
throw new Error("YAML anchors and aliases are not allowed");
|
|
339
|
+
}
|
|
340
|
+
if (/!\w+/.test(text)) {
|
|
341
|
+
throw new Error("YAML custom tags are not allowed");
|
|
342
|
+
}
|
|
343
|
+
const docSeparators = text.match(/^---$/gm);
|
|
344
|
+
if (docSeparators && docSeparators.length > 1) {
|
|
345
|
+
throw new Error("Multi-document YAML is not allowed");
|
|
346
|
+
}
|
|
347
|
+
for (const line of lines) {
|
|
348
|
+
const trimmed = line.trim();
|
|
349
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed === "---") {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const colonIndex = trimmed.indexOf(":");
|
|
353
|
+
if (colonIndex === -1) continue;
|
|
354
|
+
const key = trimmed.slice(0, colonIndex).trim();
|
|
355
|
+
let value = trimmed.slice(colonIndex + 1).trim();
|
|
356
|
+
if (value === "") {
|
|
357
|
+
value = void 0;
|
|
358
|
+
} else if (typeof value === "string") {
|
|
359
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
360
|
+
value = value.slice(1, -1);
|
|
361
|
+
} else if (value.startsWith("[") && value.endsWith("]")) {
|
|
362
|
+
const inner = value.slice(1, -1);
|
|
363
|
+
value = inner.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
364
|
+
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
|
|
365
|
+
value = parseFloat(value);
|
|
366
|
+
} else if (value === "true") {
|
|
367
|
+
value = true;
|
|
368
|
+
} else if (value === "false") {
|
|
369
|
+
value = false;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (value !== void 0) {
|
|
373
|
+
result[key] = value;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
async function fetchPolicyManifest(baseUrl) {
|
|
379
|
+
if (!baseUrl.startsWith("https://") && !baseUrl.startsWith("http://localhost")) {
|
|
380
|
+
throw new Error("Base URL must be https://");
|
|
381
|
+
}
|
|
382
|
+
const normalizedBase = baseUrl.replace(/\/$/, "");
|
|
383
|
+
const primaryUrl = `${normalizedBase}${schema.PEAC_POLICY_PATH}`;
|
|
384
|
+
const fallbackUrl = `${normalizedBase}${schema.PEAC_POLICY_FALLBACK_PATH}`;
|
|
251
385
|
try {
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
};
|
|
386
|
+
const resp = await fetch(primaryUrl, {
|
|
387
|
+
headers: { Accept: "text/plain, application/json" },
|
|
388
|
+
signal: AbortSignal.timeout(5e3)
|
|
389
|
+
});
|
|
390
|
+
if (resp.ok) {
|
|
391
|
+
const text = await resp.text();
|
|
392
|
+
const contentType = resp.headers.get("content-type") || void 0;
|
|
393
|
+
return parsePolicyManifest(text, contentType);
|
|
261
394
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
receiptHash: hashReceipt(receiptJws),
|
|
267
|
-
valid: false,
|
|
268
|
-
reasonCode: "expired",
|
|
269
|
-
issuer: payload.iss,
|
|
270
|
-
kid: header.kid,
|
|
271
|
-
durationMs
|
|
395
|
+
if (resp.status === 404) {
|
|
396
|
+
const fallbackResp = await fetch(fallbackUrl, {
|
|
397
|
+
headers: { Accept: "text/plain, application/json" },
|
|
398
|
+
signal: AbortSignal.timeout(5e3)
|
|
272
399
|
});
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const jwksFetchStart = performance.now();
|
|
280
|
-
const { jwks, fromCache } = await getJWKS(payload.iss);
|
|
281
|
-
if (!fromCache) {
|
|
282
|
-
jwksFetchTime = performance.now() - jwksFetchStart;
|
|
400
|
+
if (fallbackResp.ok) {
|
|
401
|
+
const text = await fallbackResp.text();
|
|
402
|
+
const contentType = fallbackResp.headers.get("content-type") || void 0;
|
|
403
|
+
return parsePolicyManifest(text, contentType);
|
|
404
|
+
}
|
|
405
|
+
throw new Error("Policy manifest not found at primary or fallback location");
|
|
283
406
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
407
|
+
throw new Error(`Policy manifest fetch failed: ${resp.status}`);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`Failed to fetch policy manifest from ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
411
|
+
{ cause: err }
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function parseDiscovery(text) {
|
|
416
|
+
const bytes = new TextEncoder().encode(text).length;
|
|
417
|
+
if (bytes > 2e3) {
|
|
418
|
+
throw new Error(`Discovery manifest exceeds 2000 bytes (got ${bytes})`);
|
|
419
|
+
}
|
|
420
|
+
const lines = text.trim().split("\n");
|
|
421
|
+
if (lines.length > 20) {
|
|
422
|
+
throw new Error(`Discovery manifest exceeds 20 lines (got ${lines.length})`);
|
|
423
|
+
}
|
|
424
|
+
const discovery = {};
|
|
425
|
+
for (const line of lines) {
|
|
426
|
+
const trimmed = line.trim();
|
|
427
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
428
|
+
continue;
|
|
300
429
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
430
|
+
if (trimmed.includes(":")) {
|
|
431
|
+
const [key, ...valueParts] = trimmed.split(":");
|
|
432
|
+
const value = valueParts.join(":").trim();
|
|
433
|
+
switch (key.trim()) {
|
|
434
|
+
case "version":
|
|
435
|
+
discovery.version = value;
|
|
436
|
+
break;
|
|
437
|
+
case "issuer":
|
|
438
|
+
discovery.issuer = value;
|
|
439
|
+
break;
|
|
440
|
+
case "verify":
|
|
441
|
+
discovery.verify_endpoint = value;
|
|
442
|
+
break;
|
|
443
|
+
case "jwks":
|
|
444
|
+
discovery.jwks_uri = value;
|
|
445
|
+
break;
|
|
446
|
+
case "security":
|
|
447
|
+
discovery.security_contact = value;
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
318
450
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
451
|
+
}
|
|
452
|
+
if (!discovery.version) throw new Error("Missing required field: version");
|
|
453
|
+
if (!discovery.issuer) throw new Error("Missing required field: issuer");
|
|
454
|
+
if (!discovery.verify_endpoint) throw new Error("Missing required field: verify");
|
|
455
|
+
if (!discovery.jwks_uri) throw new Error("Missing required field: jwks");
|
|
456
|
+
return discovery;
|
|
457
|
+
}
|
|
458
|
+
async function fetchDiscovery(issuerUrl) {
|
|
459
|
+
if (!issuerUrl.startsWith("https://")) {
|
|
460
|
+
throw new Error("Issuer URL must be https://");
|
|
461
|
+
}
|
|
462
|
+
const discoveryUrl = `${issuerUrl}/.well-known/peac.txt`;
|
|
463
|
+
try {
|
|
464
|
+
const resp = await fetch(discoveryUrl, {
|
|
465
|
+
headers: { Accept: "text/plain" },
|
|
466
|
+
signal: AbortSignal.timeout(5e3)
|
|
327
467
|
});
|
|
468
|
+
if (!resp.ok) {
|
|
469
|
+
throw new Error(`Discovery fetch failed: ${resp.status}`);
|
|
470
|
+
}
|
|
471
|
+
const text = await resp.text();
|
|
472
|
+
return parseDiscovery(text);
|
|
473
|
+
} catch (err) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
`Failed to fetch discovery from ${issuerUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
476
|
+
{ cause: err }
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
var cachedCapabilities = null;
|
|
481
|
+
function getSSRFCapabilities() {
|
|
482
|
+
if (cachedCapabilities) {
|
|
483
|
+
return cachedCapabilities;
|
|
484
|
+
}
|
|
485
|
+
cachedCapabilities = detectCapabilities();
|
|
486
|
+
return cachedCapabilities;
|
|
487
|
+
}
|
|
488
|
+
function detectCapabilities() {
|
|
489
|
+
if (typeof process !== "undefined" && process.versions?.node) {
|
|
328
490
|
return {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
491
|
+
runtime: "node",
|
|
492
|
+
dnsPreResolution: true,
|
|
493
|
+
ipBlocking: true,
|
|
494
|
+
networkIsolation: false,
|
|
495
|
+
protectionLevel: "full",
|
|
496
|
+
notes: [
|
|
497
|
+
"Full SSRF protection available via Node.js dns module",
|
|
498
|
+
"DNS resolution checked before HTTP connection",
|
|
499
|
+
"All RFC 1918 private ranges blocked"
|
|
500
|
+
]
|
|
336
501
|
};
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
fireTelemetryHook(telemetry?.onReceiptVerified, {
|
|
340
|
-
receiptHash: hashReceipt(receiptJws),
|
|
341
|
-
valid: false,
|
|
342
|
-
reasonCode: "verification_error",
|
|
343
|
-
durationMs
|
|
344
|
-
});
|
|
502
|
+
}
|
|
503
|
+
if (typeof process !== "undefined" && process.versions?.bun) {
|
|
345
504
|
return {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
505
|
+
runtime: "bun",
|
|
506
|
+
dnsPreResolution: true,
|
|
507
|
+
ipBlocking: true,
|
|
508
|
+
networkIsolation: false,
|
|
509
|
+
protectionLevel: "full",
|
|
510
|
+
notes: [
|
|
511
|
+
"Full SSRF protection available via Bun dns compatibility",
|
|
512
|
+
"DNS resolution checked before HTTP connection"
|
|
513
|
+
]
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
if (typeof globalThis !== "undefined" && "Deno" in globalThis) {
|
|
517
|
+
return {
|
|
518
|
+
runtime: "deno",
|
|
519
|
+
dnsPreResolution: false,
|
|
520
|
+
ipBlocking: false,
|
|
521
|
+
networkIsolation: false,
|
|
522
|
+
protectionLevel: "partial",
|
|
523
|
+
notes: [
|
|
524
|
+
"DNS pre-resolution not available in Deno by default",
|
|
525
|
+
"SSRF protection limited to URL validation and response limits",
|
|
526
|
+
"Consider using Deno.connect with hostname resolution for enhanced protection"
|
|
527
|
+
]
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
if (typeof globalThis !== "undefined" && typeof globalThis.caches !== "undefined" && typeof globalThis.HTMLRewriter !== "undefined") {
|
|
531
|
+
return {
|
|
532
|
+
runtime: "cloudflare-workers",
|
|
533
|
+
dnsPreResolution: false,
|
|
534
|
+
ipBlocking: false,
|
|
535
|
+
networkIsolation: true,
|
|
536
|
+
protectionLevel: "partial",
|
|
537
|
+
notes: [
|
|
538
|
+
"Cloudflare Workers provide network-level isolation",
|
|
539
|
+
"DNS pre-resolution not available in Workers runtime",
|
|
540
|
+
"CF network blocks many SSRF vectors at infrastructure level",
|
|
541
|
+
"SSRF protection supplemented by URL validation and response limits"
|
|
542
|
+
]
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
const g = globalThis;
|
|
546
|
+
if (typeof g.window !== "undefined" || typeof g.document !== "undefined") {
|
|
547
|
+
return {
|
|
548
|
+
runtime: "browser",
|
|
549
|
+
dnsPreResolution: false,
|
|
550
|
+
ipBlocking: false,
|
|
551
|
+
networkIsolation: false,
|
|
552
|
+
protectionLevel: "minimal",
|
|
553
|
+
notes: [
|
|
554
|
+
"Browser environment detected; DNS pre-resolution not available",
|
|
555
|
+
"SSRF protection limited to URL scheme validation",
|
|
556
|
+
"Consider validating URLs server-side before browser fetch",
|
|
557
|
+
"Same-origin policy provides some protection against SSRF"
|
|
558
|
+
]
|
|
349
559
|
};
|
|
350
560
|
}
|
|
561
|
+
return {
|
|
562
|
+
runtime: "edge-generic",
|
|
563
|
+
dnsPreResolution: false,
|
|
564
|
+
ipBlocking: false,
|
|
565
|
+
networkIsolation: false,
|
|
566
|
+
protectionLevel: "partial",
|
|
567
|
+
notes: [
|
|
568
|
+
"Edge runtime detected; DNS pre-resolution may not be available",
|
|
569
|
+
"SSRF protection limited to URL validation and response limits",
|
|
570
|
+
"Verify runtime provides additional network-level protections"
|
|
571
|
+
]
|
|
572
|
+
};
|
|
351
573
|
}
|
|
352
|
-
function
|
|
353
|
-
|
|
574
|
+
function resetSSRFCapabilitiesCache() {
|
|
575
|
+
cachedCapabilities = null;
|
|
354
576
|
}
|
|
355
|
-
|
|
356
|
-
"
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
return
|
|
365
|
-
path: Array.isArray(issue2?.path) ? issue2.path.join(".") : "",
|
|
366
|
-
message: typeof issue2?.message === "string" ? issue2.message : String(issue2)
|
|
367
|
-
}));
|
|
577
|
+
function parseIPv4(ip) {
|
|
578
|
+
const parts = ip.split(".");
|
|
579
|
+
if (parts.length !== 4) return null;
|
|
580
|
+
const octets = [];
|
|
581
|
+
for (const part of parts) {
|
|
582
|
+
const num = parseInt(part, 10);
|
|
583
|
+
if (isNaN(num) || num < 0 || num > 255) return null;
|
|
584
|
+
octets.push(num);
|
|
585
|
+
}
|
|
586
|
+
return { octets };
|
|
368
587
|
}
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
message: `Receipt schema validation failed: ${pr.error.message}`,
|
|
396
|
-
details: { parse_code: pr.error.code, issues: sanitizeParseIssues(pr.error.issues) }
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
if (issuer !== void 0 && pr.claims.iss !== issuer) {
|
|
400
|
-
return {
|
|
401
|
-
valid: false,
|
|
402
|
-
code: "E_INVALID_ISSUER",
|
|
403
|
-
message: `Issuer mismatch: expected "${issuer}", got "${pr.claims.iss}"`
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
if (audience !== void 0 && pr.claims.aud !== audience) {
|
|
407
|
-
return {
|
|
408
|
-
valid: false,
|
|
409
|
-
code: "E_INVALID_AUDIENCE",
|
|
410
|
-
message: `Audience mismatch: expected "${audience}", got "${pr.claims.aud}"`
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
if (rid !== void 0 && pr.claims.rid !== rid) {
|
|
414
|
-
return {
|
|
415
|
-
valid: false,
|
|
416
|
-
code: "E_INVALID_RECEIPT_ID",
|
|
417
|
-
message: `Receipt ID mismatch: expected "${rid}", got "${pr.claims.rid}"`
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
if (requireExp && pr.claims.exp === void 0) {
|
|
421
|
-
return {
|
|
422
|
-
valid: false,
|
|
423
|
-
code: "E_MISSING_EXP",
|
|
424
|
-
message: "Receipt missing required exp claim"
|
|
425
|
-
};
|
|
588
|
+
function isInCIDR(ip, cidr) {
|
|
589
|
+
const [rangeStr, maskStr] = cidr.split("/");
|
|
590
|
+
const range = parseIPv4(rangeStr);
|
|
591
|
+
if (!range) return false;
|
|
592
|
+
const maskBits = parseInt(maskStr, 10);
|
|
593
|
+
if (isNaN(maskBits) || maskBits < 0 || maskBits > 32) return false;
|
|
594
|
+
const ipNum = ip.octets[0] << 24 | ip.octets[1] << 16 | ip.octets[2] << 8 | ip.octets[3];
|
|
595
|
+
const rangeNum = range.octets[0] << 24 | range.octets[1] << 16 | range.octets[2] << 8 | range.octets[3];
|
|
596
|
+
const mask = maskBits === 0 ? 0 : ~((1 << 32 - maskBits) - 1);
|
|
597
|
+
return (ipNum & mask) === (rangeNum & mask);
|
|
598
|
+
}
|
|
599
|
+
function isIPv6Loopback(ip) {
|
|
600
|
+
const normalized = ip.toLowerCase().replace(/^::ffff:/, "");
|
|
601
|
+
return normalized === "::1" || normalized === "0:0:0:0:0:0:0:1";
|
|
602
|
+
}
|
|
603
|
+
function isIPv6LinkLocal(ip) {
|
|
604
|
+
const normalized = ip.toLowerCase();
|
|
605
|
+
return normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
|
|
606
|
+
}
|
|
607
|
+
function isBlockedIP(ip) {
|
|
608
|
+
const ipv4Match = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
609
|
+
const effectiveIP = ipv4Match ? ipv4Match[1] : ip;
|
|
610
|
+
const ipv4 = parseIPv4(effectiveIP);
|
|
611
|
+
if (ipv4) {
|
|
612
|
+
if (isInCIDR(ipv4, "10.0.0.0/8") || isInCIDR(ipv4, "172.16.0.0/12") || isInCIDR(ipv4, "192.168.0.0/16")) {
|
|
613
|
+
return { blocked: true, reason: "private_ip" };
|
|
426
614
|
}
|
|
427
|
-
if (
|
|
428
|
-
return {
|
|
429
|
-
valid: false,
|
|
430
|
-
code: "E_NOT_YET_VALID",
|
|
431
|
-
message: `Receipt not yet valid: issued at ${new Date(pr.claims.iat * 1e3).toISOString()}, now is ${new Date(now * 1e3).toISOString()}`
|
|
432
|
-
};
|
|
615
|
+
if (isInCIDR(ipv4, "127.0.0.0/8")) {
|
|
616
|
+
return { blocked: true, reason: "loopback" };
|
|
433
617
|
}
|
|
434
|
-
if (
|
|
435
|
-
return {
|
|
436
|
-
valid: false,
|
|
437
|
-
code: "E_EXPIRED",
|
|
438
|
-
message: `Receipt expired at ${new Date(pr.claims.exp * 1e3).toISOString()}`
|
|
439
|
-
};
|
|
618
|
+
if (isInCIDR(ipv4, "169.254.0.0/16")) {
|
|
619
|
+
return { blocked: true, reason: "link_local" };
|
|
440
620
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
621
|
+
return { blocked: false };
|
|
622
|
+
}
|
|
623
|
+
if (isIPv6Loopback(ip)) {
|
|
624
|
+
return { blocked: true, reason: "loopback" };
|
|
625
|
+
}
|
|
626
|
+
if (isIPv6LinkLocal(ip)) {
|
|
627
|
+
return { blocked: true, reason: "link_local" };
|
|
628
|
+
}
|
|
629
|
+
return { blocked: false };
|
|
630
|
+
}
|
|
631
|
+
async function resolveHostname(hostname) {
|
|
632
|
+
if (typeof process !== "undefined" && process.versions?.node) {
|
|
633
|
+
try {
|
|
634
|
+
const dns = await import('dns');
|
|
635
|
+
const { promisify } = await import('util');
|
|
636
|
+
const resolve4 = promisify(dns.resolve4);
|
|
637
|
+
const resolve6 = promisify(dns.resolve6);
|
|
638
|
+
const results = [];
|
|
639
|
+
let ipv4Error = null;
|
|
640
|
+
let ipv6Error = null;
|
|
641
|
+
try {
|
|
642
|
+
const ipv4 = await resolve4(hostname);
|
|
643
|
+
results.push(...ipv4);
|
|
644
|
+
} catch (err) {
|
|
645
|
+
ipv4Error = err;
|
|
449
646
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
policy_binding: "unavailable"
|
|
456
|
-
};
|
|
457
|
-
} else {
|
|
458
|
-
const claims = pr.claims;
|
|
459
|
-
if (subjectUri !== void 0 && claims.sub !== subjectUri) {
|
|
460
|
-
return {
|
|
461
|
-
valid: false,
|
|
462
|
-
code: "E_INVALID_SUBJECT",
|
|
463
|
-
message: `Subject mismatch: expected "${subjectUri}", got "${claims.sub ?? "undefined"}"`
|
|
464
|
-
};
|
|
647
|
+
try {
|
|
648
|
+
const ipv6 = await resolve6(hostname);
|
|
649
|
+
results.push(...ipv6);
|
|
650
|
+
} catch (err) {
|
|
651
|
+
ipv6Error = err;
|
|
465
652
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
variant: "attestation",
|
|
469
|
-
claims,
|
|
470
|
-
kid: result.header.kid,
|
|
471
|
-
policy_binding: "unavailable"
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
} catch (err) {
|
|
475
|
-
if (isCryptoError(err)) {
|
|
476
|
-
if (FORMAT_ERROR_CODES.has(err.code)) {
|
|
477
|
-
return {
|
|
478
|
-
valid: false,
|
|
479
|
-
code: "E_INVALID_FORMAT",
|
|
480
|
-
message: err.message
|
|
481
|
-
};
|
|
653
|
+
if (results.length > 0) {
|
|
654
|
+
return { ok: true, ips: results, browser: false };
|
|
482
655
|
}
|
|
483
|
-
if (
|
|
656
|
+
if (ipv4Error && ipv6Error) {
|
|
484
657
|
return {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
message: err.message
|
|
658
|
+
ok: false,
|
|
659
|
+
message: `DNS resolution failed for ${hostname}: ${ipv4Error.message}`
|
|
488
660
|
};
|
|
489
661
|
}
|
|
662
|
+
return { ok: true, ips: [], browser: false };
|
|
663
|
+
} catch (err) {
|
|
664
|
+
return {
|
|
665
|
+
ok: false,
|
|
666
|
+
message: `DNS resolution error: ${err instanceof Error ? err.message : String(err)}`
|
|
667
|
+
};
|
|
490
668
|
}
|
|
491
|
-
|
|
492
|
-
|
|
669
|
+
}
|
|
670
|
+
return { ok: true, ips: [], browser: true };
|
|
671
|
+
}
|
|
672
|
+
async function ssrfSafeFetch(url, options = {}) {
|
|
673
|
+
const {
|
|
674
|
+
timeoutMs = kernel.VERIFIER_LIMITS.fetchTimeoutMs,
|
|
675
|
+
maxBytes = kernel.VERIFIER_LIMITS.maxResponseBytes,
|
|
676
|
+
maxRedirects = 0,
|
|
677
|
+
allowRedirects = kernel.VERIFIER_NETWORK.allowRedirects,
|
|
678
|
+
allowCrossOriginRedirects = true,
|
|
679
|
+
// Default: allow for CDN compatibility
|
|
680
|
+
dnsFailureBehavior = "block",
|
|
681
|
+
// Default: fail-closed for security
|
|
682
|
+
headers = {}
|
|
683
|
+
} = options;
|
|
684
|
+
let parsedUrl;
|
|
685
|
+
try {
|
|
686
|
+
parsedUrl = new URL(url);
|
|
687
|
+
} catch {
|
|
688
|
+
return {
|
|
689
|
+
ok: false,
|
|
690
|
+
reason: "invalid_url",
|
|
691
|
+
message: `Invalid URL: ${url}`,
|
|
692
|
+
blockedUrl: url
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
if (parsedUrl.protocol !== "https:") {
|
|
696
|
+
return {
|
|
697
|
+
ok: false,
|
|
698
|
+
reason: "not_https",
|
|
699
|
+
message: `URL must use HTTPS: ${url}`,
|
|
700
|
+
blockedUrl: url
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
const dnsResult = await resolveHostname(parsedUrl.hostname);
|
|
704
|
+
if (!dnsResult.ok) {
|
|
705
|
+
if (dnsFailureBehavior === "block") {
|
|
493
706
|
return {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
message: `
|
|
707
|
+
ok: false,
|
|
708
|
+
reason: "dns_failure",
|
|
709
|
+
message: `DNS resolution blocked: ${dnsResult.message}`,
|
|
710
|
+
blockedUrl: url
|
|
497
711
|
};
|
|
498
712
|
}
|
|
499
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
500
713
|
return {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
message:
|
|
714
|
+
ok: false,
|
|
715
|
+
reason: "network_error",
|
|
716
|
+
message: dnsResult.message,
|
|
717
|
+
blockedUrl: url
|
|
504
718
|
};
|
|
505
719
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
return headers.get(schema.PEAC_RECEIPT_HEADER);
|
|
518
|
-
}
|
|
519
|
-
function setVaryHeader(headers) {
|
|
520
|
-
const existing = headers.get("Vary");
|
|
521
|
-
if (existing) {
|
|
522
|
-
const varies = existing.split(",").map((v) => v.trim());
|
|
523
|
-
if (!varies.includes(schema.PEAC_RECEIPT_HEADER)) {
|
|
524
|
-
headers.set("Vary", `${existing}, ${schema.PEAC_RECEIPT_HEADER}`);
|
|
720
|
+
if (!dnsResult.browser) {
|
|
721
|
+
for (const ip of dnsResult.ips) {
|
|
722
|
+
const blockResult = isBlockedIP(ip);
|
|
723
|
+
if (blockResult.blocked) {
|
|
724
|
+
return {
|
|
725
|
+
ok: false,
|
|
726
|
+
reason: blockResult.reason,
|
|
727
|
+
message: `Blocked ${blockResult.reason} address: ${ip} for ${url}`,
|
|
728
|
+
blockedUrl: url
|
|
729
|
+
};
|
|
730
|
+
}
|
|
525
731
|
}
|
|
526
|
-
} else {
|
|
527
|
-
headers.set("Vary", schema.PEAC_RECEIPT_HEADER);
|
|
528
732
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
return [];
|
|
534
|
-
}
|
|
535
|
-
return schema.parsePurposeHeader(value);
|
|
536
|
-
}
|
|
537
|
-
function setPurposeAppliedHeader(headers, purpose) {
|
|
538
|
-
headers.set(schema.PEAC_PURPOSE_APPLIED_HEADER, purpose);
|
|
539
|
-
}
|
|
540
|
-
function setPurposeReasonHeader(headers, reason) {
|
|
541
|
-
headers.set(schema.PEAC_PURPOSE_REASON_HEADER, reason);
|
|
542
|
-
}
|
|
543
|
-
function setVaryPurposeHeader(headers) {
|
|
544
|
-
const existing = headers.get("Vary");
|
|
545
|
-
if (existing) {
|
|
546
|
-
const varies = existing.split(",").map((v) => v.trim());
|
|
547
|
-
if (!varies.includes(schema.PEAC_PURPOSE_HEADER)) {
|
|
548
|
-
headers.set("Vary", `${existing}, ${schema.PEAC_PURPOSE_HEADER}`);
|
|
549
|
-
}
|
|
550
|
-
} else {
|
|
551
|
-
headers.set("Vary", schema.PEAC_PURPOSE_HEADER);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
function parseIssuerConfig(json) {
|
|
555
|
-
let config;
|
|
556
|
-
if (typeof json === "string") {
|
|
557
|
-
const bytes = new TextEncoder().encode(json).length;
|
|
558
|
-
if (bytes > schema.PEAC_ISSUER_CONFIG_MAX_BYTES) {
|
|
559
|
-
throw new Error(`Issuer config exceeds ${schema.PEAC_ISSUER_CONFIG_MAX_BYTES} bytes (got ${bytes})`);
|
|
560
|
-
}
|
|
561
|
-
try {
|
|
562
|
-
config = JSON.parse(json);
|
|
563
|
-
} catch {
|
|
564
|
-
throw new Error("Issuer config is not valid JSON");
|
|
565
|
-
}
|
|
566
|
-
} else {
|
|
567
|
-
config = json;
|
|
568
|
-
}
|
|
569
|
-
if (typeof config !== "object" || config === null) {
|
|
570
|
-
throw new Error("Issuer config must be an object");
|
|
571
|
-
}
|
|
572
|
-
const obj = config;
|
|
573
|
-
if (typeof obj.version !== "string" || !obj.version) {
|
|
574
|
-
throw new Error("Missing required field: version");
|
|
575
|
-
}
|
|
576
|
-
if (typeof obj.issuer !== "string" || !obj.issuer) {
|
|
577
|
-
throw new Error("Missing required field: issuer");
|
|
578
|
-
}
|
|
579
|
-
if (typeof obj.jwks_uri !== "string" || !obj.jwks_uri) {
|
|
580
|
-
throw new Error("Missing required field: jwks_uri");
|
|
581
|
-
}
|
|
582
|
-
if (!obj.issuer.startsWith("https://")) {
|
|
583
|
-
throw new Error("issuer must be an HTTPS URL");
|
|
584
|
-
}
|
|
585
|
-
if (!obj.jwks_uri.startsWith("https://")) {
|
|
586
|
-
throw new Error("jwks_uri must be an HTTPS URL");
|
|
587
|
-
}
|
|
588
|
-
if (obj.verify_endpoint !== void 0) {
|
|
589
|
-
if (typeof obj.verify_endpoint !== "string") {
|
|
590
|
-
throw new Error("verify_endpoint must be a string");
|
|
591
|
-
}
|
|
592
|
-
if (!obj.verify_endpoint.startsWith("https://")) {
|
|
593
|
-
throw new Error("verify_endpoint must be an HTTPS URL");
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
if (obj.receipt_versions !== void 0) {
|
|
597
|
-
if (!Array.isArray(obj.receipt_versions)) {
|
|
598
|
-
throw new Error("receipt_versions must be an array");
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
if (obj.algorithms !== void 0) {
|
|
602
|
-
if (!Array.isArray(obj.algorithms)) {
|
|
603
|
-
throw new Error("algorithms must be an array");
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
if (obj.payment_rails !== void 0) {
|
|
607
|
-
if (!Array.isArray(obj.payment_rails)) {
|
|
608
|
-
throw new Error("payment_rails must be an array");
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
return {
|
|
612
|
-
version: obj.version,
|
|
613
|
-
issuer: obj.issuer,
|
|
614
|
-
jwks_uri: obj.jwks_uri,
|
|
615
|
-
verify_endpoint: obj.verify_endpoint,
|
|
616
|
-
receipt_versions: obj.receipt_versions,
|
|
617
|
-
algorithms: obj.algorithms,
|
|
618
|
-
payment_rails: obj.payment_rails,
|
|
619
|
-
security_contact: obj.security_contact
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
async function fetchIssuerConfig(issuerUrl) {
|
|
623
|
-
if (!issuerUrl.startsWith("https://")) {
|
|
624
|
-
throw new Error("Issuer URL must be https://");
|
|
625
|
-
}
|
|
626
|
-
const baseUrl = issuerUrl.replace(/\/$/, "");
|
|
627
|
-
const configUrl = `${baseUrl}${schema.PEAC_ISSUER_CONFIG_PATH}`;
|
|
628
|
-
try {
|
|
629
|
-
const resp = await fetch(configUrl, {
|
|
630
|
-
headers: { Accept: "application/json" },
|
|
631
|
-
signal: AbortSignal.timeout(1e4)
|
|
632
|
-
});
|
|
633
|
-
if (!resp.ok) {
|
|
634
|
-
throw new Error(`Issuer config fetch failed: ${resp.status}`);
|
|
635
|
-
}
|
|
636
|
-
const text = await resp.text();
|
|
637
|
-
const config = parseIssuerConfig(text);
|
|
638
|
-
const normalizedExpected = baseUrl.replace(/\/$/, "");
|
|
639
|
-
const normalizedActual = config.issuer.replace(/\/$/, "");
|
|
640
|
-
if (normalizedActual !== normalizedExpected) {
|
|
641
|
-
throw new Error(`Issuer mismatch: expected ${normalizedExpected}, got ${normalizedActual}`);
|
|
642
|
-
}
|
|
643
|
-
return config;
|
|
644
|
-
} catch (err) {
|
|
645
|
-
throw new Error(
|
|
646
|
-
`Failed to fetch issuer config from ${issuerUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
647
|
-
{ cause: err }
|
|
648
|
-
);
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
function isJsonContent(text, contentType) {
|
|
652
|
-
if (contentType?.includes("application/json")) {
|
|
653
|
-
return true;
|
|
654
|
-
}
|
|
655
|
-
const firstChar = text.trimStart()[0];
|
|
656
|
-
return firstChar === "{";
|
|
657
|
-
}
|
|
658
|
-
function parsePolicyManifest(text, contentType) {
|
|
659
|
-
const bytes = new TextEncoder().encode(text).length;
|
|
660
|
-
if (bytes > schema.PEAC_POLICY_MAX_BYTES) {
|
|
661
|
-
throw new Error(`Policy manifest exceeds ${schema.PEAC_POLICY_MAX_BYTES} bytes (got ${bytes})`);
|
|
662
|
-
}
|
|
663
|
-
let manifest;
|
|
664
|
-
if (isJsonContent(text, contentType)) {
|
|
733
|
+
let redirectCount = 0;
|
|
734
|
+
let currentUrl = url;
|
|
735
|
+
const originalOrigin = parsedUrl.origin;
|
|
736
|
+
while (true) {
|
|
665
737
|
try {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
if (!manifest.version.startsWith("peac-policy/")) {
|
|
677
|
-
throw new Error(
|
|
678
|
-
`Invalid version format: "${manifest.version}". Must start with "peac-policy/" (e.g., "peac-policy/0.1")`
|
|
679
|
-
);
|
|
680
|
-
}
|
|
681
|
-
if (manifest.usage !== "open" && manifest.usage !== "conditional") {
|
|
682
|
-
throw new Error('Missing or invalid field: usage (must be "open" or "conditional")');
|
|
683
|
-
}
|
|
684
|
-
return {
|
|
685
|
-
version: manifest.version,
|
|
686
|
-
usage: manifest.usage,
|
|
687
|
-
purposes: manifest.purposes,
|
|
688
|
-
receipts: manifest.receipts,
|
|
689
|
-
attribution: manifest.attribution,
|
|
690
|
-
rate_limit: manifest.rate_limit,
|
|
691
|
-
daily_limit: manifest.daily_limit,
|
|
692
|
-
negotiate: manifest.negotiate,
|
|
693
|
-
contact: manifest.contact,
|
|
694
|
-
license: manifest.license,
|
|
695
|
-
price: manifest.price,
|
|
696
|
-
currency: manifest.currency,
|
|
697
|
-
payment_methods: manifest.payment_methods,
|
|
698
|
-
payment_endpoint: manifest.payment_endpoint
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
function parseSimpleYaml(text) {
|
|
702
|
-
const lines = text.split("\n");
|
|
703
|
-
const result = {};
|
|
704
|
-
if (text.includes("<<:")) {
|
|
705
|
-
throw new Error("YAML merge keys are not allowed");
|
|
706
|
-
}
|
|
707
|
-
if (text.includes("&") || text.includes("*")) {
|
|
708
|
-
throw new Error("YAML anchors and aliases are not allowed");
|
|
709
|
-
}
|
|
710
|
-
if (/!\w+/.test(text)) {
|
|
711
|
-
throw new Error("YAML custom tags are not allowed");
|
|
712
|
-
}
|
|
713
|
-
const docSeparators = text.match(/^---$/gm);
|
|
714
|
-
if (docSeparators && docSeparators.length > 1) {
|
|
715
|
-
throw new Error("Multi-document YAML is not allowed");
|
|
716
|
-
}
|
|
717
|
-
for (const line of lines) {
|
|
718
|
-
const trimmed = line.trim();
|
|
719
|
-
if (!trimmed || trimmed.startsWith("#") || trimmed === "---") {
|
|
720
|
-
continue;
|
|
721
|
-
}
|
|
722
|
-
const colonIndex = trimmed.indexOf(":");
|
|
723
|
-
if (colonIndex === -1) continue;
|
|
724
|
-
const key = trimmed.slice(0, colonIndex).trim();
|
|
725
|
-
let value = trimmed.slice(colonIndex + 1).trim();
|
|
726
|
-
if (value === "") {
|
|
727
|
-
value = void 0;
|
|
728
|
-
} else if (typeof value === "string") {
|
|
729
|
-
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
730
|
-
value = value.slice(1, -1);
|
|
731
|
-
} else if (value.startsWith("[") && value.endsWith("]")) {
|
|
732
|
-
const inner = value.slice(1, -1);
|
|
733
|
-
value = inner.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
734
|
-
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
|
|
735
|
-
value = parseFloat(value);
|
|
736
|
-
} else if (value === "true") {
|
|
737
|
-
value = true;
|
|
738
|
-
} else if (value === "false") {
|
|
739
|
-
value = false;
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
if (value !== void 0) {
|
|
743
|
-
result[key] = value;
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
return result;
|
|
747
|
-
}
|
|
748
|
-
async function fetchPolicyManifest(baseUrl) {
|
|
749
|
-
if (!baseUrl.startsWith("https://") && !baseUrl.startsWith("http://localhost")) {
|
|
750
|
-
throw new Error("Base URL must be https://");
|
|
751
|
-
}
|
|
752
|
-
const normalizedBase = baseUrl.replace(/\/$/, "");
|
|
753
|
-
const primaryUrl = `${normalizedBase}${schema.PEAC_POLICY_PATH}`;
|
|
754
|
-
const fallbackUrl = `${normalizedBase}${schema.PEAC_POLICY_FALLBACK_PATH}`;
|
|
755
|
-
try {
|
|
756
|
-
const resp = await fetch(primaryUrl, {
|
|
757
|
-
headers: { Accept: "text/plain, application/json" },
|
|
758
|
-
signal: AbortSignal.timeout(5e3)
|
|
759
|
-
});
|
|
760
|
-
if (resp.ok) {
|
|
761
|
-
const text = await resp.text();
|
|
762
|
-
const contentType = resp.headers.get("content-type") || void 0;
|
|
763
|
-
return parsePolicyManifest(text, contentType);
|
|
764
|
-
}
|
|
765
|
-
if (resp.status === 404) {
|
|
766
|
-
const fallbackResp = await fetch(fallbackUrl, {
|
|
767
|
-
headers: { Accept: "text/plain, application/json" },
|
|
768
|
-
signal: AbortSignal.timeout(5e3)
|
|
738
|
+
const controller = new AbortController();
|
|
739
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
740
|
+
const response = await fetch(currentUrl, {
|
|
741
|
+
headers: {
|
|
742
|
+
Accept: "application/json, text/plain",
|
|
743
|
+
...headers
|
|
744
|
+
},
|
|
745
|
+
signal: controller.signal,
|
|
746
|
+
redirect: "manual"
|
|
747
|
+
// Handle redirects manually for security
|
|
769
748
|
});
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
const
|
|
773
|
-
|
|
749
|
+
clearTimeout(timeoutId);
|
|
750
|
+
if (response.status >= 300 && response.status < 400) {
|
|
751
|
+
const location = response.headers.get("location");
|
|
752
|
+
if (!location) {
|
|
753
|
+
return {
|
|
754
|
+
ok: false,
|
|
755
|
+
reason: "network_error",
|
|
756
|
+
message: `Redirect without Location header from ${currentUrl}`
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
if (!allowRedirects) {
|
|
760
|
+
return {
|
|
761
|
+
ok: false,
|
|
762
|
+
reason: "too_many_redirects",
|
|
763
|
+
message: `Redirects not allowed: ${currentUrl} -> ${location}`,
|
|
764
|
+
blockedUrl: location
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
redirectCount++;
|
|
768
|
+
if (redirectCount > maxRedirects) {
|
|
769
|
+
return {
|
|
770
|
+
ok: false,
|
|
771
|
+
reason: "too_many_redirects",
|
|
772
|
+
message: `Too many redirects (${redirectCount} > ${maxRedirects})`,
|
|
773
|
+
blockedUrl: location
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
let redirectUrl;
|
|
777
|
+
try {
|
|
778
|
+
redirectUrl = new URL(location, currentUrl);
|
|
779
|
+
} catch {
|
|
780
|
+
return {
|
|
781
|
+
ok: false,
|
|
782
|
+
reason: "invalid_url",
|
|
783
|
+
message: `Invalid redirect URL: ${location}`,
|
|
784
|
+
blockedUrl: location
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
if (redirectUrl.protocol !== "https:") {
|
|
788
|
+
return {
|
|
789
|
+
ok: false,
|
|
790
|
+
reason: "scheme_downgrade",
|
|
791
|
+
message: `HTTPS to HTTP downgrade not allowed: ${currentUrl} -> ${redirectUrl.href}`,
|
|
792
|
+
blockedUrl: redirectUrl.href
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
if (redirectUrl.origin !== originalOrigin && !allowCrossOriginRedirects) {
|
|
796
|
+
return {
|
|
797
|
+
ok: false,
|
|
798
|
+
reason: "cross_origin_redirect",
|
|
799
|
+
message: `Cross-origin redirect not allowed: ${originalOrigin} -> ${redirectUrl.origin}`,
|
|
800
|
+
blockedUrl: redirectUrl.href
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
const redirectDnsResult = await resolveHostname(redirectUrl.hostname);
|
|
804
|
+
if (!redirectDnsResult.ok) {
|
|
805
|
+
if (dnsFailureBehavior === "block") {
|
|
806
|
+
return {
|
|
807
|
+
ok: false,
|
|
808
|
+
reason: "dns_failure",
|
|
809
|
+
message: `Redirect DNS resolution blocked: ${redirectDnsResult.message}`,
|
|
810
|
+
blockedUrl: redirectUrl.href
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
return {
|
|
814
|
+
ok: false,
|
|
815
|
+
reason: "network_error",
|
|
816
|
+
message: redirectDnsResult.message,
|
|
817
|
+
blockedUrl: redirectUrl.href
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
if (!redirectDnsResult.browser) {
|
|
821
|
+
for (const ip of redirectDnsResult.ips) {
|
|
822
|
+
const blockResult = isBlockedIP(ip);
|
|
823
|
+
if (blockResult.blocked) {
|
|
824
|
+
return {
|
|
825
|
+
ok: false,
|
|
826
|
+
reason: blockResult.reason,
|
|
827
|
+
message: `Redirect to blocked ${blockResult.reason} address: ${ip}`,
|
|
828
|
+
blockedUrl: redirectUrl.href
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
currentUrl = redirectUrl.href;
|
|
834
|
+
continue;
|
|
774
835
|
}
|
|
775
|
-
|
|
836
|
+
const contentLength = response.headers.get("content-length");
|
|
837
|
+
if (contentLength && parseInt(contentLength, 10) > maxBytes) {
|
|
838
|
+
return {
|
|
839
|
+
ok: false,
|
|
840
|
+
reason: "response_too_large",
|
|
841
|
+
message: `Response too large: ${contentLength} bytes > ${maxBytes} max`
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
const reader = response.body?.getReader();
|
|
845
|
+
if (!reader) {
|
|
846
|
+
const body2 = await response.text();
|
|
847
|
+
if (body2.length > maxBytes) {
|
|
848
|
+
return {
|
|
849
|
+
ok: false,
|
|
850
|
+
reason: "response_too_large",
|
|
851
|
+
message: `Response too large: ${body2.length} bytes > ${maxBytes} max`
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
const rawBytes2 = new TextEncoder().encode(body2);
|
|
855
|
+
return {
|
|
856
|
+
ok: true,
|
|
857
|
+
status: response.status,
|
|
858
|
+
body: body2,
|
|
859
|
+
rawBytes: rawBytes2,
|
|
860
|
+
contentType: response.headers.get("content-type") ?? void 0
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
const chunks = [];
|
|
864
|
+
let totalSize = 0;
|
|
865
|
+
while (true) {
|
|
866
|
+
const { done, value } = await reader.read();
|
|
867
|
+
if (done) break;
|
|
868
|
+
totalSize += value.length;
|
|
869
|
+
if (totalSize > maxBytes) {
|
|
870
|
+
reader.cancel();
|
|
871
|
+
return {
|
|
872
|
+
ok: false,
|
|
873
|
+
reason: "response_too_large",
|
|
874
|
+
message: `Response too large: ${totalSize} bytes > ${maxBytes} max`
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
chunks.push(value);
|
|
878
|
+
}
|
|
879
|
+
const rawBytes = chunks.reduce((acc, chunk) => {
|
|
880
|
+
const result = new Uint8Array(acc.length + chunk.length);
|
|
881
|
+
result.set(acc);
|
|
882
|
+
result.set(chunk, acc.length);
|
|
883
|
+
return result;
|
|
884
|
+
}, new Uint8Array());
|
|
885
|
+
const body = new TextDecoder().decode(rawBytes);
|
|
886
|
+
return {
|
|
887
|
+
ok: true,
|
|
888
|
+
status: response.status,
|
|
889
|
+
body,
|
|
890
|
+
rawBytes,
|
|
891
|
+
contentType: response.headers.get("content-type") ?? void 0
|
|
892
|
+
};
|
|
893
|
+
} catch (err) {
|
|
894
|
+
if (err instanceof Error) {
|
|
895
|
+
if (err.name === "AbortError" || err.message.includes("timeout")) {
|
|
896
|
+
return {
|
|
897
|
+
ok: false,
|
|
898
|
+
reason: "timeout",
|
|
899
|
+
message: `Fetch timeout after ${timeoutMs}ms: ${currentUrl}`
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return {
|
|
904
|
+
ok: false,
|
|
905
|
+
reason: "network_error",
|
|
906
|
+
message: `Network error: ${err instanceof Error ? err.message : String(err)}`
|
|
907
|
+
};
|
|
776
908
|
}
|
|
777
|
-
throw new Error(`Policy manifest fetch failed: ${resp.status}`);
|
|
778
|
-
} catch (err) {
|
|
779
|
-
throw new Error(
|
|
780
|
-
`Failed to fetch policy manifest from ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
781
|
-
{ cause: err }
|
|
782
|
-
);
|
|
783
909
|
}
|
|
784
910
|
}
|
|
785
|
-
function
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
throw new Error(`Discovery manifest exceeds 20 lines (got ${lines.length})`);
|
|
793
|
-
}
|
|
794
|
-
const discovery = {};
|
|
795
|
-
for (const line of lines) {
|
|
796
|
-
const trimmed = line.trim();
|
|
797
|
-
if (!trimmed || trimmed.startsWith("#")) {
|
|
798
|
-
continue;
|
|
911
|
+
async function fetchJWKSSafe(jwksUrl, options) {
|
|
912
|
+
return ssrfSafeFetch(jwksUrl, {
|
|
913
|
+
...options,
|
|
914
|
+
maxBytes: kernel.VERIFIER_LIMITS.maxJwksBytes,
|
|
915
|
+
headers: {
|
|
916
|
+
Accept: "application/json",
|
|
917
|
+
...options?.headers
|
|
799
918
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
break;
|
|
810
|
-
case "verify":
|
|
811
|
-
discovery.verify_endpoint = value;
|
|
812
|
-
break;
|
|
813
|
-
case "jwks":
|
|
814
|
-
discovery.jwks_uri = value;
|
|
815
|
-
break;
|
|
816
|
-
case "security":
|
|
817
|
-
discovery.security_contact = value;
|
|
818
|
-
break;
|
|
819
|
-
}
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
async function fetchPointerSafe(pointerUrl, options) {
|
|
922
|
+
return ssrfSafeFetch(pointerUrl, {
|
|
923
|
+
...options,
|
|
924
|
+
maxBytes: kernel.VERIFIER_LIMITS.maxReceiptBytes,
|
|
925
|
+
headers: {
|
|
926
|
+
Accept: "application/jose, application/json",
|
|
927
|
+
...options?.headers
|
|
820
928
|
}
|
|
821
|
-
}
|
|
822
|
-
if (!discovery.version) throw new Error("Missing required field: version");
|
|
823
|
-
if (!discovery.issuer) throw new Error("Missing required field: issuer");
|
|
824
|
-
if (!discovery.verify_endpoint) throw new Error("Missing required field: verify");
|
|
825
|
-
if (!discovery.jwks_uri) throw new Error("Missing required field: jwks");
|
|
826
|
-
return discovery;
|
|
929
|
+
});
|
|
827
930
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
931
|
+
|
|
932
|
+
// src/jwks-resolver.ts
|
|
933
|
+
var DEFAULT_CACHE_TTL_MS = 5 * 60 * 1e3;
|
|
934
|
+
var DEFAULT_MAX_CACHE_ENTRIES = 1e3;
|
|
935
|
+
var jwksCache = /* @__PURE__ */ new Map();
|
|
936
|
+
function cacheGet(key, now) {
|
|
937
|
+
const entry = jwksCache.get(key);
|
|
938
|
+
if (!entry) return void 0;
|
|
939
|
+
if (entry.expiresAt <= now) {
|
|
940
|
+
jwksCache.delete(key);
|
|
941
|
+
return void 0;
|
|
831
942
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
return parseDiscovery(text);
|
|
843
|
-
} catch (err) {
|
|
844
|
-
throw new Error(
|
|
845
|
-
`Failed to fetch discovery from ${issuerUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
846
|
-
{ cause: err }
|
|
847
|
-
);
|
|
943
|
+
jwksCache.delete(key);
|
|
944
|
+
jwksCache.set(key, entry);
|
|
945
|
+
return entry;
|
|
946
|
+
}
|
|
947
|
+
function cacheSet(key, entry, maxEntries) {
|
|
948
|
+
if (jwksCache.has(key)) jwksCache.delete(key);
|
|
949
|
+
jwksCache.set(key, entry);
|
|
950
|
+
while (jwksCache.size > maxEntries) {
|
|
951
|
+
const oldestKey = jwksCache.keys().next().value;
|
|
952
|
+
if (oldestKey !== void 0) jwksCache.delete(oldestKey);
|
|
848
953
|
}
|
|
849
954
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
max_jwks_bytes: kernel.VERIFIER_LIMITS.maxJwksBytes,
|
|
853
|
-
max_jwks_keys: kernel.VERIFIER_LIMITS.maxJwksKeys,
|
|
854
|
-
max_redirects: kernel.VERIFIER_LIMITS.maxRedirects,
|
|
855
|
-
fetch_timeout_ms: kernel.VERIFIER_LIMITS.fetchTimeoutMs,
|
|
856
|
-
max_extension_bytes: kernel.VERIFIER_LIMITS.maxExtensionBytes
|
|
857
|
-
};
|
|
858
|
-
var DEFAULT_NETWORK_SECURITY = {
|
|
859
|
-
https_only: kernel.VERIFIER_NETWORK.httpsOnly,
|
|
860
|
-
block_private_ips: kernel.VERIFIER_NETWORK.blockPrivateIps,
|
|
861
|
-
allow_redirects: kernel.VERIFIER_NETWORK.allowRedirects,
|
|
862
|
-
allow_cross_origin_redirects: true,
|
|
863
|
-
// Allow for CDN compatibility
|
|
864
|
-
dns_failure_behavior: "block"
|
|
865
|
-
// Fail-closed by default
|
|
866
|
-
};
|
|
867
|
-
function createDefaultPolicy(mode) {
|
|
868
|
-
return {
|
|
869
|
-
policy_version: kernel.VERIFIER_POLICY_VERSION,
|
|
870
|
-
mode,
|
|
871
|
-
limits: { ...DEFAULT_VERIFIER_LIMITS },
|
|
872
|
-
network: { ...DEFAULT_NETWORK_SECURITY }
|
|
873
|
-
};
|
|
955
|
+
function clearJWKSCache() {
|
|
956
|
+
jwksCache.clear();
|
|
874
957
|
}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
"limits.receipt_bytes",
|
|
878
|
-
"jws.protected_header",
|
|
879
|
-
"claims.schema_unverified",
|
|
880
|
-
"issuer.trust_policy",
|
|
881
|
-
"issuer.discovery",
|
|
882
|
-
"key.resolve",
|
|
883
|
-
"jws.signature",
|
|
884
|
-
"claims.time_window",
|
|
885
|
-
"extensions.limits",
|
|
886
|
-
"transport.profile_binding",
|
|
887
|
-
"policy.binding"
|
|
888
|
-
];
|
|
889
|
-
var NON_DETERMINISTIC_ARTIFACT_KEYS = [
|
|
890
|
-
"issuer_jwks_digest"
|
|
891
|
-
];
|
|
892
|
-
function createDigest(hexValue) {
|
|
893
|
-
return {
|
|
894
|
-
alg: "sha-256",
|
|
895
|
-
value: hexValue.toLowerCase()
|
|
896
|
-
};
|
|
958
|
+
function getJWKSCacheSize() {
|
|
959
|
+
return jwksCache.size;
|
|
897
960
|
}
|
|
898
|
-
function
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
961
|
+
function canonicalizeIssuerOrigin(issuerUrl) {
|
|
962
|
+
try {
|
|
963
|
+
const origin = new URL(issuerUrl).origin;
|
|
964
|
+
if (origin === "null") {
|
|
965
|
+
return { ok: false, message: `Issuer URL has no valid origin: ${issuerUrl}` };
|
|
966
|
+
}
|
|
967
|
+
return { ok: true, origin };
|
|
968
|
+
} catch {
|
|
969
|
+
return { ok: false, message: `Issuer URL is not a valid URL: ${issuerUrl}` };
|
|
970
|
+
}
|
|
903
971
|
}
|
|
904
|
-
function
|
|
905
|
-
|
|
906
|
-
switch (
|
|
972
|
+
function mapSSRFError(reason, context) {
|
|
973
|
+
let code;
|
|
974
|
+
switch (reason) {
|
|
907
975
|
case "not_https":
|
|
976
|
+
code = "E_VERIFY_INSECURE_SCHEME_BLOCKED";
|
|
977
|
+
break;
|
|
908
978
|
case "private_ip":
|
|
909
979
|
case "loopback":
|
|
910
980
|
case "link_local":
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
return `${prefix}_blocked`;
|
|
981
|
+
code = "E_VERIFY_KEY_FETCH_BLOCKED";
|
|
982
|
+
break;
|
|
914
983
|
case "timeout":
|
|
915
|
-
|
|
984
|
+
code = "E_VERIFY_KEY_FETCH_TIMEOUT";
|
|
985
|
+
break;
|
|
916
986
|
case "response_too_large":
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
987
|
+
code = context === "jwks" ? "E_VERIFY_JWKS_TOO_LARGE" : "E_VERIFY_KEY_FETCH_FAILED";
|
|
988
|
+
break;
|
|
989
|
+
case "dns_failure":
|
|
990
|
+
case "network_error":
|
|
920
991
|
case "too_many_redirects":
|
|
921
992
|
case "scheme_downgrade":
|
|
922
|
-
case "
|
|
993
|
+
case "cross_origin_redirect":
|
|
923
994
|
case "invalid_url":
|
|
995
|
+
code = context === "issuer_config" ? "E_VERIFY_ISSUER_CONFIG_MISSING" : "E_VERIFY_KEY_FETCH_FAILED";
|
|
996
|
+
break;
|
|
997
|
+
case "jwks_too_many_keys":
|
|
998
|
+
code = "E_VERIFY_JWKS_TOO_MANY_KEYS";
|
|
999
|
+
break;
|
|
924
1000
|
default:
|
|
925
|
-
|
|
1001
|
+
code = "E_VERIFY_KEY_FETCH_FAILED";
|
|
1002
|
+
break;
|
|
926
1003
|
}
|
|
1004
|
+
return { code, reason };
|
|
927
1005
|
}
|
|
928
|
-
function
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
key_fetch_blocked: "E_VERIFY_KEY_FETCH_BLOCKED",
|
|
941
|
-
key_fetch_failed: "E_VERIFY_KEY_FETCH_FAILED",
|
|
942
|
-
key_fetch_timeout: "E_VERIFY_KEY_FETCH_TIMEOUT",
|
|
943
|
-
pointer_fetch_blocked: "E_VERIFY_POINTER_FETCH_BLOCKED",
|
|
944
|
-
pointer_fetch_failed: "E_VERIFY_POINTER_FETCH_FAILED",
|
|
945
|
-
pointer_fetch_timeout: "E_VERIFY_POINTER_FETCH_TIMEOUT",
|
|
946
|
-
pointer_fetch_too_large: "E_VERIFY_POINTER_FETCH_TOO_LARGE",
|
|
947
|
-
pointer_digest_mismatch: "E_VERIFY_POINTER_DIGEST_MISMATCH",
|
|
948
|
-
jwks_too_large: "E_VERIFY_JWKS_TOO_LARGE",
|
|
949
|
-
jwks_too_many_keys: "E_VERIFY_JWKS_TOO_MANY_KEYS",
|
|
950
|
-
expired: "E_VERIFY_EXPIRED",
|
|
951
|
-
not_yet_valid: "E_VERIFY_NOT_YET_VALID",
|
|
952
|
-
audience_mismatch: "E_VERIFY_AUDIENCE_MISMATCH",
|
|
953
|
-
schema_invalid: "E_VERIFY_SCHEMA_INVALID",
|
|
954
|
-
policy_violation: "E_VERIFY_POLICY_VIOLATION",
|
|
955
|
-
extension_too_large: "E_VERIFY_EXTENSION_TOO_LARGE",
|
|
956
|
-
invalid_transport: "E_VERIFY_INVALID_TRANSPORT"
|
|
957
|
-
};
|
|
958
|
-
return mapping[reason] || "E_VERIFY_POLICY_VIOLATION";
|
|
959
|
-
}
|
|
960
|
-
var cachedCapabilities = null;
|
|
961
|
-
function getSSRFCapabilities() {
|
|
962
|
-
if (cachedCapabilities) {
|
|
963
|
-
return cachedCapabilities;
|
|
1006
|
+
async function resolveJWKS(issuerUrl, options) {
|
|
1007
|
+
const cacheTtlMs = options?.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
1008
|
+
const maxCacheEntries = options?.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES;
|
|
1009
|
+
const noCache = options?.noCache ?? false;
|
|
1010
|
+
const normalized = canonicalizeIssuerOrigin(issuerUrl);
|
|
1011
|
+
if (!normalized.ok) {
|
|
1012
|
+
return {
|
|
1013
|
+
ok: false,
|
|
1014
|
+
code: "E_VERIFY_ISSUER_CONFIG_INVALID",
|
|
1015
|
+
message: normalized.message,
|
|
1016
|
+
blockedUrl: issuerUrl
|
|
1017
|
+
};
|
|
964
1018
|
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1019
|
+
const normalizedIssuer = normalized.origin;
|
|
1020
|
+
const now = Date.now();
|
|
1021
|
+
if (!noCache) {
|
|
1022
|
+
const cached = cacheGet(normalizedIssuer, now);
|
|
1023
|
+
if (cached) {
|
|
1024
|
+
return { ok: true, jwks: cached.jwks, fromCache: true };
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if (!normalizedIssuer.startsWith("https://")) {
|
|
970
1028
|
return {
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
protectionLevel: "full",
|
|
976
|
-
notes: [
|
|
977
|
-
"Full SSRF protection available via Node.js dns module",
|
|
978
|
-
"DNS resolution checked before HTTP connection",
|
|
979
|
-
"All RFC 1918 private ranges blocked"
|
|
980
|
-
]
|
|
1029
|
+
ok: false,
|
|
1030
|
+
code: "E_VERIFY_INSECURE_SCHEME_BLOCKED",
|
|
1031
|
+
message: `Issuer URL must be HTTPS: ${normalizedIssuer}`,
|
|
1032
|
+
blockedUrl: normalizedIssuer
|
|
981
1033
|
};
|
|
982
1034
|
}
|
|
983
|
-
|
|
1035
|
+
const configUrl = `${normalizedIssuer}/.well-known/peac-issuer.json`;
|
|
1036
|
+
const configResult = await ssrfSafeFetch(configUrl, {
|
|
1037
|
+
maxBytes: schema.PEAC_ISSUER_CONFIG_MAX_BYTES,
|
|
1038
|
+
headers: { Accept: "application/json" }
|
|
1039
|
+
});
|
|
1040
|
+
if (!configResult.ok) {
|
|
1041
|
+
const mapped = mapSSRFError(configResult.reason, "issuer_config");
|
|
984
1042
|
return {
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
notes: [
|
|
991
|
-
"Full SSRF protection available via Bun dns compatibility",
|
|
992
|
-
"DNS resolution checked before HTTP connection"
|
|
993
|
-
]
|
|
1043
|
+
ok: false,
|
|
1044
|
+
code: mapped.code,
|
|
1045
|
+
message: `Failed to fetch peac-issuer.json: ${configResult.message}`,
|
|
1046
|
+
reason: mapped.reason,
|
|
1047
|
+
blockedUrl: configResult.blockedUrl
|
|
994
1048
|
};
|
|
995
1049
|
}
|
|
996
|
-
|
|
1050
|
+
let issuerConfig;
|
|
1051
|
+
try {
|
|
1052
|
+
issuerConfig = parseIssuerConfig(configResult.body);
|
|
1053
|
+
} catch (err) {
|
|
997
1054
|
return {
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
networkIsolation: false,
|
|
1002
|
-
protectionLevel: "partial",
|
|
1003
|
-
notes: [
|
|
1004
|
-
"DNS pre-resolution not available in Deno by default",
|
|
1005
|
-
"SSRF protection limited to URL validation and response limits",
|
|
1006
|
-
"Consider using Deno.connect with hostname resolution for enhanced protection"
|
|
1007
|
-
]
|
|
1055
|
+
ok: false,
|
|
1056
|
+
code: "E_VERIFY_ISSUER_CONFIG_INVALID",
|
|
1057
|
+
message: `Invalid peac-issuer.json: ${err instanceof Error ? err.message : String(err)}`
|
|
1008
1058
|
};
|
|
1009
1059
|
}
|
|
1010
|
-
|
|
1060
|
+
const configIssuerResult = canonicalizeIssuerOrigin(issuerConfig.issuer);
|
|
1061
|
+
const configIssuer = configIssuerResult.ok ? configIssuerResult.origin : issuerConfig.issuer;
|
|
1062
|
+
if (configIssuer !== normalizedIssuer) {
|
|
1063
|
+
return {
|
|
1064
|
+
ok: false,
|
|
1065
|
+
code: "E_VERIFY_ISSUER_MISMATCH",
|
|
1066
|
+
message: `Issuer mismatch: expected ${normalizedIssuer}, got ${configIssuer}`
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
if (!issuerConfig.jwks_uri) {
|
|
1070
|
+
return {
|
|
1071
|
+
ok: false,
|
|
1072
|
+
code: "E_VERIFY_JWKS_URI_INVALID",
|
|
1073
|
+
message: "peac-issuer.json missing required jwks_uri field"
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
if (!issuerConfig.jwks_uri.startsWith("https://")) {
|
|
1077
|
+
return {
|
|
1078
|
+
ok: false,
|
|
1079
|
+
code: "E_VERIFY_JWKS_URI_INVALID",
|
|
1080
|
+
message: `jwks_uri must be HTTPS: ${issuerConfig.jwks_uri}`,
|
|
1081
|
+
blockedUrl: issuerConfig.jwks_uri
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
const jwksResult = await fetchJWKSSafe(issuerConfig.jwks_uri);
|
|
1085
|
+
if (!jwksResult.ok) {
|
|
1086
|
+
const mapped = mapSSRFError(jwksResult.reason, "jwks");
|
|
1087
|
+
return {
|
|
1088
|
+
ok: false,
|
|
1089
|
+
code: mapped.code,
|
|
1090
|
+
message: `Failed to fetch JWKS from ${issuerConfig.jwks_uri}: ${jwksResult.message}`,
|
|
1091
|
+
reason: mapped.reason,
|
|
1092
|
+
blockedUrl: jwksResult.blockedUrl
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
let jwks;
|
|
1096
|
+
try {
|
|
1097
|
+
jwks = JSON.parse(jwksResult.body);
|
|
1098
|
+
} catch {
|
|
1099
|
+
return {
|
|
1100
|
+
ok: false,
|
|
1101
|
+
code: "E_VERIFY_JWKS_INVALID",
|
|
1102
|
+
message: "JWKS response is not valid JSON"
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
if (!jwks.keys || !Array.isArray(jwks.keys)) {
|
|
1106
|
+
return {
|
|
1107
|
+
ok: false,
|
|
1108
|
+
code: "E_VERIFY_JWKS_INVALID",
|
|
1109
|
+
message: "JWKS missing required keys array"
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
if (jwks.keys.length > kernel.VERIFIER_LIMITS.maxJwksKeys) {
|
|
1113
|
+
return {
|
|
1114
|
+
ok: false,
|
|
1115
|
+
code: "E_VERIFY_JWKS_TOO_MANY_KEYS",
|
|
1116
|
+
message: `JWKS has too many keys: ${jwks.keys.length} > ${kernel.VERIFIER_LIMITS.maxJwksKeys}`
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
if (!noCache) {
|
|
1120
|
+
cacheSet(normalizedIssuer, { jwks, expiresAt: now + cacheTtlMs }, maxCacheEntries);
|
|
1121
|
+
}
|
|
1122
|
+
return {
|
|
1123
|
+
ok: true,
|
|
1124
|
+
jwks,
|
|
1125
|
+
fromCache: false,
|
|
1126
|
+
rawBytes: jwksResult.rawBytes
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// src/verify.ts
|
|
1131
|
+
function jwkToPublicKey(jwk) {
|
|
1132
|
+
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") {
|
|
1133
|
+
throw new Error("Only Ed25519 keys (OKP/Ed25519) are supported");
|
|
1134
|
+
}
|
|
1135
|
+
const xBytes = Buffer.from(jwk.x, "base64url");
|
|
1136
|
+
if (xBytes.length !== 32) {
|
|
1137
|
+
throw new Error("Ed25519 public key must be 32 bytes");
|
|
1138
|
+
}
|
|
1139
|
+
return new Uint8Array(xBytes);
|
|
1140
|
+
}
|
|
1141
|
+
async function verifyReceipt(optionsOrJws) {
|
|
1142
|
+
const receiptJws = typeof optionsOrJws === "string" ? optionsOrJws : optionsOrJws.receiptJws;
|
|
1143
|
+
const inputSnapshot = typeof optionsOrJws === "string" ? void 0 : optionsOrJws.subject_snapshot;
|
|
1144
|
+
const telemetry = typeof optionsOrJws === "string" ? void 0 : optionsOrJws.telemetry;
|
|
1145
|
+
const startTime = performance.now();
|
|
1146
|
+
let jwksFetchTime;
|
|
1147
|
+
try {
|
|
1148
|
+
const { header, payload } = crypto.decode(receiptJws);
|
|
1149
|
+
const constraintResult = schema.validateKernelConstraints(payload);
|
|
1150
|
+
if (!constraintResult.valid) {
|
|
1151
|
+
const v = constraintResult.violations[0];
|
|
1152
|
+
return {
|
|
1153
|
+
ok: false,
|
|
1154
|
+
reason: "constraint_violation",
|
|
1155
|
+
details: `Kernel constraint violated: ${v.constraint} (actual: ${v.actual}, limit: ${v.limit})`
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
schema.ReceiptClaims.parse(payload);
|
|
1159
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1e3)) {
|
|
1160
|
+
const durationMs = performance.now() - startTime;
|
|
1161
|
+
fireTelemetryHook(telemetry?.onReceiptVerified, {
|
|
1162
|
+
receiptHash: hashReceipt(receiptJws),
|
|
1163
|
+
valid: false,
|
|
1164
|
+
reasonCode: "expired",
|
|
1165
|
+
issuer: payload.iss,
|
|
1166
|
+
kid: header.kid,
|
|
1167
|
+
durationMs
|
|
1168
|
+
});
|
|
1169
|
+
return {
|
|
1170
|
+
ok: false,
|
|
1171
|
+
reason: "expired",
|
|
1172
|
+
details: `Receipt expired at ${new Date(payload.exp * 1e3).toISOString()}`
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
const jwksFetchStart = performance.now();
|
|
1176
|
+
const jwksResult = await resolveJWKS(payload.iss);
|
|
1177
|
+
if (!jwksResult.ok) {
|
|
1178
|
+
return {
|
|
1179
|
+
ok: false,
|
|
1180
|
+
reason: jwksResult.code,
|
|
1181
|
+
details: jwksResult.message
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
if (!jwksResult.fromCache) {
|
|
1185
|
+
jwksFetchTime = performance.now() - jwksFetchStart;
|
|
1186
|
+
}
|
|
1187
|
+
const jwk = jwksResult.jwks.keys.find((k) => k.kid === header.kid);
|
|
1188
|
+
if (!jwk) {
|
|
1189
|
+
const durationMs = performance.now() - startTime;
|
|
1190
|
+
fireTelemetryHook(telemetry?.onReceiptVerified, {
|
|
1191
|
+
receiptHash: hashReceipt(receiptJws),
|
|
1192
|
+
valid: false,
|
|
1193
|
+
reasonCode: "unknown_key",
|
|
1194
|
+
issuer: payload.iss,
|
|
1195
|
+
kid: header.kid,
|
|
1196
|
+
durationMs
|
|
1197
|
+
});
|
|
1198
|
+
return {
|
|
1199
|
+
ok: false,
|
|
1200
|
+
reason: "unknown_key",
|
|
1201
|
+
details: `No key found with kid=${header.kid}`
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
const publicKey = jwkToPublicKey(jwk);
|
|
1205
|
+
const result = await crypto.verify(receiptJws, publicKey);
|
|
1206
|
+
if (!result.valid) {
|
|
1207
|
+
const durationMs = performance.now() - startTime;
|
|
1208
|
+
fireTelemetryHook(telemetry?.onReceiptVerified, {
|
|
1209
|
+
receiptHash: hashReceipt(receiptJws),
|
|
1210
|
+
valid: false,
|
|
1211
|
+
reasonCode: "invalid_signature",
|
|
1212
|
+
issuer: payload.iss,
|
|
1213
|
+
kid: header.kid,
|
|
1214
|
+
durationMs
|
|
1215
|
+
});
|
|
1216
|
+
return {
|
|
1217
|
+
ok: false,
|
|
1218
|
+
reason: "invalid_signature",
|
|
1219
|
+
details: "Ed25519 signature verification failed"
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
const validatedSnapshot = schema.validateSubjectSnapshot(inputSnapshot);
|
|
1223
|
+
const verifyTime = performance.now() - startTime;
|
|
1224
|
+
fireTelemetryHook(telemetry?.onReceiptVerified, {
|
|
1225
|
+
receiptHash: hashReceipt(receiptJws),
|
|
1226
|
+
valid: true,
|
|
1227
|
+
issuer: payload.iss,
|
|
1228
|
+
kid: header.kid,
|
|
1229
|
+
durationMs: verifyTime
|
|
1230
|
+
});
|
|
1011
1231
|
return {
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
"DNS pre-resolution not available in Workers runtime",
|
|
1020
|
-
"CF network blocks many SSRF vectors at infrastructure level",
|
|
1021
|
-
"SSRF protection supplemented by URL validation and response limits"
|
|
1022
|
-
]
|
|
1232
|
+
ok: true,
|
|
1233
|
+
claims: payload,
|
|
1234
|
+
...validatedSnapshot && { subject_snapshot: validatedSnapshot },
|
|
1235
|
+
perf: {
|
|
1236
|
+
verify_ms: verifyTime,
|
|
1237
|
+
...jwksFetchTime && { jwks_fetch_ms: jwksFetchTime }
|
|
1238
|
+
}
|
|
1023
1239
|
};
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
const durationMs = performance.now() - startTime;
|
|
1242
|
+
fireTelemetryHook(telemetry?.onReceiptVerified, {
|
|
1243
|
+
receiptHash: hashReceipt(receiptJws),
|
|
1244
|
+
valid: false,
|
|
1245
|
+
reasonCode: "verification_error",
|
|
1246
|
+
durationMs
|
|
1247
|
+
});
|
|
1027
1248
|
return {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
networkIsolation: false,
|
|
1032
|
-
protectionLevel: "minimal",
|
|
1033
|
-
notes: [
|
|
1034
|
-
"Browser environment detected; DNS pre-resolution not available",
|
|
1035
|
-
"SSRF protection limited to URL scheme validation",
|
|
1036
|
-
"Consider validating URLs server-side before browser fetch",
|
|
1037
|
-
"Same-origin policy provides some protection against SSRF"
|
|
1038
|
-
]
|
|
1249
|
+
ok: false,
|
|
1250
|
+
reason: "verification_error",
|
|
1251
|
+
details: err instanceof Error ? err.message : String(err)
|
|
1039
1252
|
};
|
|
1040
1253
|
}
|
|
1041
|
-
return {
|
|
1042
|
-
runtime: "edge-generic",
|
|
1043
|
-
dnsPreResolution: false,
|
|
1044
|
-
ipBlocking: false,
|
|
1045
|
-
networkIsolation: false,
|
|
1046
|
-
protectionLevel: "partial",
|
|
1047
|
-
notes: [
|
|
1048
|
-
"Edge runtime detected; DNS pre-resolution may not be available",
|
|
1049
|
-
"SSRF protection limited to URL validation and response limits",
|
|
1050
|
-
"Verify runtime provides additional network-level protections"
|
|
1051
|
-
]
|
|
1052
|
-
};
|
|
1053
|
-
}
|
|
1054
|
-
function resetSSRFCapabilitiesCache() {
|
|
1055
|
-
cachedCapabilities = null;
|
|
1056
|
-
}
|
|
1057
|
-
function parseIPv4(ip) {
|
|
1058
|
-
const parts = ip.split(".");
|
|
1059
|
-
if (parts.length !== 4) return null;
|
|
1060
|
-
const octets = [];
|
|
1061
|
-
for (const part of parts) {
|
|
1062
|
-
const num = parseInt(part, 10);
|
|
1063
|
-
if (isNaN(num) || num < 0 || num > 255) return null;
|
|
1064
|
-
octets.push(num);
|
|
1065
|
-
}
|
|
1066
|
-
return { octets };
|
|
1067
|
-
}
|
|
1068
|
-
function isInCIDR(ip, cidr) {
|
|
1069
|
-
const [rangeStr, maskStr] = cidr.split("/");
|
|
1070
|
-
const range = parseIPv4(rangeStr);
|
|
1071
|
-
if (!range) return false;
|
|
1072
|
-
const maskBits = parseInt(maskStr, 10);
|
|
1073
|
-
if (isNaN(maskBits) || maskBits < 0 || maskBits > 32) return false;
|
|
1074
|
-
const ipNum = ip.octets[0] << 24 | ip.octets[1] << 16 | ip.octets[2] << 8 | ip.octets[3];
|
|
1075
|
-
const rangeNum = range.octets[0] << 24 | range.octets[1] << 16 | range.octets[2] << 8 | range.octets[3];
|
|
1076
|
-
const mask = maskBits === 0 ? 0 : ~((1 << 32 - maskBits) - 1);
|
|
1077
|
-
return (ipNum & mask) === (rangeNum & mask);
|
|
1078
1254
|
}
|
|
1079
|
-
function
|
|
1080
|
-
|
|
1081
|
-
return normalized === "::1" || normalized === "0:0:0:0:0:0:0:1";
|
|
1255
|
+
function isCryptoError(err) {
|
|
1256
|
+
return err !== null && typeof err === "object" && "name" in err && err.name === "CryptoError" && "code" in err && typeof err.code === "string" && err.code.startsWith("CRYPTO_") && "message" in err && typeof err.message === "string";
|
|
1082
1257
|
}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1258
|
+
var FORMAT_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
1259
|
+
"CRYPTO_INVALID_JWS_FORMAT",
|
|
1260
|
+
"CRYPTO_INVALID_TYP",
|
|
1261
|
+
"CRYPTO_INVALID_ALG",
|
|
1262
|
+
"CRYPTO_INVALID_KEY_LENGTH"
|
|
1263
|
+
]);
|
|
1264
|
+
var MAX_PARSE_ISSUES = 25;
|
|
1265
|
+
function sanitizeParseIssues(issues) {
|
|
1266
|
+
if (!Array.isArray(issues)) return void 0;
|
|
1267
|
+
return issues.slice(0, MAX_PARSE_ISSUES).map((issue2) => ({
|
|
1268
|
+
path: Array.isArray(issue2?.path) ? issue2.path.join(".") : "",
|
|
1269
|
+
message: typeof issue2?.message === "string" ? issue2.message : String(issue2)
|
|
1270
|
+
}));
|
|
1086
1271
|
}
|
|
1087
|
-
function
|
|
1088
|
-
const
|
|
1089
|
-
const
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
if (
|
|
1093
|
-
return {
|
|
1272
|
+
async function verifyLocal(jws, publicKey, options = {}) {
|
|
1273
|
+
const { issuer, audience, subjectUri, rid, requireExp = false, maxClockSkew = 300 } = options;
|
|
1274
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
1275
|
+
try {
|
|
1276
|
+
const result = await crypto.verify(jws, publicKey);
|
|
1277
|
+
if (!result.valid) {
|
|
1278
|
+
return {
|
|
1279
|
+
valid: false,
|
|
1280
|
+
code: "E_INVALID_SIGNATURE",
|
|
1281
|
+
message: "Ed25519 signature verification failed"
|
|
1282
|
+
};
|
|
1094
1283
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1284
|
+
const constraintResult = schema.validateKernelConstraints(result.payload);
|
|
1285
|
+
if (!constraintResult.valid) {
|
|
1286
|
+
const v = constraintResult.violations[0];
|
|
1287
|
+
return {
|
|
1288
|
+
valid: false,
|
|
1289
|
+
code: "E_CONSTRAINT_VIOLATION",
|
|
1290
|
+
message: `Kernel constraint violated: ${v.constraint} (actual: ${v.actual}, limit: ${v.limit})`
|
|
1291
|
+
};
|
|
1097
1292
|
}
|
|
1098
|
-
|
|
1099
|
-
|
|
1293
|
+
const pr = schema.parseReceiptClaims(result.payload);
|
|
1294
|
+
if (!pr.ok) {
|
|
1295
|
+
return {
|
|
1296
|
+
valid: false,
|
|
1297
|
+
code: "E_INVALID_FORMAT",
|
|
1298
|
+
message: `Receipt schema validation failed: ${pr.error.message}`,
|
|
1299
|
+
details: { parse_code: pr.error.code, issues: sanitizeParseIssues(pr.error.issues) }
|
|
1300
|
+
};
|
|
1100
1301
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
return { blocked: false };
|
|
1110
|
-
}
|
|
1111
|
-
async function resolveHostname(hostname) {
|
|
1112
|
-
if (typeof process !== "undefined" && process.versions?.node) {
|
|
1113
|
-
try {
|
|
1114
|
-
const dns = await import('dns');
|
|
1115
|
-
const { promisify } = await import('util');
|
|
1116
|
-
const resolve4 = promisify(dns.resolve4);
|
|
1117
|
-
const resolve6 = promisify(dns.resolve6);
|
|
1118
|
-
const results = [];
|
|
1119
|
-
let ipv4Error = null;
|
|
1120
|
-
let ipv6Error = null;
|
|
1121
|
-
try {
|
|
1122
|
-
const ipv4 = await resolve4(hostname);
|
|
1123
|
-
results.push(...ipv4);
|
|
1124
|
-
} catch (err) {
|
|
1125
|
-
ipv4Error = err;
|
|
1126
|
-
}
|
|
1127
|
-
try {
|
|
1128
|
-
const ipv6 = await resolve6(hostname);
|
|
1129
|
-
results.push(...ipv6);
|
|
1130
|
-
} catch (err) {
|
|
1131
|
-
ipv6Error = err;
|
|
1132
|
-
}
|
|
1133
|
-
if (results.length > 0) {
|
|
1134
|
-
return { ok: true, ips: results, browser: false };
|
|
1135
|
-
}
|
|
1136
|
-
if (ipv4Error && ipv6Error) {
|
|
1137
|
-
return {
|
|
1138
|
-
ok: false,
|
|
1139
|
-
message: `DNS resolution failed for ${hostname}: ${ipv4Error.message}`
|
|
1140
|
-
};
|
|
1141
|
-
}
|
|
1142
|
-
return { ok: true, ips: [], browser: false };
|
|
1143
|
-
} catch (err) {
|
|
1302
|
+
if (issuer !== void 0 && pr.claims.iss !== issuer) {
|
|
1303
|
+
return {
|
|
1304
|
+
valid: false,
|
|
1305
|
+
code: "E_INVALID_ISSUER",
|
|
1306
|
+
message: `Issuer mismatch: expected "${issuer}", got "${pr.claims.iss}"`
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
if (audience !== void 0 && pr.claims.aud !== audience) {
|
|
1144
1310
|
return {
|
|
1145
|
-
|
|
1146
|
-
|
|
1311
|
+
valid: false,
|
|
1312
|
+
code: "E_INVALID_AUDIENCE",
|
|
1313
|
+
message: `Audience mismatch: expected "${audience}", got "${pr.claims.aud}"`
|
|
1147
1314
|
};
|
|
1148
1315
|
}
|
|
1149
|
-
|
|
1150
|
-
return { ok: true, ips: [], browser: true };
|
|
1151
|
-
}
|
|
1152
|
-
async function ssrfSafeFetch(url, options = {}) {
|
|
1153
|
-
const {
|
|
1154
|
-
timeoutMs = kernel.VERIFIER_LIMITS.fetchTimeoutMs,
|
|
1155
|
-
maxBytes = kernel.VERIFIER_LIMITS.maxResponseBytes,
|
|
1156
|
-
maxRedirects = 0,
|
|
1157
|
-
allowRedirects = kernel.VERIFIER_NETWORK.allowRedirects,
|
|
1158
|
-
allowCrossOriginRedirects = true,
|
|
1159
|
-
// Default: allow for CDN compatibility
|
|
1160
|
-
dnsFailureBehavior = "block",
|
|
1161
|
-
// Default: fail-closed for security
|
|
1162
|
-
headers = {}
|
|
1163
|
-
} = options;
|
|
1164
|
-
let parsedUrl;
|
|
1165
|
-
try {
|
|
1166
|
-
parsedUrl = new URL(url);
|
|
1167
|
-
} catch {
|
|
1168
|
-
return {
|
|
1169
|
-
ok: false,
|
|
1170
|
-
reason: "invalid_url",
|
|
1171
|
-
message: `Invalid URL: ${url}`,
|
|
1172
|
-
blockedUrl: url
|
|
1173
|
-
};
|
|
1174
|
-
}
|
|
1175
|
-
if (parsedUrl.protocol !== "https:") {
|
|
1176
|
-
return {
|
|
1177
|
-
ok: false,
|
|
1178
|
-
reason: "not_https",
|
|
1179
|
-
message: `URL must use HTTPS: ${url}`,
|
|
1180
|
-
blockedUrl: url
|
|
1181
|
-
};
|
|
1182
|
-
}
|
|
1183
|
-
const dnsResult = await resolveHostname(parsedUrl.hostname);
|
|
1184
|
-
if (!dnsResult.ok) {
|
|
1185
|
-
if (dnsFailureBehavior === "block") {
|
|
1316
|
+
if (rid !== void 0 && pr.claims.rid !== rid) {
|
|
1186
1317
|
return {
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
message: `
|
|
1190
|
-
blockedUrl: url
|
|
1318
|
+
valid: false,
|
|
1319
|
+
code: "E_INVALID_RECEIPT_ID",
|
|
1320
|
+
message: `Receipt ID mismatch: expected "${rid}", got "${pr.claims.rid}"`
|
|
1191
1321
|
};
|
|
1192
1322
|
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
}
|
|
1200
|
-
if (!dnsResult.browser) {
|
|
1201
|
-
for (const ip of dnsResult.ips) {
|
|
1202
|
-
const blockResult = isBlockedIP(ip);
|
|
1203
|
-
if (blockResult.blocked) {
|
|
1204
|
-
return {
|
|
1205
|
-
ok: false,
|
|
1206
|
-
reason: blockResult.reason,
|
|
1207
|
-
message: `Blocked ${blockResult.reason} address: ${ip} for ${url}`,
|
|
1208
|
-
blockedUrl: url
|
|
1209
|
-
};
|
|
1210
|
-
}
|
|
1323
|
+
if (requireExp && pr.claims.exp === void 0) {
|
|
1324
|
+
return {
|
|
1325
|
+
valid: false,
|
|
1326
|
+
code: "E_MISSING_EXP",
|
|
1327
|
+
message: "Receipt missing required exp claim"
|
|
1328
|
+
};
|
|
1211
1329
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
clearTimeout(timeoutId);
|
|
1230
|
-
if (response.status >= 300 && response.status < 400) {
|
|
1231
|
-
const location = response.headers.get("location");
|
|
1232
|
-
if (!location) {
|
|
1233
|
-
return {
|
|
1234
|
-
ok: false,
|
|
1235
|
-
reason: "network_error",
|
|
1236
|
-
message: `Redirect without Location header from ${currentUrl}`
|
|
1237
|
-
};
|
|
1238
|
-
}
|
|
1239
|
-
if (!allowRedirects) {
|
|
1240
|
-
return {
|
|
1241
|
-
ok: false,
|
|
1242
|
-
reason: "too_many_redirects",
|
|
1243
|
-
message: `Redirects not allowed: ${currentUrl} -> ${location}`,
|
|
1244
|
-
blockedUrl: location
|
|
1245
|
-
};
|
|
1246
|
-
}
|
|
1247
|
-
redirectCount++;
|
|
1248
|
-
if (redirectCount > maxRedirects) {
|
|
1249
|
-
return {
|
|
1250
|
-
ok: false,
|
|
1251
|
-
reason: "too_many_redirects",
|
|
1252
|
-
message: `Too many redirects (${redirectCount} > ${maxRedirects})`,
|
|
1253
|
-
blockedUrl: location
|
|
1254
|
-
};
|
|
1255
|
-
}
|
|
1256
|
-
let redirectUrl;
|
|
1257
|
-
try {
|
|
1258
|
-
redirectUrl = new URL(location, currentUrl);
|
|
1259
|
-
} catch {
|
|
1260
|
-
return {
|
|
1261
|
-
ok: false,
|
|
1262
|
-
reason: "invalid_url",
|
|
1263
|
-
message: `Invalid redirect URL: ${location}`,
|
|
1264
|
-
blockedUrl: location
|
|
1265
|
-
};
|
|
1266
|
-
}
|
|
1267
|
-
if (redirectUrl.protocol !== "https:") {
|
|
1268
|
-
return {
|
|
1269
|
-
ok: false,
|
|
1270
|
-
reason: "scheme_downgrade",
|
|
1271
|
-
message: `HTTPS to HTTP downgrade not allowed: ${currentUrl} -> ${redirectUrl.href}`,
|
|
1272
|
-
blockedUrl: redirectUrl.href
|
|
1273
|
-
};
|
|
1274
|
-
}
|
|
1275
|
-
if (redirectUrl.origin !== originalOrigin && !allowCrossOriginRedirects) {
|
|
1276
|
-
return {
|
|
1277
|
-
ok: false,
|
|
1278
|
-
reason: "cross_origin_redirect",
|
|
1279
|
-
message: `Cross-origin redirect not allowed: ${originalOrigin} -> ${redirectUrl.origin}`,
|
|
1280
|
-
blockedUrl: redirectUrl.href
|
|
1281
|
-
};
|
|
1282
|
-
}
|
|
1283
|
-
const redirectDnsResult = await resolveHostname(redirectUrl.hostname);
|
|
1284
|
-
if (!redirectDnsResult.ok) {
|
|
1285
|
-
if (dnsFailureBehavior === "block") {
|
|
1286
|
-
return {
|
|
1287
|
-
ok: false,
|
|
1288
|
-
reason: "dns_failure",
|
|
1289
|
-
message: `Redirect DNS resolution blocked: ${redirectDnsResult.message}`,
|
|
1290
|
-
blockedUrl: redirectUrl.href
|
|
1291
|
-
};
|
|
1292
|
-
}
|
|
1293
|
-
return {
|
|
1294
|
-
ok: false,
|
|
1295
|
-
reason: "network_error",
|
|
1296
|
-
message: redirectDnsResult.message,
|
|
1297
|
-
blockedUrl: redirectUrl.href
|
|
1298
|
-
};
|
|
1299
|
-
}
|
|
1300
|
-
if (!redirectDnsResult.browser) {
|
|
1301
|
-
for (const ip of redirectDnsResult.ips) {
|
|
1302
|
-
const blockResult = isBlockedIP(ip);
|
|
1303
|
-
if (blockResult.blocked) {
|
|
1304
|
-
return {
|
|
1305
|
-
ok: false,
|
|
1306
|
-
reason: blockResult.reason,
|
|
1307
|
-
message: `Redirect to blocked ${blockResult.reason} address: ${ip}`,
|
|
1308
|
-
blockedUrl: redirectUrl.href
|
|
1309
|
-
};
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
currentUrl = redirectUrl.href;
|
|
1314
|
-
continue;
|
|
1315
|
-
}
|
|
1316
|
-
const contentLength = response.headers.get("content-length");
|
|
1317
|
-
if (contentLength && parseInt(contentLength, 10) > maxBytes) {
|
|
1330
|
+
if (pr.claims.iat > now + maxClockSkew) {
|
|
1331
|
+
return {
|
|
1332
|
+
valid: false,
|
|
1333
|
+
code: "E_NOT_YET_VALID",
|
|
1334
|
+
message: `Receipt not yet valid: issued at ${new Date(pr.claims.iat * 1e3).toISOString()}, now is ${new Date(now * 1e3).toISOString()}`
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
if (pr.claims.exp !== void 0 && pr.claims.exp < now - maxClockSkew) {
|
|
1338
|
+
return {
|
|
1339
|
+
valid: false,
|
|
1340
|
+
code: "E_EXPIRED",
|
|
1341
|
+
message: `Receipt expired at ${new Date(pr.claims.exp * 1e3).toISOString()}`
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
if (pr.variant === "commerce") {
|
|
1345
|
+
const claims = pr.claims;
|
|
1346
|
+
if (subjectUri !== void 0 && claims.subject?.uri !== subjectUri) {
|
|
1318
1347
|
return {
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
message: `
|
|
1348
|
+
valid: false,
|
|
1349
|
+
code: "E_INVALID_SUBJECT",
|
|
1350
|
+
message: `Subject mismatch: expected "${subjectUri}", got "${claims.subject?.uri ?? "undefined"}"`
|
|
1322
1351
|
};
|
|
1323
1352
|
}
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
const rawBytes2 = new TextEncoder().encode(body2);
|
|
1353
|
+
return {
|
|
1354
|
+
valid: true,
|
|
1355
|
+
variant: "commerce",
|
|
1356
|
+
claims,
|
|
1357
|
+
kid: result.header.kid,
|
|
1358
|
+
policy_binding: "unavailable"
|
|
1359
|
+
};
|
|
1360
|
+
} else {
|
|
1361
|
+
const claims = pr.claims;
|
|
1362
|
+
if (subjectUri !== void 0 && claims.sub !== subjectUri) {
|
|
1335
1363
|
return {
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
};
|
|
1342
|
-
}
|
|
1343
|
-
const chunks = [];
|
|
1344
|
-
let totalSize = 0;
|
|
1345
|
-
while (true) {
|
|
1346
|
-
const { done, value } = await reader.read();
|
|
1347
|
-
if (done) break;
|
|
1348
|
-
totalSize += value.length;
|
|
1349
|
-
if (totalSize > maxBytes) {
|
|
1350
|
-
reader.cancel();
|
|
1351
|
-
return {
|
|
1352
|
-
ok: false,
|
|
1353
|
-
reason: "response_too_large",
|
|
1354
|
-
message: `Response too large: ${totalSize} bytes > ${maxBytes} max`
|
|
1355
|
-
};
|
|
1356
|
-
}
|
|
1357
|
-
chunks.push(value);
|
|
1358
|
-
}
|
|
1359
|
-
const rawBytes = chunks.reduce((acc, chunk) => {
|
|
1360
|
-
const result = new Uint8Array(acc.length + chunk.length);
|
|
1361
|
-
result.set(acc);
|
|
1362
|
-
result.set(chunk, acc.length);
|
|
1363
|
-
return result;
|
|
1364
|
-
}, new Uint8Array());
|
|
1365
|
-
const body = new TextDecoder().decode(rawBytes);
|
|
1364
|
+
valid: false,
|
|
1365
|
+
code: "E_INVALID_SUBJECT",
|
|
1366
|
+
message: `Subject mismatch: expected "${subjectUri}", got "${claims.sub ?? "undefined"}"`
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1366
1369
|
return {
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1370
|
+
valid: true,
|
|
1371
|
+
variant: "attestation",
|
|
1372
|
+
claims,
|
|
1373
|
+
kid: result.header.kid,
|
|
1374
|
+
policy_binding: "unavailable"
|
|
1372
1375
|
};
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
}
|
|
1376
|
+
}
|
|
1377
|
+
} catch (err) {
|
|
1378
|
+
if (isCryptoError(err)) {
|
|
1379
|
+
if (FORMAT_ERROR_CODES.has(err.code)) {
|
|
1380
|
+
return {
|
|
1381
|
+
valid: false,
|
|
1382
|
+
code: "E_INVALID_FORMAT",
|
|
1383
|
+
message: err.message
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
if (err.code === "CRYPTO_INVALID_SIGNATURE") {
|
|
1387
|
+
return {
|
|
1388
|
+
valid: false,
|
|
1389
|
+
code: "E_INVALID_SIGNATURE",
|
|
1390
|
+
message: err.message
|
|
1391
|
+
};
|
|
1382
1392
|
}
|
|
1393
|
+
}
|
|
1394
|
+
if (err !== null && typeof err === "object" && "name" in err && err.name === "SyntaxError") {
|
|
1395
|
+
const syntaxMessage = "message" in err && typeof err.message === "string" ? err.message : "Invalid JSON";
|
|
1383
1396
|
return {
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
message: `
|
|
1397
|
+
valid: false,
|
|
1398
|
+
code: "E_INVALID_FORMAT",
|
|
1399
|
+
message: `Invalid receipt payload: ${syntaxMessage}`
|
|
1387
1400
|
};
|
|
1388
1401
|
}
|
|
1402
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1403
|
+
return {
|
|
1404
|
+
valid: false,
|
|
1405
|
+
code: "E_INTERNAL",
|
|
1406
|
+
message: `Unexpected verification error: ${message}`
|
|
1407
|
+
};
|
|
1389
1408
|
}
|
|
1390
1409
|
}
|
|
1391
|
-
|
|
1392
|
-
return
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1410
|
+
function isCommerceResult(r) {
|
|
1411
|
+
return r.valid === true && r.variant === "commerce";
|
|
1412
|
+
}
|
|
1413
|
+
function isAttestationResult(r) {
|
|
1414
|
+
return r.valid === true && r.variant === "attestation";
|
|
1415
|
+
}
|
|
1416
|
+
function setReceiptHeader(headers, receiptJws) {
|
|
1417
|
+
headers.set(schema.PEAC_RECEIPT_HEADER, receiptJws);
|
|
1418
|
+
}
|
|
1419
|
+
function getReceiptHeader(headers) {
|
|
1420
|
+
return headers.get(schema.PEAC_RECEIPT_HEADER);
|
|
1421
|
+
}
|
|
1422
|
+
function setVaryHeader(headers) {
|
|
1423
|
+
const existing = headers.get("Vary");
|
|
1424
|
+
if (existing) {
|
|
1425
|
+
const varies = existing.split(",").map((v) => v.trim());
|
|
1426
|
+
if (!varies.includes(schema.PEAC_RECEIPT_HEADER)) {
|
|
1427
|
+
headers.set("Vary", `${existing}, ${schema.PEAC_RECEIPT_HEADER}`);
|
|
1398
1428
|
}
|
|
1399
|
-
}
|
|
1429
|
+
} else {
|
|
1430
|
+
headers.set("Vary", schema.PEAC_RECEIPT_HEADER);
|
|
1431
|
+
}
|
|
1400
1432
|
}
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1433
|
+
function getPurposeHeader(headers) {
|
|
1434
|
+
const value = headers.get(schema.PEAC_PURPOSE_HEADER);
|
|
1435
|
+
if (!value) {
|
|
1436
|
+
return [];
|
|
1437
|
+
}
|
|
1438
|
+
return schema.parsePurposeHeader(value);
|
|
1439
|
+
}
|
|
1440
|
+
function setPurposeAppliedHeader(headers, purpose) {
|
|
1441
|
+
headers.set(schema.PEAC_PURPOSE_APPLIED_HEADER, purpose);
|
|
1442
|
+
}
|
|
1443
|
+
function setPurposeReasonHeader(headers, reason) {
|
|
1444
|
+
headers.set(schema.PEAC_PURPOSE_REASON_HEADER, reason);
|
|
1445
|
+
}
|
|
1446
|
+
function setVaryPurposeHeader(headers) {
|
|
1447
|
+
const existing = headers.get("Vary");
|
|
1448
|
+
if (existing) {
|
|
1449
|
+
const varies = existing.split(",").map((v) => v.trim());
|
|
1450
|
+
if (!varies.includes(schema.PEAC_PURPOSE_HEADER)) {
|
|
1451
|
+
headers.set("Vary", `${existing}, ${schema.PEAC_PURPOSE_HEADER}`);
|
|
1408
1452
|
}
|
|
1409
|
-
}
|
|
1453
|
+
} else {
|
|
1454
|
+
headers.set("Vary", schema.PEAC_PURPOSE_HEADER);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
var DEFAULT_VERIFIER_LIMITS = {
|
|
1458
|
+
max_receipt_bytes: kernel.VERIFIER_LIMITS.maxReceiptBytes,
|
|
1459
|
+
max_jwks_bytes: kernel.VERIFIER_LIMITS.maxJwksBytes,
|
|
1460
|
+
max_jwks_keys: kernel.VERIFIER_LIMITS.maxJwksKeys,
|
|
1461
|
+
max_redirects: kernel.VERIFIER_LIMITS.maxRedirects,
|
|
1462
|
+
fetch_timeout_ms: kernel.VERIFIER_LIMITS.fetchTimeoutMs,
|
|
1463
|
+
max_extension_bytes: kernel.VERIFIER_LIMITS.maxExtensionBytes
|
|
1464
|
+
};
|
|
1465
|
+
var DEFAULT_NETWORK_SECURITY = {
|
|
1466
|
+
https_only: kernel.VERIFIER_NETWORK.httpsOnly,
|
|
1467
|
+
block_private_ips: kernel.VERIFIER_NETWORK.blockPrivateIps,
|
|
1468
|
+
allow_redirects: kernel.VERIFIER_NETWORK.allowRedirects,
|
|
1469
|
+
allow_cross_origin_redirects: true,
|
|
1470
|
+
// Allow for CDN compatibility
|
|
1471
|
+
dns_failure_behavior: "block"
|
|
1472
|
+
// Fail-closed by default
|
|
1473
|
+
};
|
|
1474
|
+
function createDefaultPolicy(mode) {
|
|
1475
|
+
return {
|
|
1476
|
+
policy_version: kernel.VERIFIER_POLICY_VERSION,
|
|
1477
|
+
mode,
|
|
1478
|
+
limits: { ...DEFAULT_VERIFIER_LIMITS },
|
|
1479
|
+
network: { ...DEFAULT_NETWORK_SECURITY }
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
var CHECK_IDS = [
|
|
1483
|
+
"jws.parse",
|
|
1484
|
+
"limits.receipt_bytes",
|
|
1485
|
+
"jws.protected_header",
|
|
1486
|
+
"claims.schema_unverified",
|
|
1487
|
+
"issuer.trust_policy",
|
|
1488
|
+
"issuer.discovery",
|
|
1489
|
+
"key.resolve",
|
|
1490
|
+
"jws.signature",
|
|
1491
|
+
"claims.time_window",
|
|
1492
|
+
"extensions.limits",
|
|
1493
|
+
"transport.profile_binding",
|
|
1494
|
+
"policy.binding"
|
|
1495
|
+
];
|
|
1496
|
+
var NON_DETERMINISTIC_ARTIFACT_KEYS = [
|
|
1497
|
+
"issuer_jwks_digest"
|
|
1498
|
+
];
|
|
1499
|
+
function createDigest(hexValue) {
|
|
1500
|
+
return {
|
|
1501
|
+
alg: "sha-256",
|
|
1502
|
+
value: hexValue.toLowerCase()
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
function createEmptyReport(policy) {
|
|
1506
|
+
return {
|
|
1507
|
+
report_version: kernel.VERIFICATION_REPORT_VERSION,
|
|
1508
|
+
policy
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
function ssrfErrorToReasonCode(ssrfReason, fetchType) {
|
|
1512
|
+
const prefix = fetchType === "key" ? "key_fetch" : "pointer_fetch";
|
|
1513
|
+
switch (ssrfReason) {
|
|
1514
|
+
case "not_https":
|
|
1515
|
+
case "private_ip":
|
|
1516
|
+
case "loopback":
|
|
1517
|
+
case "link_local":
|
|
1518
|
+
case "cross_origin_redirect":
|
|
1519
|
+
case "dns_failure":
|
|
1520
|
+
return `${prefix}_blocked`;
|
|
1521
|
+
case "timeout":
|
|
1522
|
+
return `${prefix}_timeout`;
|
|
1523
|
+
case "response_too_large":
|
|
1524
|
+
return fetchType === "pointer" ? "pointer_fetch_too_large" : "jwks_too_large";
|
|
1525
|
+
case "jwks_too_many_keys":
|
|
1526
|
+
return "jwks_too_many_keys";
|
|
1527
|
+
case "too_many_redirects":
|
|
1528
|
+
case "scheme_downgrade":
|
|
1529
|
+
case "network_error":
|
|
1530
|
+
case "invalid_url":
|
|
1531
|
+
default:
|
|
1532
|
+
return `${prefix}_failed`;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
function reasonCodeToSeverity(reason) {
|
|
1536
|
+
if (reason === "ok") return "info";
|
|
1537
|
+
return "error";
|
|
1538
|
+
}
|
|
1539
|
+
function reasonCodeToErrorCode(reason) {
|
|
1540
|
+
const mapping = {
|
|
1541
|
+
ok: "",
|
|
1542
|
+
receipt_too_large: "E_VERIFY_RECEIPT_TOO_LARGE",
|
|
1543
|
+
malformed_receipt: "E_VERIFY_MALFORMED_RECEIPT",
|
|
1544
|
+
signature_invalid: "E_VERIFY_SIGNATURE_INVALID",
|
|
1545
|
+
issuer_not_allowed: "E_VERIFY_ISSUER_NOT_ALLOWED",
|
|
1546
|
+
key_not_found: "E_VERIFY_KEY_NOT_FOUND",
|
|
1547
|
+
key_fetch_blocked: "E_VERIFY_KEY_FETCH_BLOCKED",
|
|
1548
|
+
key_fetch_failed: "E_VERIFY_KEY_FETCH_FAILED",
|
|
1549
|
+
key_fetch_timeout: "E_VERIFY_KEY_FETCH_TIMEOUT",
|
|
1550
|
+
pointer_fetch_blocked: "E_VERIFY_POINTER_FETCH_BLOCKED",
|
|
1551
|
+
pointer_fetch_failed: "E_VERIFY_POINTER_FETCH_FAILED",
|
|
1552
|
+
pointer_fetch_timeout: "E_VERIFY_POINTER_FETCH_TIMEOUT",
|
|
1553
|
+
pointer_fetch_too_large: "E_VERIFY_POINTER_FETCH_TOO_LARGE",
|
|
1554
|
+
pointer_digest_mismatch: "E_VERIFY_POINTER_DIGEST_MISMATCH",
|
|
1555
|
+
jwks_too_large: "E_VERIFY_JWKS_TOO_LARGE",
|
|
1556
|
+
jwks_too_many_keys: "E_VERIFY_JWKS_TOO_MANY_KEYS",
|
|
1557
|
+
expired: "E_VERIFY_EXPIRED",
|
|
1558
|
+
not_yet_valid: "E_VERIFY_NOT_YET_VALID",
|
|
1559
|
+
audience_mismatch: "E_VERIFY_AUDIENCE_MISMATCH",
|
|
1560
|
+
schema_invalid: "E_VERIFY_SCHEMA_INVALID",
|
|
1561
|
+
policy_violation: "E_VERIFY_POLICY_VIOLATION",
|
|
1562
|
+
extension_too_large: "E_VERIFY_EXTENSION_TOO_LARGE",
|
|
1563
|
+
invalid_transport: "E_VERIFY_INVALID_TRANSPORT"
|
|
1564
|
+
};
|
|
1565
|
+
return mapping[reason] || "E_VERIFY_POLICY_VIOLATION";
|
|
1410
1566
|
}
|
|
1411
1567
|
var VerificationReportBuilder = class {
|
|
1412
1568
|
state;
|
|
@@ -1675,8 +1831,6 @@ async function buildSuccessReport(policy, receiptBytes, issuer, kid, checkDetail
|
|
|
1675
1831
|
}
|
|
1676
1832
|
|
|
1677
1833
|
// src/verifier-core.ts
|
|
1678
|
-
var jwksCache2 = /* @__PURE__ */ new Map();
|
|
1679
|
-
var CACHE_TTL_MS2 = 5 * 60 * 1e3;
|
|
1680
1834
|
function normalizeIssuer(issuer) {
|
|
1681
1835
|
try {
|
|
1682
1836
|
const url = new URL(issuer);
|
|
@@ -1702,75 +1856,23 @@ function findPinnedKey(issuer, kid, pinnedKeys) {
|
|
|
1702
1856
|
const normalizedIssuer = normalizeIssuer(issuer);
|
|
1703
1857
|
return pinnedKeys.find((pk) => normalizeIssuer(pk.issuer) === normalizedIssuer && pk.kid === kid);
|
|
1704
1858
|
}
|
|
1705
|
-
async function fetchIssuerConfig2(issuerOrigin) {
|
|
1706
|
-
const configUrl = `${issuerOrigin}/.well-known/peac-issuer.json`;
|
|
1707
|
-
const result = await ssrfSafeFetch(configUrl, {
|
|
1708
|
-
maxBytes: 65536,
|
|
1709
|
-
// 64 KB
|
|
1710
|
-
headers: { Accept: "application/json" }
|
|
1711
|
-
});
|
|
1712
|
-
if (!result.ok) {
|
|
1713
|
-
return null;
|
|
1714
|
-
}
|
|
1715
|
-
try {
|
|
1716
|
-
return JSON.parse(result.body);
|
|
1717
|
-
} catch {
|
|
1718
|
-
return null;
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
1859
|
async function fetchIssuerJWKS(issuerOrigin) {
|
|
1722
|
-
const
|
|
1723
|
-
const cached = jwksCache2.get(issuerOrigin);
|
|
1724
|
-
if (cached && cached.expiresAt > now) {
|
|
1725
|
-
return { jwks: cached.jwks, fromCache: true };
|
|
1726
|
-
}
|
|
1727
|
-
const config = await fetchIssuerConfig2(issuerOrigin);
|
|
1728
|
-
if (!config?.jwks_uri) {
|
|
1729
|
-
const fallbackUrl = `${issuerOrigin}/.well-known/jwks.json`;
|
|
1730
|
-
const result2 = await fetchJWKSSafe(fallbackUrl);
|
|
1731
|
-
if (!result2.ok) {
|
|
1732
|
-
return { error: result2 };
|
|
1733
|
-
}
|
|
1734
|
-
try {
|
|
1735
|
-
const jwks = JSON.parse(result2.body);
|
|
1736
|
-
jwksCache2.set(issuerOrigin, { jwks, expiresAt: now + CACHE_TTL_MS2 });
|
|
1737
|
-
return { jwks, fromCache: false, rawBytes: result2.rawBytes };
|
|
1738
|
-
} catch {
|
|
1739
|
-
return {
|
|
1740
|
-
error: {
|
|
1741
|
-
ok: false,
|
|
1742
|
-
reason: "network_error",
|
|
1743
|
-
message: "Invalid JWKS JSON"
|
|
1744
|
-
}
|
|
1745
|
-
};
|
|
1746
|
-
}
|
|
1747
|
-
}
|
|
1748
|
-
const result = await fetchJWKSSafe(config.jwks_uri);
|
|
1860
|
+
const result = await resolveJWKS(issuerOrigin);
|
|
1749
1861
|
if (!result.ok) {
|
|
1750
|
-
return { error: result };
|
|
1751
|
-
}
|
|
1752
|
-
try {
|
|
1753
|
-
const jwks = JSON.parse(result.body);
|
|
1754
|
-
if (jwks.keys.length > kernel.VERIFIER_LIMITS.maxJwksKeys) {
|
|
1755
|
-
return {
|
|
1756
|
-
error: {
|
|
1757
|
-
ok: false,
|
|
1758
|
-
reason: "jwks_too_many_keys",
|
|
1759
|
-
message: `JWKS has too many keys: ${jwks.keys.length} > ${kernel.VERIFIER_LIMITS.maxJwksKeys}`
|
|
1760
|
-
}
|
|
1761
|
-
};
|
|
1762
|
-
}
|
|
1763
|
-
jwksCache2.set(issuerOrigin, { jwks, expiresAt: now + CACHE_TTL_MS2 });
|
|
1764
|
-
return { jwks, fromCache: false, rawBytes: result.rawBytes };
|
|
1765
|
-
} catch {
|
|
1766
1862
|
return {
|
|
1767
1863
|
error: {
|
|
1768
1864
|
ok: false,
|
|
1769
|
-
reason: "network_error",
|
|
1770
|
-
message:
|
|
1865
|
+
reason: result.reason ?? "network_error",
|
|
1866
|
+
message: result.message,
|
|
1867
|
+
blockedUrl: result.blockedUrl
|
|
1771
1868
|
}
|
|
1772
1869
|
};
|
|
1773
1870
|
}
|
|
1871
|
+
return {
|
|
1872
|
+
jwks: result.jwks,
|
|
1873
|
+
fromCache: result.fromCache,
|
|
1874
|
+
rawBytes: result.rawBytes
|
|
1875
|
+
};
|
|
1774
1876
|
}
|
|
1775
1877
|
async function verifyReceiptCore(options) {
|
|
1776
1878
|
const {
|
|
@@ -2107,12 +2209,6 @@ async function verifyReceiptCore(options) {
|
|
|
2107
2209
|
claims: parsedClaims
|
|
2108
2210
|
};
|
|
2109
2211
|
}
|
|
2110
|
-
function clearJWKSCache() {
|
|
2111
|
-
jwksCache2.clear();
|
|
2112
|
-
}
|
|
2113
|
-
function getJWKSCacheSize() {
|
|
2114
|
-
return jwksCache2.size;
|
|
2115
|
-
}
|
|
2116
2212
|
|
|
2117
2213
|
// src/transport-profiles.ts
|
|
2118
2214
|
function parseHeaderProfile(headerValue) {
|
|
@@ -2704,6 +2800,7 @@ exports.parseTransportProfile = parseTransportProfile;
|
|
|
2704
2800
|
exports.reasonCodeToErrorCode = reasonCodeToErrorCode;
|
|
2705
2801
|
exports.reasonCodeToSeverity = reasonCodeToSeverity;
|
|
2706
2802
|
exports.resetSSRFCapabilitiesCache = resetSSRFCapabilitiesCache;
|
|
2803
|
+
exports.resolveJWKS = resolveJWKS;
|
|
2707
2804
|
exports.setPurposeAppliedHeader = setPurposeAppliedHeader;
|
|
2708
2805
|
exports.setPurposeReasonHeader = setPurposeReasonHeader;
|
|
2709
2806
|
exports.setReceiptHeader = setReceiptHeader;
|