@apifuse/provider-sdk 2.2.0-beta.24 → 2.2.0-beta.26
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/AUTHORING.md +7 -6
- package/CHANGELOG.md +9 -1
- package/README.md +3 -3
- package/bin/apifuse-check.ts +62 -3
- package/bin/apifuse-pack-check.ts +8 -2
- package/bin/apifuse-pack-smoke.ts +43 -2
- package/bin/apifuse-pack-types.ts +58 -0
- package/bin/apifuse-submit-check.ts +15 -2
- package/dist/auth.js +29 -0
- package/dist/cli/templates/provider/README.md.tpl +4 -4
- package/dist/contract-serialization.d.ts +20 -1
- package/dist/contract-serialization.js +583 -8
- package/dist/contract.d.ts +2 -0
- package/dist/contract.js +9 -5
- package/dist/declaration-validation.d.ts +23 -0
- package/dist/declaration-validation.js +159 -0
- package/dist/define.d.ts +1 -1
- package/dist/define.js +13 -2
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -3
- package/dist/lint.js +85 -3
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +1 -1
- package/dist/runtime/cache.d.ts +1 -0
- package/dist/runtime/cache.js +169 -15
- package/dist/runtime/resolver-vendors/bindings.d.ts +42 -2
- package/dist/runtime/resolver-vendors/bindings.js +31 -6
- package/dist/runtime/resolver-vendors/browser.d.ts +3 -7
- package/dist/runtime/resolver-vendors/browser.js +7 -22
- package/dist/runtime/resolver-vendors/hosts.d.ts +2 -0
- package/dist/runtime/resolver-vendors/hosts.js +33 -0
- package/dist/runtime/resolver-vendors/twocaptcha.d.ts +23 -0
- package/dist/runtime/resolver-vendors/twocaptcha.js +264 -0
- package/dist/runtime/resolver-vendors/types.d.ts +44 -3
- package/dist/runtime/resolver-vendors/types.js +10 -0
- package/dist/runtime/resolver.d.ts +17 -2
- package/dist/runtime/resolver.js +237 -15
- package/dist/runtime/stealth.d.ts +26 -4
- package/dist/runtime/stealth.js +224 -114
- package/dist/schema.d.ts +63 -0
- package/dist/schema.js +808 -8
- package/dist/server/serve.js +8 -0
- package/dist/stealth/profiles.js +16 -7
- package/dist/types.d.ts +37 -4
- package/package.json +2 -2
- package/src/auth.ts +40 -0
- package/src/cli/templates/provider/README.md.tpl +4 -4
- package/src/contract-serialization.ts +857 -8
- package/src/contract.ts +16 -5
- package/src/declaration-validation.ts +202 -0
- package/src/define.ts +23 -2
- package/src/index.ts +13 -0
- package/src/lint.ts +98 -3
- package/src/provider.ts +10 -0
- package/src/runtime/cache.ts +189 -14
- package/src/runtime/resolver-vendors/bindings.ts +40 -15
- package/src/runtime/resolver-vendors/browser.ts +9 -31
- package/src/runtime/resolver-vendors/hosts.ts +38 -0
- package/src/runtime/resolver-vendors/twocaptcha.ts +366 -0
- package/src/runtime/resolver-vendors/types.ts +54 -0
- package/src/runtime/resolver.ts +304 -24
- package/src/runtime/stealth.ts +317 -136
- package/src/schema.ts +1060 -9
- package/src/server/serve.ts +8 -0
- package/src/stealth/profiles.ts +17 -7
- package/src/types.ts +39 -6
package/src/runtime/cache.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
2
|
|
|
3
3
|
import { providerCacheRedisUrlFromEnv } from "../config/loader.js";
|
|
4
|
+
import { ProviderError } from "../errors.js";
|
|
4
5
|
import type {
|
|
5
6
|
ProviderCache,
|
|
6
7
|
ProviderCacheGetOrSetOptions,
|
|
@@ -43,9 +44,12 @@ export type ProviderCacheOptions = {
|
|
|
43
44
|
now?: () => number;
|
|
44
45
|
};
|
|
45
46
|
|
|
47
|
+
export const APIFUSE__CACHE__KEY_PEPPER_ENV = "APIFUSE__CACHE__KEY_PEPPER";
|
|
48
|
+
|
|
46
49
|
const DEFAULT_PREFIX = "apifuse:provider-cache:v1";
|
|
47
50
|
const DEFAULT_MEMORY_MAX_ENTRIES = 1_000;
|
|
48
51
|
const DEFAULT_REDIS_TIMEOUT_MS = 150;
|
|
52
|
+
const SECRET_SCOPED_KEY_MARKER = "[secret-scoped";
|
|
49
53
|
const SECRET_FIELD_NAMES = new Set([
|
|
50
54
|
"authorization",
|
|
51
55
|
"cookie",
|
|
@@ -61,6 +65,7 @@ const SECRET_FIELD_NAMES = new Set([
|
|
|
61
65
|
]);
|
|
62
66
|
|
|
63
67
|
const sharedBackends = new Map<string, SharedCacheBackend>();
|
|
68
|
+
let warnedAboutUnpepperedSecretKeys = false;
|
|
64
69
|
|
|
65
70
|
function backendKey(redisUrl: string | undefined): string {
|
|
66
71
|
return redisUrl ?? "memory";
|
|
@@ -107,19 +112,178 @@ function shouldRedactField(name: string, extra: Set<string>): boolean {
|
|
|
107
112
|
);
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
|
|
115
|
+
type NormalizedKeyPart = {
|
|
116
|
+
value: unknown;
|
|
117
|
+
secretScoped: boolean;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function unsupportedSecretValue(path: string, reason: string): never {
|
|
121
|
+
throw new ProviderError(`Secret cache-key values must be JSON-safe; ${reason} at ${path}.`, {
|
|
122
|
+
code: "CACHE_KEY_SECRET_VALUE_UNSUPPORTED",
|
|
123
|
+
fix: "Convert the secret cache-key selector to JSON-safe primitives, arrays, or plain objects.",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function assertJsonSafeSecretValue(
|
|
128
|
+
value: unknown,
|
|
129
|
+
reportedPath: string,
|
|
130
|
+
ancestors = new Set<object>(),
|
|
131
|
+
): void {
|
|
132
|
+
if (value === undefined) return;
|
|
133
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
|
134
|
+
if (typeof value === "number") {
|
|
135
|
+
if (!Number.isFinite(value))
|
|
136
|
+
unsupportedSecretValue(reportedPath, "non-finite numbers are unsupported");
|
|
137
|
+
if (Object.is(value, -0))
|
|
138
|
+
unsupportedSecretValue(reportedPath, "negative zero is unsupported");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (typeof value !== "object") {
|
|
142
|
+
unsupportedSecretValue(reportedPath, `${typeof value} values are unsupported`);
|
|
143
|
+
}
|
|
144
|
+
if (ancestors.has(value))
|
|
145
|
+
unsupportedSecretValue(reportedPath, "cyclic values are unsupported");
|
|
146
|
+
|
|
147
|
+
ancestors.add(value);
|
|
148
|
+
try {
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
151
|
+
unsupportedSecretValue(reportedPath, "symbol-keyed array properties are unsupported");
|
|
152
|
+
}
|
|
153
|
+
const expectedNames = new Set(["length"]);
|
|
154
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
155
|
+
const key = String(index);
|
|
156
|
+
expectedNames.add(key);
|
|
157
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
158
|
+
if (!descriptor)
|
|
159
|
+
unsupportedSecretValue(reportedPath, "sparse arrays are unsupported");
|
|
160
|
+
if (!descriptor.enumerable || !("value" in descriptor)) {
|
|
161
|
+
unsupportedSecretValue(reportedPath, "array accessors are unsupported");
|
|
162
|
+
}
|
|
163
|
+
assertJsonSafeSecretValue(descriptor.value, reportedPath, ancestors);
|
|
164
|
+
}
|
|
165
|
+
if (Object.getOwnPropertyNames(value).some((name) => !expectedNames.has(name))) {
|
|
166
|
+
unsupportedSecretValue(reportedPath, "custom array properties are unsupported");
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const prototype = Object.getPrototypeOf(value);
|
|
172
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
173
|
+
unsupportedSecretValue(reportedPath, "non-plain objects are unsupported");
|
|
174
|
+
}
|
|
175
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
176
|
+
unsupportedSecretValue(reportedPath, "symbol-keyed properties are unsupported");
|
|
177
|
+
}
|
|
178
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
179
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
180
|
+
if (!descriptor?.enumerable || !("value" in descriptor)) {
|
|
181
|
+
unsupportedSecretValue(
|
|
182
|
+
reportedPath,
|
|
183
|
+
"non-enumerable properties and accessors are unsupported",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
assertJsonSafeSecretValue(descriptor.value, reportedPath, ancestors);
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
ancestors.delete(value);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function containsUndefined(value: unknown): boolean {
|
|
194
|
+
if (value === undefined) return true;
|
|
195
|
+
if (Array.isArray(value)) return value.some(containsUndefined);
|
|
196
|
+
if (isRecord(value)) return Object.values(value).some(containsUndefined);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function tagSecretValue(value: unknown): unknown {
|
|
201
|
+
if (value === undefined) return ["undefined"];
|
|
202
|
+
if (value === null) return ["null"];
|
|
203
|
+
if (typeof value === "string") return ["string", value];
|
|
204
|
+
if (typeof value === "number") return ["number", value];
|
|
205
|
+
if (typeof value === "boolean") return ["boolean", value];
|
|
206
|
+
if (Array.isArray(value)) return ["array", value.map(tagSecretValue)];
|
|
207
|
+
return [
|
|
208
|
+
"object",
|
|
209
|
+
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
|
210
|
+
key,
|
|
211
|
+
tagSecretValue(entry),
|
|
212
|
+
]),
|
|
213
|
+
];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function serializeSecretValue(value: unknown): string {
|
|
217
|
+
if (!containsUndefined(value)) return JSON.stringify([value]);
|
|
218
|
+
return `undefined-v1:${JSON.stringify(tagSecretValue(value))}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function warnAboutUnpepperedSecretKey(): void {
|
|
222
|
+
if (warnedAboutUnpepperedSecretKeys) return;
|
|
223
|
+
warnedAboutUnpepperedSecretKeys = true;
|
|
224
|
+
console.warn(
|
|
225
|
+
JSON.stringify({
|
|
226
|
+
level: "warn",
|
|
227
|
+
event: "provider_cache_secret_key_unpeppered",
|
|
228
|
+
message: `Secret-bearing cache keys are using unkeyed SHA-256 because ${APIFUSE__CACHE__KEY_PEPPER_ENV} is not configured.`,
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function normalizeKeyPart(
|
|
234
|
+
value: unknown,
|
|
235
|
+
extra: Set<string>,
|
|
236
|
+
pepper: string | undefined,
|
|
237
|
+
): NormalizedKeyPart {
|
|
111
238
|
if (Array.isArray(value)) {
|
|
112
|
-
|
|
239
|
+
const entries = value.map((entry) => normalizeKeyPart(entry, extra, pepper));
|
|
240
|
+
return {
|
|
241
|
+
value: entries.map((entry) => entry.value),
|
|
242
|
+
secretScoped: entries.some((entry) => entry.secretScoped),
|
|
243
|
+
};
|
|
113
244
|
}
|
|
114
245
|
if (isRecord(value)) {
|
|
115
|
-
const normalized: Record<string, unknown> =
|
|
246
|
+
const normalized: Record<string, unknown> = Object.create(null);
|
|
247
|
+
let secretScoped = false;
|
|
116
248
|
for (const key of Object.keys(value).sort()) {
|
|
117
|
-
|
|
118
|
-
|
|
249
|
+
const part = shouldRedactField(key, extra)
|
|
250
|
+
? hashSecretValue(value[key], key, extra, pepper)
|
|
251
|
+
: normalizeKeyPart(value[key], extra, pepper);
|
|
252
|
+
normalized[key] = part.value;
|
|
253
|
+
secretScoped ||= part.secretScoped;
|
|
119
254
|
}
|
|
120
|
-
return normalized;
|
|
255
|
+
return { value: normalized, secretScoped };
|
|
256
|
+
}
|
|
257
|
+
return { value, secretScoped: false };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function hashSecretValue(
|
|
261
|
+
value: unknown,
|
|
262
|
+
fieldName: string,
|
|
263
|
+
extra: Set<string>,
|
|
264
|
+
pepper: string | undefined,
|
|
265
|
+
): NormalizedKeyPart {
|
|
266
|
+
assertJsonSafeSecretValue(value, `${fieldName} (inside secret value)`);
|
|
267
|
+
const canonical = serializeSecretValue(normalizeKeyPart(value, extra, pepper).value);
|
|
268
|
+
if (pepper === undefined) {
|
|
269
|
+
warnAboutUnpepperedSecretKey();
|
|
270
|
+
const digest = createHash("sha256").update(canonical).digest("hex");
|
|
271
|
+
return { value: `sha256:${digest}`, secretScoped: true };
|
|
121
272
|
}
|
|
122
|
-
|
|
273
|
+
const digest = createHmac("sha256", pepper).update(canonical).digest("hex");
|
|
274
|
+
return { value: `hmac-sha256:${digest}`, secretScoped: true };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function metadataKeys(
|
|
278
|
+
events: ProviderCacheLookupMeta[],
|
|
279
|
+
secretScopedKeys: Set<string>,
|
|
280
|
+
): string[] {
|
|
281
|
+
let secretScopedIndex = 0;
|
|
282
|
+
return Array.from(new Set(events.map((event) => event.key))).map((key) => {
|
|
283
|
+
if (!secretScopedKeys.has(key)) return key;
|
|
284
|
+
secretScopedIndex += 1;
|
|
285
|
+
return `${SECRET_SCOPED_KEY_MARKER}#${secretScopedIndex}]`;
|
|
286
|
+
});
|
|
123
287
|
}
|
|
124
288
|
|
|
125
289
|
function stableHash(value: unknown): string {
|
|
@@ -198,10 +362,13 @@ async function withRedisFallback<T>(operation: () => Promise<T>): Promise<T | un
|
|
|
198
362
|
|
|
199
363
|
export function createProviderCache(options: ProviderCacheOptions): ProviderCache {
|
|
200
364
|
const redisUrl = options.redisUrl ?? providerCacheRedisUrlFromEnv();
|
|
365
|
+
const configuredPepper = process.env[APIFUSE__CACHE__KEY_PEPPER_ENV];
|
|
366
|
+
const pepper = configuredPepper && configuredPepper.length > 0 ? configuredPepper : undefined;
|
|
201
367
|
const backend = getSharedBackend(redisUrl);
|
|
202
368
|
const memoryMaxEntries = Math.max(1, options.memoryMaxEntries ?? DEFAULT_MEMORY_MAX_ENTRIES);
|
|
203
369
|
const now = options.now ?? Date.now;
|
|
204
370
|
const events: ProviderCacheLookupMeta[] = [];
|
|
371
|
+
const secretScopedKeys = new Set<string>();
|
|
205
372
|
|
|
206
373
|
function record(meta: ProviderCacheLookupMeta): void {
|
|
207
374
|
events.push(meta);
|
|
@@ -353,8 +520,10 @@ export function createProviderCache(options: ProviderCacheOptions): ProviderCach
|
|
|
353
520
|
return {
|
|
354
521
|
key(namespace, parts, keyOptions?: ProviderCacheKeyOptions) {
|
|
355
522
|
const extra = new Set((keyOptions?.redactFields ?? []).map((field) => field.toLowerCase()));
|
|
356
|
-
const normalized = normalizeKeyPart(parts, extra);
|
|
357
|
-
|
|
523
|
+
const normalized = normalizeKeyPart(parts, extra, pepper);
|
|
524
|
+
const key = `${DEFAULT_PREFIX}:${options.providerId}:${namespace}:${stableHash(normalized.value)}`;
|
|
525
|
+
if (normalized.secretScoped) secretScopedKeys.add(key);
|
|
526
|
+
return key;
|
|
358
527
|
},
|
|
359
528
|
|
|
360
529
|
async get<T = unknown>(key: string): Promise<ProviderCacheResult<T> | null> {
|
|
@@ -413,7 +582,7 @@ export function createProviderCache(options: ProviderCacheOptions): ProviderCach
|
|
|
413
582
|
return {
|
|
414
583
|
hit: events.some((event) => event.hit),
|
|
415
584
|
stale: events.some((event) => event.stale),
|
|
416
|
-
keys:
|
|
585
|
+
keys: metadataKeys(events, secretScopedKeys),
|
|
417
586
|
source: sourceSummary(events),
|
|
418
587
|
};
|
|
419
588
|
},
|
|
@@ -423,13 +592,18 @@ export function createProviderCache(options: ProviderCacheOptions): ProviderCach
|
|
|
423
592
|
export function createBypassProviderCache(
|
|
424
593
|
options: Pick<ProviderCacheOptions, "providerId">,
|
|
425
594
|
): ProviderCache {
|
|
595
|
+
const configuredPepper = process.env[APIFUSE__CACHE__KEY_PEPPER_ENV];
|
|
596
|
+
const pepper = configuredPepper && configuredPepper.length > 0 ? configuredPepper : undefined;
|
|
426
597
|
const events: ProviderCacheLookupMeta[] = [];
|
|
598
|
+
const secretScopedKeys = new Set<string>();
|
|
427
599
|
|
|
428
600
|
return {
|
|
429
601
|
key(namespace, parts, keyOptions?: ProviderCacheKeyOptions) {
|
|
430
602
|
const extra = new Set((keyOptions?.redactFields ?? []).map((field) => field.toLowerCase()));
|
|
431
|
-
const normalized = normalizeKeyPart(parts, extra);
|
|
432
|
-
|
|
603
|
+
const normalized = normalizeKeyPart(parts, extra, pepper);
|
|
604
|
+
const key = `${DEFAULT_PREFIX}:${options.providerId}:${namespace}:${stableHash(normalized.value)}`;
|
|
605
|
+
if (normalized.secretScoped) secretScopedKeys.add(key);
|
|
606
|
+
return key;
|
|
433
607
|
},
|
|
434
608
|
|
|
435
609
|
async get<T = unknown>(_key: string): Promise<ProviderCacheResult<T> | null> {
|
|
@@ -464,7 +638,7 @@ export function createBypassProviderCache(
|
|
|
464
638
|
return {
|
|
465
639
|
hit: false,
|
|
466
640
|
stale: false,
|
|
467
|
-
keys:
|
|
641
|
+
keys: metadataKeys(events, secretScopedKeys),
|
|
468
642
|
source: sourceSummary(events),
|
|
469
643
|
};
|
|
470
644
|
},
|
|
@@ -478,4 +652,5 @@ export function resetProviderCacheForTests(): void {
|
|
|
478
652
|
backend.redis?.disconnect();
|
|
479
653
|
}
|
|
480
654
|
sharedBackends.clear();
|
|
655
|
+
warnedAboutUnpepperedSecretKeys = false;
|
|
481
656
|
}
|
|
@@ -1,30 +1,55 @@
|
|
|
1
1
|
import type { ProviderChallenge, ProviderChallengeKind } from "../../types.js";
|
|
2
2
|
import type { ResolverIssuingIdentity } from "./types.js";
|
|
3
3
|
|
|
4
|
+
type ResolverChallengeBinding = {
|
|
5
|
+
readonly cacheable: boolean;
|
|
6
|
+
readonly identityBinding: "none" | "identity_scoped" | "portable";
|
|
7
|
+
readonly directCacheable: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// An IP-bound artifact minted without any recorded egress identity is unsafe to
|
|
11
|
+
// share. The Akamai kinds therefore reject direct caching, while Cloudflare
|
|
12
|
+
// keeps its pre-existing direct-cache behavior pending measurement.
|
|
4
13
|
export const RESOLVER_CHALLENGE_BINDINGS = {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
14
|
+
turnstile: { cacheable: false, identityBinding: "none", directCacheable: false },
|
|
15
|
+
recaptcha_v2: { cacheable: false, identityBinding: "none", directCacheable: false },
|
|
16
|
+
recaptcha_v3: { cacheable: false, identityBinding: "none", directCacheable: false },
|
|
17
|
+
hcaptcha: { cacheable: false, identityBinding: "none", directCacheable: false },
|
|
18
|
+
cloudflare_interstitial: {
|
|
19
|
+
cacheable: true,
|
|
20
|
+
identityBinding: "identity_scoped",
|
|
21
|
+
directCacheable: true,
|
|
22
|
+
},
|
|
23
|
+
aws_waf: { cacheable: true, identityBinding: "portable", directCacheable: true },
|
|
24
|
+
akamai_sec_cpt: {
|
|
25
|
+
cacheable: true,
|
|
26
|
+
identityBinding: "identity_scoped",
|
|
27
|
+
directCacheable: false,
|
|
28
|
+
},
|
|
29
|
+
akamai_sensor: {
|
|
30
|
+
cacheable: true,
|
|
31
|
+
identityBinding: "identity_scoped",
|
|
32
|
+
directCacheable: false,
|
|
33
|
+
},
|
|
34
|
+
} as const satisfies Readonly<Record<ProviderChallengeKind, ResolverChallengeBinding>>;
|
|
35
|
+
|
|
36
|
+
export function resolverChallengeIsCacheable(challenge: ProviderChallenge): boolean {
|
|
37
|
+
return RESOLVER_CHALLENGE_BINDINGS[challenge.kind].cacheable;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolverChallengeAllowsDirectCache(challenge: ProviderChallenge): boolean {
|
|
41
|
+
return RESOLVER_CHALLENGE_BINDINGS[challenge.kind].directCacheable;
|
|
42
|
+
}
|
|
10
43
|
|
|
11
44
|
export function resolverChallengeIsIdentityScoped(challenge: ProviderChallenge): boolean {
|
|
12
|
-
return
|
|
13
|
-
RESOLVER_CHALLENGE_BINDINGS[challenge.kind as keyof typeof RESOLVER_CHALLENGE_BINDINGS] ===
|
|
14
|
-
"identity_scoped"
|
|
15
|
-
);
|
|
45
|
+
return RESOLVER_CHALLENGE_BINDINGS[challenge.kind].identityBinding === "identity_scoped";
|
|
16
46
|
}
|
|
17
47
|
|
|
18
48
|
export function resolverChallengeIssuingIdentity(
|
|
19
49
|
challenge: ProviderChallenge,
|
|
20
50
|
identity: ResolverIssuingIdentity,
|
|
21
51
|
): ResolverIssuingIdentity {
|
|
22
|
-
|
|
23
|
-
RESOLVER_CHALLENGE_BINDINGS as Partial<
|
|
24
|
-
Readonly<Record<ProviderChallengeKind, "identity_scoped" | "portable">>
|
|
25
|
-
>
|
|
26
|
-
)[challenge.kind];
|
|
27
|
-
if (binding === "portable") {
|
|
52
|
+
if (RESOLVER_CHALLENGE_BINDINGS[challenge.kind].identityBinding === "portable") {
|
|
28
53
|
return { userAgent: identity.userAgent };
|
|
29
54
|
}
|
|
30
55
|
return identity;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isProviderError
|
|
1
|
+
import { isProviderError } from "../../errors.js";
|
|
2
2
|
import type {
|
|
3
3
|
BrowserClient,
|
|
4
4
|
BrowserCookie,
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
import { type BrowserClientOptions, createBrowserClient } from "../browser.js";
|
|
10
10
|
import type { TraceRecorder } from "../trace.js";
|
|
11
11
|
import { resolverChallengeIssuingIdentity } from "./bindings.js";
|
|
12
|
+
import { assertResolverHostAllowed, normalizedResolverHostname } from "./hosts.js";
|
|
12
13
|
import {
|
|
13
14
|
type ResolverIdentity,
|
|
14
15
|
type ResolverVendorAdapter,
|
|
@@ -35,11 +36,6 @@ export interface BrowserResolverVendorOptions {
|
|
|
35
36
|
readonly createClient?: BrowserClientFactory;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
export type BrowserResolverSolution = Extract<ChallengeSolution, { readonly form: "cookies" }> & {
|
|
39
|
-
/** Unix seconds from the cookie that proved the challenge cleared. */
|
|
40
|
-
readonly expires?: number;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
39
|
export interface BrowserResolverVendorAdapter extends ResolverVendorAdapter {
|
|
44
40
|
readonly id: "browser";
|
|
45
41
|
solve(
|
|
@@ -47,7 +43,7 @@ export interface BrowserResolverVendorAdapter extends ResolverVendorAdapter {
|
|
|
47
43
|
identity: ResolverIdentity | undefined,
|
|
48
44
|
signal: AbortSignal,
|
|
49
45
|
traceRecorder?: TraceRecorder,
|
|
50
|
-
): Promise<
|
|
46
|
+
): Promise<Extract<ChallengeSolution, { readonly form: "cookies" }>>;
|
|
51
47
|
}
|
|
52
48
|
|
|
53
49
|
class BrowserSolveTimeoutError extends Error {
|
|
@@ -155,38 +151,20 @@ function isSupportedKind(kind: string): kind is SupportedBrowserChallengeKind {
|
|
|
155
151
|
return Object.hasOwn(SUCCESS_COOKIE_NAMES, kind);
|
|
156
152
|
}
|
|
157
153
|
|
|
158
|
-
function normalizedHostname(hostname: string): string {
|
|
159
|
-
return hostname.trim().toLowerCase().replace(/\.$/, "");
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function assertChallengeHostAllowed(pageUrl: string, allowedHosts: readonly string[]): void {
|
|
163
|
-
const challengeHost = normalizedHostname(new URL(pageUrl).hostname);
|
|
164
|
-
const isAllowed = allowedHosts.some((host) => {
|
|
165
|
-
const declaredHost = normalizedHostname(host);
|
|
166
|
-
return declaredHost.length > 0 && !declaredHost.includes("*") && declaredHost === challengeHost;
|
|
167
|
-
});
|
|
168
|
-
if (isAllowed) return;
|
|
169
|
-
|
|
170
|
-
throw new ProviderError(`Resolver challenge host "${challengeHost}" is not declared`, {
|
|
171
|
-
code: "RESOLVER_HOST_NOT_ALLOWED",
|
|
172
|
-
fix: "Add the exact challenge hostname to the provider's allowedHosts declaration.",
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
154
|
function cookieDomainSpecificity(cookie: BrowserCookie): number {
|
|
177
|
-
return
|
|
155
|
+
return normalizedResolverHostname(cookie.domain.replace(/^\./, "")).length;
|
|
178
156
|
}
|
|
179
157
|
|
|
180
158
|
function isHostOnlyCookieFor(cookie: BrowserCookie, hostname: string): boolean {
|
|
181
159
|
return (
|
|
182
160
|
!cookie.domain.startsWith(".") &&
|
|
183
|
-
|
|
161
|
+
normalizedResolverHostname(cookie.domain) === normalizedResolverHostname(hostname)
|
|
184
162
|
);
|
|
185
163
|
}
|
|
186
164
|
|
|
187
165
|
function cookieAppliesToUrl(cookie: BrowserCookie, url: URL): boolean {
|
|
188
|
-
const cookieDomain =
|
|
189
|
-
const requestHostname =
|
|
166
|
+
const cookieDomain = normalizedResolverHostname(cookie.domain.replace(/^\./, ""));
|
|
167
|
+
const requestHostname = normalizedResolverHostname(url.hostname);
|
|
190
168
|
const domainMatches =
|
|
191
169
|
cookieDomain.length > 0 &&
|
|
192
170
|
(requestHostname === cookieDomain ||
|
|
@@ -226,7 +204,7 @@ async function solveInPage(
|
|
|
226
204
|
successCookieName: string,
|
|
227
205
|
pollIntervalMs: number,
|
|
228
206
|
signal: AbortSignal,
|
|
229
|
-
): Promise<
|
|
207
|
+
): Promise<Extract<ChallengeSolution, { readonly form: "cookies" }>> {
|
|
230
208
|
await raceWithAbort(() => page.goto(pageUrl), signal);
|
|
231
209
|
|
|
232
210
|
while (true) {
|
|
@@ -345,7 +323,7 @@ export function createBrowserResolverVendorAdapter(
|
|
|
345
323
|
if (!isSupportedKind(challenge.kind)) {
|
|
346
324
|
throw new TypeError(`Browser resolver does not support ${challenge.kind}`);
|
|
347
325
|
}
|
|
348
|
-
|
|
326
|
+
assertResolverHostAllowed(challenge.pageUrl, options.allowedHosts);
|
|
349
327
|
const challengeKind = challenge.kind;
|
|
350
328
|
callerSignal.throwIfAborted();
|
|
351
329
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ProviderError } from "../../errors.js";
|
|
2
|
+
|
|
3
|
+
export function normalizedResolverHostname(hostname: string): string {
|
|
4
|
+
return hostname.trim().toLowerCase().replace(/\.$/, "");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function assertResolverHostAllowed(
|
|
8
|
+
targetUrl: string,
|
|
9
|
+
allowedHosts: readonly string[],
|
|
10
|
+
): void {
|
|
11
|
+
let targetUrlObject: URL;
|
|
12
|
+
try {
|
|
13
|
+
targetUrlObject = new URL(targetUrl);
|
|
14
|
+
} catch {
|
|
15
|
+
throw new ProviderError("Resolver target URL is invalid", {
|
|
16
|
+
code: "RESOLVER_HOST_NOT_ALLOWED",
|
|
17
|
+
fix: "Use a valid URL whose exact hostname appears in the provider's allowedHosts declaration.",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
if (targetUrlObject.protocol !== "http:" && targetUrlObject.protocol !== "https:") {
|
|
21
|
+
throw new ProviderError(`Resolver target URL scheme "${targetUrlObject.protocol}" is not allowed`, {
|
|
22
|
+
code: "RESOLVER_HOST_NOT_ALLOWED",
|
|
23
|
+
fix: "Use an http or https URL whose exact hostname appears in the provider's allowedHosts declaration.",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const targetHost = normalizedResolverHostname(targetUrlObject.hostname);
|
|
27
|
+
|
|
28
|
+
const isAllowed = allowedHosts.some((host) => {
|
|
29
|
+
const declaredHost = normalizedResolverHostname(host);
|
|
30
|
+
return declaredHost.length > 0 && !declaredHost.includes("*") && declaredHost === targetHost;
|
|
31
|
+
});
|
|
32
|
+
if (isAllowed) return;
|
|
33
|
+
|
|
34
|
+
throw new ProviderError(`Resolver target host "${targetHost}" is not declared`, {
|
|
35
|
+
code: "RESOLVER_HOST_NOT_ALLOWED",
|
|
36
|
+
fix: "Add the exact target hostname to the provider's allowedHosts declaration.",
|
|
37
|
+
});
|
|
38
|
+
}
|