@uncefact/untp-utils 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -1
- package/build/artefacts/detect-version-from-context.d.ts +46 -0
- package/build/artefacts/detect-version-from-context.js +77 -0
- package/build/artefacts/detect-version-from-context.js.map +1 -0
- package/build/artefacts/detect-version-from-context.test.d.ts +1 -0
- package/build/artefacts/detect-version-from-context.test.js +69 -0
- package/build/artefacts/detect-version-from-context.test.js.map +1 -0
- package/build/artefacts/index.d.ts +3 -0
- package/build/artefacts/index.js +4 -0
- package/build/artefacts/index.js.map +1 -0
- package/build/artefacts/urls.d.ts +79 -0
- package/build/artefacts/urls.js +133 -0
- package/build/artefacts/urls.js.map +1 -0
- package/build/artefacts/urls.test.d.ts +1 -0
- package/build/artefacts/urls.test.js +91 -0
- package/build/artefacts/urls.test.js.map +1 -0
- package/build/artefacts/version.d.ts +9 -0
- package/build/artefacts/version.js +17 -0
- package/build/artefacts/version.js.map +1 -0
- package/build/artefacts/version.test.d.ts +1 -0
- package/build/artefacts/version.test.js +16 -0
- package/build/artefacts/version.test.js.map +1 -0
- package/build/cache/in-memory-ttl-cache.d.ts +2 -0
- package/build/cache/in-memory-ttl-cache.js +76 -0
- package/build/cache/in-memory-ttl-cache.js.map +1 -0
- package/build/cache/in-memory-ttl-cache.test.d.ts +1 -0
- package/build/cache/in-memory-ttl-cache.test.js +236 -0
- package/build/cache/in-memory-ttl-cache.test.js.map +1 -0
- package/build/cache/index.d.ts +2 -0
- package/build/cache/index.js +2 -0
- package/build/cache/index.js.map +1 -0
- package/build/cache/ttl-cache.d.ts +22 -0
- package/build/cache/ttl-cache.js +2 -0
- package/build/cache/ttl-cache.js.map +1 -0
- package/build/common/as-non-empty-string.d.ts +7 -0
- package/build/common/as-non-empty-string.js +10 -0
- package/build/common/as-non-empty-string.js.map +1 -0
- package/build/common/as-non-empty-string.test.d.ts +1 -0
- package/build/common/as-non-empty-string.test.js +25 -0
- package/build/common/as-non-empty-string.test.js.map +1 -0
- package/build/common/require-string.d.ts +12 -0
- package/build/common/require-string.js +27 -0
- package/build/common/require-string.js.map +1 -0
- package/build/common/require-string.test.d.ts +1 -0
- package/build/common/require-string.test.js +53 -0
- package/build/common/require-string.test.js.map +1 -0
- package/build/conformity-vocabulary/codes.d.ts +10 -0
- package/build/conformity-vocabulary/codes.js +10 -0
- package/build/conformity-vocabulary/codes.js.map +1 -0
- package/build/conformity-vocabulary/errors.d.ts +44 -0
- package/build/conformity-vocabulary/errors.js +68 -0
- package/build/conformity-vocabulary/errors.js.map +1 -0
- package/build/conformity-vocabulary/index.d.ts +6 -0
- package/build/conformity-vocabulary/index.js +7 -0
- package/build/conformity-vocabulary/index.js.map +1 -0
- package/build/conformity-vocabulary/parse-conformity-catalogue.d.ts +26 -0
- package/build/conformity-vocabulary/parse-conformity-catalogue.js +106 -0
- package/build/conformity-vocabulary/parse-conformity-catalogue.js.map +1 -0
- package/build/conformity-vocabulary/parse-conformity-catalogue.test.d.ts +1 -0
- package/build/conformity-vocabulary/parse-conformity-catalogue.test.js +327 -0
- package/build/conformity-vocabulary/parse-conformity-catalogue.test.js.map +1 -0
- package/build/conformity-vocabulary/parse-conformity-scheme.d.ts +28 -0
- package/build/conformity-vocabulary/parse-conformity-scheme.js +47 -0
- package/build/conformity-vocabulary/parse-conformity-scheme.js.map +1 -0
- package/build/conformity-vocabulary/parse-conformity-scheme.test.d.ts +1 -0
- package/build/conformity-vocabulary/parse-conformity-scheme.test.js +284 -0
- package/build/conformity-vocabulary/parse-conformity-scheme.test.js.map +1 -0
- package/build/conformity-vocabulary/parsers/v0-7-0.parser.d.ts +19 -0
- package/build/conformity-vocabulary/parsers/v0-7-0.parser.js +219 -0
- package/build/conformity-vocabulary/parsers/v0-7-0.parser.js.map +1 -0
- package/build/conformity-vocabulary/types.d.ts +180 -0
- package/build/conformity-vocabulary/types.js +2 -0
- package/build/conformity-vocabulary/types.js.map +1 -0
- package/build/conformity-vocabulary/validate-conformity-claim.d.ts +26 -0
- package/build/conformity-vocabulary/validate-conformity-claim.js +165 -0
- package/build/conformity-vocabulary/validate-conformity-claim.js.map +1 -0
- package/build/conformity-vocabulary/validate-conformity-claim.test.d.ts +1 -0
- package/build/conformity-vocabulary/validate-conformity-claim.test.js +478 -0
- package/build/conformity-vocabulary/validate-conformity-claim.test.js.map +1 -0
- package/build/http-headers/index.d.ts +5 -0
- package/build/http-headers/index.js +6 -0
- package/build/http-headers/index.js.map +1 -0
- package/build/http-headers/is-safe-header-value.d.ts +10 -0
- package/build/http-headers/is-safe-header-value.js +15 -0
- package/build/http-headers/is-safe-header-value.js.map +1 -0
- package/build/http-headers/is-safe-header-value.test.d.ts +1 -0
- package/build/http-headers/is-safe-header-value.test.js +37 -0
- package/build/http-headers/is-safe-header-value.test.js.map +1 -0
- package/build/http-headers/parse-entity-tag.d.ts +9 -0
- package/build/http-headers/parse-entity-tag.js +18 -0
- package/build/http-headers/parse-entity-tag.js.map +1 -0
- package/build/http-headers/parse-entity-tag.test.d.ts +1 -0
- package/build/http-headers/parse-entity-tag.test.js +41 -0
- package/build/http-headers/parse-entity-tag.test.js.map +1 -0
- package/build/http-headers/parse-imf-date.d.ts +13 -0
- package/build/http-headers/parse-imf-date.js +23 -0
- package/build/http-headers/parse-imf-date.js.map +1 -0
- package/build/http-headers/parse-imf-date.test.d.ts +1 -0
- package/build/http-headers/parse-imf-date.test.js +31 -0
- package/build/http-headers/parse-imf-date.test.js.map +1 -0
- package/build/http-headers/parse-media-type.d.ts +8 -0
- package/build/http-headers/parse-media-type.js +21 -0
- package/build/http-headers/parse-media-type.js.map +1 -0
- package/build/http-headers/parse-media-type.test.d.ts +1 -0
- package/build/http-headers/parse-media-type.test.js +39 -0
- package/build/http-headers/parse-media-type.test.js.map +1 -0
- package/build/http-headers/user-agent.d.ts +35 -0
- package/build/http-headers/user-agent.js +45 -0
- package/build/http-headers/user-agent.js.map +1 -0
- package/build/http-headers/user-agent.test.d.ts +1 -0
- package/build/http-headers/user-agent.test.js +27 -0
- package/build/http-headers/user-agent.test.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.js +1 -1
- package/build/index.js.map +1 -1
- package/build/loaders/errors.d.ts +21 -0
- package/build/loaders/errors.js +45 -0
- package/build/loaders/errors.js.map +1 -0
- package/build/loaders/index.d.ts +3 -0
- package/build/loaders/index.js +4 -0
- package/build/loaders/index.js.map +1 -0
- package/build/loaders/jsonld-document-loader.d.ts +46 -0
- package/build/loaders/jsonld-document-loader.js +34 -0
- package/build/loaders/jsonld-document-loader.js.map +1 -0
- package/build/loaders/jsonld-document-loader.test.d.ts +1 -0
- package/build/loaders/jsonld-document-loader.test.js +71 -0
- package/build/loaders/jsonld-document-loader.test.js.map +1 -0
- package/build/loaders/schema-loader.d.ts +21 -0
- package/build/loaders/schema-loader.js +67 -0
- package/build/loaders/schema-loader.js.map +1 -0
- package/build/loaders/schema-loader.ssrf.test.d.ts +1 -0
- package/build/loaders/schema-loader.ssrf.test.js +55 -0
- package/build/loaders/schema-loader.ssrf.test.js.map +1 -0
- package/build/loaders/schema-loader.test.d.ts +1 -0
- package/build/loaders/schema-loader.test.js +162 -0
- package/build/loaders/schema-loader.test.js.map +1 -0
- package/build/multibase-digest/multibase-digest.d.ts +23 -0
- package/build/multibase-digest/multibase-digest.js +40 -0
- package/build/multibase-digest/multibase-digest.js.map +1 -1
- package/build/multibase-digest/multibase-digest.test.js +46 -0
- package/build/multibase-digest/multibase-digest.test.js.map +1 -1
- package/build/node/errors.d.ts +41 -0
- package/build/node/errors.js +80 -0
- package/build/node/errors.js.map +1 -0
- package/build/node/index.d.ts +3 -0
- package/build/node/index.js +4 -0
- package/build/node/index.js.map +1 -0
- package/build/node/is-private-ip.d.ts +41 -0
- package/build/node/is-private-ip.js +138 -0
- package/build/node/is-private-ip.js.map +1 -0
- package/build/node/is-private-ip.test.d.ts +1 -0
- package/build/node/is-private-ip.test.js +96 -0
- package/build/node/is-private-ip.test.js.map +1 -0
- package/build/node/validate-public-url.d.ts +49 -0
- package/build/node/validate-public-url.js +94 -0
- package/build/node/validate-public-url.js.map +1 -0
- package/build/node/validate-public-url.test.d.ts +1 -0
- package/build/node/validate-public-url.test.js +177 -0
- package/build/node/validate-public-url.test.js.map +1 -0
- package/build/resolvers/errors.d.ts +43 -0
- package/build/resolvers/errors.js +99 -0
- package/build/resolvers/errors.js.map +1 -0
- package/build/resolvers/index.d.ts +5 -0
- package/build/resolvers/index.js +6 -0
- package/build/resolvers/index.js.map +1 -0
- package/build/resolvers/resolve-document-if-changed.d.ts +48 -0
- package/build/resolvers/resolve-document-if-changed.js +42 -0
- package/build/resolvers/resolve-document-if-changed.js.map +1 -0
- package/build/resolvers/resolve-document-if-changed.test.d.ts +1 -0
- package/build/resolvers/resolve-document-if-changed.test.js +109 -0
- package/build/resolvers/resolve-document-if-changed.test.js.map +1 -0
- package/build/resolvers/resolve-document.d.ts +86 -0
- package/build/resolvers/resolve-document.js +239 -0
- package/build/resolvers/resolve-document.js.map +1 -0
- package/build/resolvers/resolve-document.test.d.ts +1 -0
- package/build/resolvers/resolve-document.test.js +299 -0
- package/build/resolvers/resolve-document.test.js.map +1 -0
- package/build/resolvers/resolve-json-document.d.ts +40 -0
- package/build/resolvers/resolve-json-document.js +42 -0
- package/build/resolvers/resolve-json-document.js.map +1 -0
- package/build/resolvers/resolve-json-document.test.d.ts +1 -0
- package/build/resolvers/resolve-json-document.test.js +61 -0
- package/build/resolvers/resolve-json-document.test.js.map +1 -0
- package/build/structured-error.d.ts +49 -0
- package/build/structured-error.js +17 -0
- package/build/structured-error.js.map +1 -0
- package/build/structured-error.test.d.ts +1 -0
- package/build/structured-error.test.js +112 -0
- package/build/structured-error.test.js.map +1 -0
- package/build/validation/describe-jsonld-failure.d.ts +50 -0
- package/build/validation/describe-jsonld-failure.js +104 -0
- package/build/validation/describe-jsonld-failure.js.map +1 -0
- package/build/validation/describe-jsonld-failure.test.d.ts +1 -0
- package/build/validation/describe-jsonld-failure.test.js +141 -0
- package/build/validation/describe-jsonld-failure.test.js.map +1 -0
- package/build/validation/errors.d.ts +40 -0
- package/build/validation/errors.js +75 -0
- package/build/validation/errors.js.map +1 -0
- package/build/validation/index.d.ts +4 -0
- package/build/validation/index.js +5 -0
- package/build/validation/index.js.map +1 -0
- package/build/validation/validate-against-schemas.d.ts +22 -0
- package/build/validation/validate-against-schemas.js +65 -0
- package/build/validation/validate-against-schemas.js.map +1 -0
- package/build/validation/validate-against-schemas.test.d.ts +1 -0
- package/build/validation/validate-against-schemas.test.js +169 -0
- package/build/validation/validate-against-schemas.test.js.map +1 -0
- package/build/validation/validate-jsonld.context-cache.test.d.ts +1 -0
- package/build/validation/validate-jsonld.context-cache.test.js +32 -0
- package/build/validation/validate-jsonld.context-cache.test.js.map +1 -0
- package/build/validation/validate-jsonld.d.ts +33 -0
- package/build/validation/validate-jsonld.js +99 -0
- package/build/validation/validate-jsonld.js.map +1 -0
- package/build/validation/validate-jsonld.ssrf.test.d.ts +1 -0
- package/build/validation/validate-jsonld.ssrf.test.js +108 -0
- package/build/validation/validate-jsonld.ssrf.test.js.map +1 -0
- package/build/validation/validate-jsonld.test.d.ts +1 -0
- package/build/validation/validate-jsonld.test.js +200 -0
- package/build/validation/validate-jsonld.test.js.map +1 -0
- package/package.json +64 -3
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { MultibaseDigest } from '../multibase-digest/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Defaults applied by {@link resolveDocument} (and, via composition,
|
|
4
|
+
* by {@link import('./resolve-document-if-changed.js').resolveDocumentIfChanged})
|
|
5
|
+
* when the caller does not supply a value.
|
|
6
|
+
*/
|
|
7
|
+
export declare const RESOLVER_DEFAULTS: {
|
|
8
|
+
/** Body-size cap in bytes; exceeding throws {@link ResolverTooLargeError}. */
|
|
9
|
+
readonly maxResponseBytes: 1048576;
|
|
10
|
+
/** Total request timeout in milliseconds (DNS + connect + TLS + first byte + body). */
|
|
11
|
+
readonly totalTimeoutMs: 10000;
|
|
12
|
+
/** Maximum additional hops after the initial request; exceeding throws {@link ResolverTooManyRedirectsError}. */
|
|
13
|
+
readonly maxRedirects: 3;
|
|
14
|
+
/** Multibase digest algorithm used to hash response bodies. */
|
|
15
|
+
readonly digestAlgorithm: "sha2-256";
|
|
16
|
+
/** Multibase encoding used for response body digests. */
|
|
17
|
+
readonly digestEncoding: "base58btc";
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Result of a fetch that produced a final response (any non-redirect status,
|
|
21
|
+
* including `304 Not Modified`).
|
|
22
|
+
*
|
|
23
|
+
* Only an allowlisted subset of response headers is exposed (the ones
|
|
24
|
+
* downstream consumers need: `etag` and `last-modified` for the conditional-
|
|
25
|
+
* fetch skip chain, `content-type` for body-type checks).
|
|
26
|
+
*
|
|
27
|
+
* All fields are `readonly`: a `LoadResult` is a frozen snapshot of one
|
|
28
|
+
* fetch and must not be mutated by consumers.
|
|
29
|
+
*/
|
|
30
|
+
export interface LoadResult {
|
|
31
|
+
/** Final URL after redirect chasing (matches `url` when there were no redirects). */
|
|
32
|
+
readonly finalUrl: string;
|
|
33
|
+
/** HTTP status code of the final response (the redirect chain terminator, or `304`). */
|
|
34
|
+
readonly status: number;
|
|
35
|
+
/** Raw response body bytes. */
|
|
36
|
+
readonly body: Uint8Array;
|
|
37
|
+
/** Multibase digest of {@link body} (algorithm/encoding per {@link RESOLVER_DEFAULTS}). */
|
|
38
|
+
readonly bodyDigest: MultibaseDigest;
|
|
39
|
+
/** The response's `ETag` header if present. */
|
|
40
|
+
readonly etag?: string;
|
|
41
|
+
/** The response's `Last-Modified` header if present. */
|
|
42
|
+
readonly lastModified?: string;
|
|
43
|
+
/** The response's `Content-Type` header if present. */
|
|
44
|
+
readonly contentType?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Options accepted by {@link resolveDocument} and
|
|
48
|
+
* {@link import('./resolve-document-if-changed.js').resolveDocumentIfChanged}.
|
|
49
|
+
*
|
|
50
|
+
* Header keys are case-insensitive on the wire; `resolveDocumentIfChanged`
|
|
51
|
+
* lowercases all keys before merging and reserves `if-none-match` /
|
|
52
|
+
* `if-modified-since` for its conditional-fetch wiring.
|
|
53
|
+
*/
|
|
54
|
+
export interface ResolveDocumentOptions {
|
|
55
|
+
/** Override {@link RESOLVER_DEFAULTS.maxResponseBytes}. */
|
|
56
|
+
maxResponseBytes?: number;
|
|
57
|
+
/** Override {@link RESOLVER_DEFAULTS.totalTimeoutMs}. */
|
|
58
|
+
totalTimeoutMs?: number;
|
|
59
|
+
/** Override {@link RESOLVER_DEFAULTS.maxRedirects}. */
|
|
60
|
+
maxRedirects?: number;
|
|
61
|
+
/** Additional headers to send with the request (e.g. `Accept`). */
|
|
62
|
+
headers?: Record<string, string>;
|
|
63
|
+
/** Allowed URL schemes (forwarded to {@link import('../node/index.js').validatePublicUrl}). */
|
|
64
|
+
allowedSchemes?: readonly string[];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Fetches `url` with the standard SSRF / size / timeout / redirect guards
|
|
68
|
+
* applied, returning the response body + metadata as a {@link LoadResult}.
|
|
69
|
+
*
|
|
70
|
+
* Each redirect hop is re-validated through
|
|
71
|
+
* {@link import('../node/index.js').validatePublicUrl}, and the connection
|
|
72
|
+
* to each hop is pinned to the IP that validation resolved, so an upstream
|
|
73
|
+
* cannot redirect to a private URL or rebind its hostname between check
|
|
74
|
+
* and connect.
|
|
75
|
+
*
|
|
76
|
+
* @throws {UrlValidationError} for URL / scheme / hostname / DNS / private-address rejections from `validatePublicUrl`.
|
|
77
|
+
* @throws {ResolverNetworkError} when fetch rejects before producing a response.
|
|
78
|
+
* @throws {ResolverHttpError} on a non-2xx response status (with `.status`).
|
|
79
|
+
* @throws {ResolverTooLargeError} when the body exceeds the size cap (with `.limit`).
|
|
80
|
+
* @throws {ResolverTooManyRedirectsError} when the redirect chain exceeds the hop cap (with `.limit`).
|
|
81
|
+
* @throws {ResolverTimedOutError} when the total timeout fires (with `.timeoutMs`).
|
|
82
|
+
* @throws {ResolverRedirectMissingLocationError} for a 3xx with no / unparseable Location header.
|
|
83
|
+
*
|
|
84
|
+
* @see https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
|
|
85
|
+
*/
|
|
86
|
+
export declare function resolveDocument(url: string, options?: ResolveDocumentOptions): Promise<LoadResult>;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { parseEntityTag, parseImfDate, parseMediaType, DEFAULT_USER_AGENT, USER_AGENT_ENV_VAR, } from '../http-headers/index.js';
|
|
2
|
+
import { MultibaseDigest } from '../multibase-digest/index.js';
|
|
3
|
+
import { validatePublicUrl } from '../node/index.js';
|
|
4
|
+
import { ResolverHttpError, ResolverNetworkError, ResolverRedirectMissingLocationError, ResolverTimedOutError, ResolverTooLargeError, ResolverTooManyRedirectsError, } from './errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Defaults applied by {@link resolveDocument} (and, via composition,
|
|
7
|
+
* by {@link import('./resolve-document-if-changed.js').resolveDocumentIfChanged})
|
|
8
|
+
* when the caller does not supply a value.
|
|
9
|
+
*/
|
|
10
|
+
export const RESOLVER_DEFAULTS = {
|
|
11
|
+
/** Body-size cap in bytes; exceeding throws {@link ResolverTooLargeError}. */
|
|
12
|
+
maxResponseBytes: 1_048_576,
|
|
13
|
+
/** Total request timeout in milliseconds (DNS + connect + TLS + first byte + body). */
|
|
14
|
+
totalTimeoutMs: 10_000,
|
|
15
|
+
/** Maximum additional hops after the initial request; exceeding throws {@link ResolverTooManyRedirectsError}. */
|
|
16
|
+
maxRedirects: 3,
|
|
17
|
+
/** Multibase digest algorithm used to hash response bodies. */
|
|
18
|
+
digestAlgorithm: 'sha2-256',
|
|
19
|
+
/** Multibase encoding used for response body digests. */
|
|
20
|
+
digestEncoding: 'base58btc',
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Merges the resolved `User-Agent` into the caller's headers. A caller-supplied
|
|
24
|
+
* `user-agent` (any casing) wins; otherwise the `RI_HTTP_USER_AGENT` env
|
|
25
|
+
* override (blank treated as unset), then the default. The override is not
|
|
26
|
+
* validated here: deployments validate it at boot via
|
|
27
|
+
* {@link isValidHttpUserAgent} (the RI does, in instrumentation.node.ts),
|
|
28
|
+
* and a value that slips through with control characters makes the fetch
|
|
29
|
+
* itself fail loudly rather than being silently replaced.
|
|
30
|
+
*/
|
|
31
|
+
function withUserAgent(headers) {
|
|
32
|
+
const hasExplicit = Object.keys(headers ?? {}).some((key) => key.toLowerCase() === 'user-agent');
|
|
33
|
+
if (hasExplicit)
|
|
34
|
+
return headers;
|
|
35
|
+
const fromEnv = process.env[USER_AGENT_ENV_VAR];
|
|
36
|
+
const userAgent = fromEnv !== undefined && fromEnv.trim() !== '' ? fromEnv : DEFAULT_USER_AGENT;
|
|
37
|
+
return { ...headers, 'User-Agent': userAgent };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Fetches `url` with the standard SSRF / size / timeout / redirect guards
|
|
41
|
+
* applied, returning the response body + metadata as a {@link LoadResult}.
|
|
42
|
+
*
|
|
43
|
+
* Each redirect hop is re-validated through
|
|
44
|
+
* {@link import('../node/index.js').validatePublicUrl}, and the connection
|
|
45
|
+
* to each hop is pinned to the IP that validation resolved, so an upstream
|
|
46
|
+
* cannot redirect to a private URL or rebind its hostname between check
|
|
47
|
+
* and connect.
|
|
48
|
+
*
|
|
49
|
+
* @throws {UrlValidationError} for URL / scheme / hostname / DNS / private-address rejections from `validatePublicUrl`.
|
|
50
|
+
* @throws {ResolverNetworkError} when fetch rejects before producing a response.
|
|
51
|
+
* @throws {ResolverHttpError} on a non-2xx response status (with `.status`).
|
|
52
|
+
* @throws {ResolverTooLargeError} when the body exceeds the size cap (with `.limit`).
|
|
53
|
+
* @throws {ResolverTooManyRedirectsError} when the redirect chain exceeds the hop cap (with `.limit`).
|
|
54
|
+
* @throws {ResolverTimedOutError} when the total timeout fires (with `.timeoutMs`).
|
|
55
|
+
* @throws {ResolverRedirectMissingLocationError} for a 3xx with no / unparseable Location header.
|
|
56
|
+
*
|
|
57
|
+
* @see https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
|
|
58
|
+
*/
|
|
59
|
+
export async function resolveDocument(url, options) {
|
|
60
|
+
// Dynamic import: undici's module initialisation needs web globals
|
|
61
|
+
// (TextDecoder) that jsdom test environments lack, so it loads at fetch
|
|
62
|
+
// time to keep this module, and the resolvers barrel, importable there.
|
|
63
|
+
// Same pattern as validate-jsonld.ts's lazy jsonld import.
|
|
64
|
+
const { Agent, fetch: undiciFetch } = await import('undici');
|
|
65
|
+
const maxBytes = options?.maxResponseBytes ?? RESOLVER_DEFAULTS.maxResponseBytes;
|
|
66
|
+
const totalTimeoutMs = options?.totalTimeoutMs ?? RESOLVER_DEFAULTS.totalTimeoutMs;
|
|
67
|
+
const maxRedirects = options?.maxRedirects ?? RESOLVER_DEFAULTS.maxRedirects;
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const timeoutHandle = setTimeout(() => controller.abort(), totalTimeoutMs);
|
|
70
|
+
try {
|
|
71
|
+
let currentUrl = url;
|
|
72
|
+
for (let hop = 0; hop <= maxRedirects; hop += 1) {
|
|
73
|
+
const { address: pinnedAddress, family: pinnedFamily } = await validatePublicUrl(currentUrl, {
|
|
74
|
+
allowedSchemes: options?.allowedSchemes,
|
|
75
|
+
});
|
|
76
|
+
const dispatcher = new Agent({
|
|
77
|
+
connect: {
|
|
78
|
+
// undici resolves the connect target with `all: true` to support
|
|
79
|
+
// happy-eyeballs; the callback receives a `LookupAddress[]`.
|
|
80
|
+
// Return a single-entry array containing the IP we pinned via
|
|
81
|
+
// `validatePublicUrl`, so the connection target is exactly the
|
|
82
|
+
// address that the SSRF check validated.
|
|
83
|
+
lookup: (_hostname, _opts, cb) => cb(null, [{ address: pinnedAddress, family: pinnedFamily }]),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
// The entire request lifecycle (fetch + status-check + body read +
|
|
87
|
+
// digest) sits inside one try/finally with the dispatcher close:
|
|
88
|
+
// `undici.Agent.close()` waits for active requests to drain, and a
|
|
89
|
+
// request is "drained" only once its body has been consumed. Closing
|
|
90
|
+
// before `readWithLimit` deadlocks the close on the unconsumed body.
|
|
91
|
+
try {
|
|
92
|
+
const response = await undiciFetch(currentUrl, {
|
|
93
|
+
method: 'GET',
|
|
94
|
+
redirect: 'manual',
|
|
95
|
+
signal: controller.signal,
|
|
96
|
+
headers: withUserAgent(options?.headers),
|
|
97
|
+
dispatcher,
|
|
98
|
+
});
|
|
99
|
+
// 304 Not Modified: body is intentionally empty; the caller maps it
|
|
100
|
+
// to `unchanged` in `resolveDocumentIfChanged`.
|
|
101
|
+
if (response.status === 304) {
|
|
102
|
+
const headerView = extractHeaders(response.headers);
|
|
103
|
+
const empty = new Uint8Array(0);
|
|
104
|
+
const bodyDigest = await computeDigest(empty);
|
|
105
|
+
return {
|
|
106
|
+
finalUrl: currentUrl,
|
|
107
|
+
status: 304,
|
|
108
|
+
body: empty,
|
|
109
|
+
bodyDigest,
|
|
110
|
+
etag: headerView.etag,
|
|
111
|
+
lastModified: headerView.lastModified,
|
|
112
|
+
contentType: headerView.contentType,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (response.status >= 300 && response.status < 400) {
|
|
116
|
+
const location = response.headers.get('location');
|
|
117
|
+
if (!location) {
|
|
118
|
+
throw new ResolverRedirectMissingLocationError(currentUrl, response.status);
|
|
119
|
+
}
|
|
120
|
+
let nextUrl;
|
|
121
|
+
try {
|
|
122
|
+
nextUrl = new URL(location, currentUrl).toString();
|
|
123
|
+
}
|
|
124
|
+
catch (cause) {
|
|
125
|
+
throw new ResolverRedirectMissingLocationError(currentUrl, location, cause);
|
|
126
|
+
}
|
|
127
|
+
// Cancel the 3xx body so `dispatcher.close()` in `finally` can
|
|
128
|
+
// proceed without waiting for body drain.
|
|
129
|
+
if (response.body)
|
|
130
|
+
await response.body.cancel().catch(() => undefined);
|
|
131
|
+
currentUrl = nextUrl;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
if (response.body)
|
|
136
|
+
await response.body.cancel().catch(() => undefined);
|
|
137
|
+
throw new ResolverHttpError(currentUrl, response.status);
|
|
138
|
+
}
|
|
139
|
+
const body = await readWithLimit(response, maxBytes, currentUrl, totalTimeoutMs);
|
|
140
|
+
const bodyDigest = await computeDigest(body);
|
|
141
|
+
const headerView = extractHeaders(response.headers);
|
|
142
|
+
return {
|
|
143
|
+
finalUrl: currentUrl,
|
|
144
|
+
status: response.status,
|
|
145
|
+
body,
|
|
146
|
+
bodyDigest,
|
|
147
|
+
etag: headerView.etag,
|
|
148
|
+
lastModified: headerView.lastModified,
|
|
149
|
+
contentType: headerView.contentType,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await dispatcher.close().catch(() => undefined);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
throw new ResolverTooManyRedirectsError(url, maxRedirects);
|
|
157
|
+
}
|
|
158
|
+
catch (cause) {
|
|
159
|
+
if (cause instanceof Error && cause.code?.startsWith('resolver.')) {
|
|
160
|
+
throw cause;
|
|
161
|
+
}
|
|
162
|
+
// Re-throw UrlValidationErrors and other StructuredErrors unwrapped.
|
|
163
|
+
if (cause instanceof Error &&
|
|
164
|
+
typeof cause.code === 'string' &&
|
|
165
|
+
(cause.code.startsWith('url.') ||
|
|
166
|
+
cause.code.startsWith('resolver.'))) {
|
|
167
|
+
throw cause;
|
|
168
|
+
}
|
|
169
|
+
if (isAbortLikeError(cause)) {
|
|
170
|
+
throw new ResolverTimedOutError(url, totalTimeoutMs, cause);
|
|
171
|
+
}
|
|
172
|
+
throw new ResolverNetworkError(url, cause);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
clearTimeout(timeoutHandle);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function isAbortLikeError(error) {
|
|
179
|
+
return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
|
|
180
|
+
}
|
|
181
|
+
function extractHeaders(headers) {
|
|
182
|
+
const view = {};
|
|
183
|
+
const etag = parseEntityTag(headers.get('etag') ?? '');
|
|
184
|
+
if (etag !== undefined)
|
|
185
|
+
view.etag = etag;
|
|
186
|
+
const lastModified = parseImfDate(headers.get('last-modified') ?? '');
|
|
187
|
+
if (lastModified !== undefined)
|
|
188
|
+
view.lastModified = lastModified;
|
|
189
|
+
const contentType = parseMediaType(headers.get('content-type') ?? '');
|
|
190
|
+
if (contentType !== undefined)
|
|
191
|
+
view.contentType = contentType;
|
|
192
|
+
return view;
|
|
193
|
+
}
|
|
194
|
+
async function computeDigest(body) {
|
|
195
|
+
return MultibaseDigest.fromData(body, {
|
|
196
|
+
algorithm: RESOLVER_DEFAULTS.digestAlgorithm,
|
|
197
|
+
base: RESOLVER_DEFAULTS.digestEncoding,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
async function readWithLimit(response, limit, url, totalTimeoutMs) {
|
|
201
|
+
if (!response.body)
|
|
202
|
+
return new Uint8Array(0);
|
|
203
|
+
const reader = response.body.getReader();
|
|
204
|
+
const chunks = [];
|
|
205
|
+
let total = 0;
|
|
206
|
+
try {
|
|
207
|
+
// eslint-disable-next-line no-constant-condition
|
|
208
|
+
while (true) {
|
|
209
|
+
const { value, done } = await reader.read();
|
|
210
|
+
if (done)
|
|
211
|
+
break;
|
|
212
|
+
if (!value)
|
|
213
|
+
continue;
|
|
214
|
+
total += value.byteLength;
|
|
215
|
+
if (total > limit) {
|
|
216
|
+
await reader.cancel().catch(() => undefined);
|
|
217
|
+
throw new ResolverTooLargeError(url, limit);
|
|
218
|
+
}
|
|
219
|
+
chunks.push(value);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
// Release the reader so the upstream socket can be recycled.
|
|
224
|
+
await reader.cancel().catch(() => undefined);
|
|
225
|
+
if (error instanceof ResolverTooLargeError)
|
|
226
|
+
throw error;
|
|
227
|
+
if (isAbortLikeError(error))
|
|
228
|
+
throw new ResolverTimedOutError(url, totalTimeoutMs, error);
|
|
229
|
+
throw new ResolverNetworkError(url, error);
|
|
230
|
+
}
|
|
231
|
+
const merged = new Uint8Array(total);
|
|
232
|
+
let offset = 0;
|
|
233
|
+
for (const chunk of chunks) {
|
|
234
|
+
merged.set(chunk, offset);
|
|
235
|
+
offset += chunk.byteLength;
|
|
236
|
+
}
|
|
237
|
+
return merged;
|
|
238
|
+
}
|
|
239
|
+
//# sourceMappingURL=resolve-document.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-document.js","sourceRoot":"","sources":["../../src/resolvers/resolve-document.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
import { ReadableStream } from 'node:stream/web';
|
|
3
|
+
import { PrivateAddressError } from '../node/index.js';
|
|
4
|
+
import { ResolverHttpError, ResolverNetworkError, ResolverRedirectMissingLocationError, ResolverTimedOutError, ResolverTooLargeError, ResolverTooManyRedirectsError, } from './errors.js';
|
|
5
|
+
const undiciFetch = jest.fn();
|
|
6
|
+
let lastAgentClose = jest.fn(() => Promise.resolve());
|
|
7
|
+
class FakeAgent {
|
|
8
|
+
close;
|
|
9
|
+
constructor() {
|
|
10
|
+
this.close = jest.fn(() => Promise.resolve());
|
|
11
|
+
lastAgentClose = this.close;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
jest.unstable_mockModule('undici', () => ({
|
|
15
|
+
Agent: FakeAgent,
|
|
16
|
+
fetch: undiciFetch,
|
|
17
|
+
}));
|
|
18
|
+
const validatePublicUrl = jest.fn();
|
|
19
|
+
jest.unstable_mockModule('../node/index.js', () => ({
|
|
20
|
+
validatePublicUrl,
|
|
21
|
+
PrivateAddressError,
|
|
22
|
+
}));
|
|
23
|
+
const { resolveDocument } = await import('./resolve-document.js');
|
|
24
|
+
const { DEFAULT_USER_AGENT, USER_AGENT_ENV_VAR } = await import('../http-headers/index.js');
|
|
25
|
+
function makeResponse(opts) {
|
|
26
|
+
const status = opts.status ?? 200;
|
|
27
|
+
const headers = new Headers(opts.headers ?? {});
|
|
28
|
+
let bodyStream;
|
|
29
|
+
if (opts.body === null || opts.body === undefined) {
|
|
30
|
+
bodyStream = null;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const bytes = typeof opts.body === 'string' ? new TextEncoder().encode(opts.body) : opts.body;
|
|
34
|
+
bodyStream = new ReadableStream({
|
|
35
|
+
start(controller) {
|
|
36
|
+
controller.enqueue(bytes);
|
|
37
|
+
controller.close();
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
status,
|
|
43
|
+
ok: opts.ok ?? (status >= 200 && status < 300),
|
|
44
|
+
headers,
|
|
45
|
+
body: bodyStream,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function resolvedAddress(address = '1.1.1.1', family = 4) {
|
|
49
|
+
return { address, family };
|
|
50
|
+
}
|
|
51
|
+
describe('resolveDocument', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
undiciFetch.mockReset();
|
|
54
|
+
validatePublicUrl.mockReset();
|
|
55
|
+
});
|
|
56
|
+
describe('SSRF guard', () => {
|
|
57
|
+
it('propagates validatePublicUrl errors unwrapped and never calls fetch', async () => {
|
|
58
|
+
validatePublicUrl.mockRejectedValue(new PrivateAddressError('https://attacker.example/', ['10.0.0.1']));
|
|
59
|
+
await expect(resolveDocument('https://attacker.example/')).rejects.toBeInstanceOf(PrivateAddressError);
|
|
60
|
+
expect(undiciFetch).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe('happy path', () => {
|
|
64
|
+
it('returns a LoadResult with body, digest, and the allowlisted headers', async () => {
|
|
65
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
66
|
+
undiciFetch.mockResolvedValue(makeResponse({
|
|
67
|
+
body: '{"hello":"world"}',
|
|
68
|
+
headers: {
|
|
69
|
+
'content-type': 'application/json',
|
|
70
|
+
etag: '"abc"',
|
|
71
|
+
'last-modified': 'Wed, 21 May 2026 12:00:00 GMT',
|
|
72
|
+
'set-cookie': 'session=secret', // must NOT appear on the LoadResult
|
|
73
|
+
'x-internal-trace': 'noisy', // ditto
|
|
74
|
+
},
|
|
75
|
+
}));
|
|
76
|
+
const result = await resolveDocument('https://example.com/doc.json');
|
|
77
|
+
expect(result.status).toBe(200);
|
|
78
|
+
expect(result.finalUrl).toBe('https://example.com/doc.json');
|
|
79
|
+
expect(result.etag).toBe('"abc"');
|
|
80
|
+
expect(result.lastModified).toBe('Wed, 21 May 2026 12:00:00 GMT');
|
|
81
|
+
expect(result.contentType).toBe('application/json');
|
|
82
|
+
expect(new TextDecoder().decode(result.body)).toBe('{"hello":"world"}');
|
|
83
|
+
expect(result.bodyDigest.toString()).toMatch(/^z[1-9A-HJ-NP-Za-km-z]+$/); // multibase base58btc
|
|
84
|
+
// Arbitrary upstream headers must NOT be echoed onto the LoadResult.
|
|
85
|
+
const valueAsRecord = result;
|
|
86
|
+
expect(valueAsRecord['set-cookie']).toBeUndefined();
|
|
87
|
+
expect(valueAsRecord['x-internal-trace']).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('HTTP errors', () => {
|
|
91
|
+
it('throws ResolverHttpError for a 4xx response with status attached', async () => {
|
|
92
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
93
|
+
undiciFetch.mockResolvedValue(makeResponse({ status: 404, ok: false, body: 'not found' }));
|
|
94
|
+
const error = (await resolveDocument('https://example.com/missing').catch((e) => e));
|
|
95
|
+
expect(error).toBeInstanceOf(ResolverHttpError);
|
|
96
|
+
expect(error.status).toBe(404);
|
|
97
|
+
});
|
|
98
|
+
it('throws ResolverHttpError for a 5xx response', async () => {
|
|
99
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
100
|
+
undiciFetch.mockResolvedValue(makeResponse({ status: 503, ok: false, body: 'busy' }));
|
|
101
|
+
const error = (await resolveDocument('https://example.com/busy').catch((e) => e));
|
|
102
|
+
expect(error).toBeInstanceOf(ResolverHttpError);
|
|
103
|
+
expect(error.status).toBe(503);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('network errors and timeouts', () => {
|
|
107
|
+
it('throws ResolverNetworkError on fetch rejection', async () => {
|
|
108
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
109
|
+
undiciFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
110
|
+
const error = (await resolveDocument('https://example.com/').catch((e) => e));
|
|
111
|
+
expect(error).toBeInstanceOf(ResolverNetworkError);
|
|
112
|
+
expect(error.cause).toBeInstanceOf(Error);
|
|
113
|
+
});
|
|
114
|
+
it('throws ResolverTimedOutError when the fetch is aborted', async () => {
|
|
115
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
116
|
+
const abort = new Error('aborted');
|
|
117
|
+
abort.name = 'AbortError';
|
|
118
|
+
undiciFetch.mockRejectedValue(abort);
|
|
119
|
+
await expect(resolveDocument('https://example.com/')).rejects.toBeInstanceOf(ResolverTimedOutError);
|
|
120
|
+
});
|
|
121
|
+
it('throws ResolverNetworkError when the response body stream rejects mid-read', async () => {
|
|
122
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
123
|
+
const erroringStream = new ReadableStream({
|
|
124
|
+
pull(controller) {
|
|
125
|
+
controller.error(new Error('connection reset by peer'));
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
undiciFetch.mockResolvedValue({
|
|
129
|
+
status: 200,
|
|
130
|
+
ok: true,
|
|
131
|
+
headers: new Headers(),
|
|
132
|
+
body: erroringStream,
|
|
133
|
+
});
|
|
134
|
+
await expect(resolveDocument('https://example.com/')).rejects.toBeInstanceOf(ResolverNetworkError);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
describe('size limit', () => {
|
|
138
|
+
it('throws ResolverTooLargeError with limit attached when the body exceeds maxResponseBytes', async () => {
|
|
139
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
140
|
+
const big = new Uint8Array(2048);
|
|
141
|
+
undiciFetch.mockResolvedValue(makeResponse({ body: big }));
|
|
142
|
+
const error = (await resolveDocument('https://example.com/big', { maxResponseBytes: 1024 }).catch((e) => e));
|
|
143
|
+
expect(error).toBeInstanceOf(ResolverTooLargeError);
|
|
144
|
+
expect(error.limit).toBe(1024);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe('redirects', () => {
|
|
148
|
+
it('follows a redirect chain within the cap', async () => {
|
|
149
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
150
|
+
undiciFetch
|
|
151
|
+
.mockResolvedValueOnce(makeResponse({ status: 301, headers: { location: 'https://example.com/next' }, body: null }))
|
|
152
|
+
.mockResolvedValueOnce(makeResponse({ body: 'final' }));
|
|
153
|
+
const result = await resolveDocument('https://example.com/start');
|
|
154
|
+
expect(result.finalUrl).toBe('https://example.com/next');
|
|
155
|
+
expect(new TextDecoder().decode(result.body)).toBe('final');
|
|
156
|
+
expect(undiciFetch).toHaveBeenCalledTimes(2);
|
|
157
|
+
});
|
|
158
|
+
it('re-validates each redirect target through validatePublicUrl', async () => {
|
|
159
|
+
validatePublicUrl
|
|
160
|
+
.mockResolvedValueOnce(resolvedAddress('1.1.1.1'))
|
|
161
|
+
.mockRejectedValueOnce(new PrivateAddressError('https://internal.example/', ['10.0.0.1']));
|
|
162
|
+
undiciFetch.mockResolvedValueOnce(makeResponse({ status: 301, headers: { location: 'https://internal.example/' }, body: null }));
|
|
163
|
+
await expect(resolveDocument('https://public.example/')).rejects.toBeInstanceOf(PrivateAddressError);
|
|
164
|
+
expect(validatePublicUrl).toHaveBeenCalledTimes(2);
|
|
165
|
+
// Second hop's URL must be the Location target, not the original.
|
|
166
|
+
expect(validatePublicUrl).toHaveBeenNthCalledWith(2, 'https://internal.example/', expect.anything());
|
|
167
|
+
expect(undiciFetch).toHaveBeenCalledTimes(1);
|
|
168
|
+
});
|
|
169
|
+
it('throws ResolverRedirectMissingLocationError when Location is not a parseable URL', async () => {
|
|
170
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
171
|
+
const malformed = 'http://[';
|
|
172
|
+
undiciFetch.mockResolvedValue(makeResponse({ status: 301, headers: { location: malformed }, body: null }));
|
|
173
|
+
const error = (await resolveDocument('https://example.com/').catch((e) => e));
|
|
174
|
+
expect(error).toBeInstanceOf(ResolverRedirectMissingLocationError);
|
|
175
|
+
expect(error.received).toBe(malformed);
|
|
176
|
+
});
|
|
177
|
+
it('throws ResolverTooManyRedirectsError with limit attached when the chain exceeds the cap', async () => {
|
|
178
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
179
|
+
undiciFetch.mockResolvedValue(makeResponse({ status: 301, headers: { location: 'https://example.com/loop' }, body: null }));
|
|
180
|
+
const error = (await resolveDocument('https://example.com/start', { maxRedirects: 1 }).catch((e) => e));
|
|
181
|
+
expect(error).toBeInstanceOf(ResolverTooManyRedirectsError);
|
|
182
|
+
expect(error.limit).toBe(1);
|
|
183
|
+
});
|
|
184
|
+
it('throws ResolverRedirectMissingLocationError when a 3xx has no Location', async () => {
|
|
185
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
186
|
+
undiciFetch.mockResolvedValue(makeResponse({ status: 302, body: null }));
|
|
187
|
+
await expect(resolveDocument('https://example.com/')).rejects.toBeInstanceOf(ResolverRedirectMissingLocationError);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
describe('dispatcher lifecycle', () => {
|
|
191
|
+
it('closes the dispatcher only after the response body has been fully read', async () => {
|
|
192
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
193
|
+
const bodyPull = jest.fn();
|
|
194
|
+
const slowStream = new ReadableStream({
|
|
195
|
+
async pull(controller) {
|
|
196
|
+
bodyPull();
|
|
197
|
+
controller.enqueue(new TextEncoder().encode('chunk'));
|
|
198
|
+
controller.close();
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
undiciFetch.mockResolvedValue({
|
|
202
|
+
status: 200,
|
|
203
|
+
ok: true,
|
|
204
|
+
headers: new Headers(),
|
|
205
|
+
body: slowStream,
|
|
206
|
+
});
|
|
207
|
+
await resolveDocument('https://example.com/');
|
|
208
|
+
expect(bodyPull).toHaveBeenCalled();
|
|
209
|
+
expect(lastAgentClose).toHaveBeenCalled();
|
|
210
|
+
const pullOrder = bodyPull.mock.invocationCallOrder[0];
|
|
211
|
+
const closeOrder = lastAgentClose.mock.invocationCallOrder[0];
|
|
212
|
+
expect(closeOrder).toBeGreaterThan(pullOrder);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
describe('User-Agent', () => {
|
|
216
|
+
afterEach(() => {
|
|
217
|
+
delete process.env[USER_AGENT_ENV_VAR];
|
|
218
|
+
});
|
|
219
|
+
function sentHeaders(callIndex = 0) {
|
|
220
|
+
const init = undiciFetch.mock.calls[callIndex][1];
|
|
221
|
+
return init.headers ?? {};
|
|
222
|
+
}
|
|
223
|
+
it('sends the default User-Agent when the caller supplies none', async () => {
|
|
224
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
225
|
+
undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
|
|
226
|
+
await resolveDocument('https://example.com/doc.json', { headers: { Accept: 'application/json' } });
|
|
227
|
+
expect(sentHeaders()['User-Agent']).toBe(DEFAULT_USER_AGENT);
|
|
228
|
+
expect(sentHeaders()['Accept']).toBe('application/json');
|
|
229
|
+
});
|
|
230
|
+
it('sends the default User-Agent when no headers are supplied at all', async () => {
|
|
231
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
232
|
+
undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
|
|
233
|
+
await resolveDocument('https://example.com/doc.json');
|
|
234
|
+
expect(sentHeaders()['User-Agent']).toBe(DEFAULT_USER_AGENT);
|
|
235
|
+
});
|
|
236
|
+
it('prefers the RI_HTTP_USER_AGENT environment override to the default', async () => {
|
|
237
|
+
process.env[USER_AGENT_ENV_VAR] = 'acme-operator/1.0';
|
|
238
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
239
|
+
undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
|
|
240
|
+
await resolveDocument('https://example.com/doc.json');
|
|
241
|
+
expect(sentHeaders()['User-Agent']).toBe('acme-operator/1.0');
|
|
242
|
+
});
|
|
243
|
+
it('falls back to the default when the environment override is blank (blank is treated as unset)', async () => {
|
|
244
|
+
process.env[USER_AGENT_ENV_VAR] = ' ';
|
|
245
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
246
|
+
undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
|
|
247
|
+
await resolveDocument('https://example.com/doc.json');
|
|
248
|
+
expect(sentHeaders()['User-Agent']).toBe(DEFAULT_USER_AGENT);
|
|
249
|
+
});
|
|
250
|
+
it('passes a non-blank override through unvalidated (boot-time validation owns rejection)', async () => {
|
|
251
|
+
process.env[USER_AGENT_ENV_VAR] = 'operator-agent/1.0';
|
|
252
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
253
|
+
undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
|
|
254
|
+
await resolveDocument('https://example.com/doc.json');
|
|
255
|
+
expect(sentHeaders()['User-Agent']).toBe('operator-agent/1.0');
|
|
256
|
+
});
|
|
257
|
+
it('passes an invalid override through raw rather than silently substituting the default', async () => {
|
|
258
|
+
process.env[USER_AGENT_ENV_VAR] = 'evil\r\nX-Injected: 1';
|
|
259
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
260
|
+
undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
|
|
261
|
+
await resolveDocument('https://example.com/doc.json');
|
|
262
|
+
// The load-bearing rule: no request-time fallback. The raw value reaches
|
|
263
|
+
// the fetch layer, which rejects it loudly in real undici; deployments
|
|
264
|
+
// that want fail-fast validate at boot instead.
|
|
265
|
+
expect(sentHeaders()['User-Agent']).toBe('evil\r\nX-Injected: 1');
|
|
266
|
+
});
|
|
267
|
+
it.each(['user-agent', 'User-Agent', 'USER-AGENT', 'uSeR-aGeNt'])('never overrides a caller-supplied %s header', async (headerName) => {
|
|
268
|
+
process.env[USER_AGENT_ENV_VAR] = 'acme-operator/1.0';
|
|
269
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
270
|
+
undiciFetch.mockResolvedValue(makeResponse({ body: '{}' }));
|
|
271
|
+
await resolveDocument('https://example.com/doc.json', { headers: { [headerName]: 'caller/2.0' } });
|
|
272
|
+
const headers = sentHeaders();
|
|
273
|
+
expect(headers[headerName]).toBe('caller/2.0');
|
|
274
|
+
const uaKeys = Object.keys(headers).filter((k) => k.toLowerCase() === 'user-agent');
|
|
275
|
+
expect(uaKeys).toEqual([headerName]);
|
|
276
|
+
});
|
|
277
|
+
it('sends the User-Agent on every redirect hop', async () => {
|
|
278
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
279
|
+
undiciFetch
|
|
280
|
+
.mockResolvedValueOnce(makeResponse({ status: 302, headers: { location: 'https://example.com/final' }, body: null }))
|
|
281
|
+
.mockResolvedValueOnce(makeResponse({ body: '{}' }));
|
|
282
|
+
await resolveDocument('https://example.com/doc.json');
|
|
283
|
+
expect(undiciFetch).toHaveBeenCalledTimes(2);
|
|
284
|
+
expect(sentHeaders(0)['User-Agent']).toBe(DEFAULT_USER_AGENT);
|
|
285
|
+
expect(sentHeaders(1)['User-Agent']).toBe(DEFAULT_USER_AGENT);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
describe('304 Not Modified', () => {
|
|
289
|
+
it('returns a LoadResult with status 304 and an empty body', async () => {
|
|
290
|
+
validatePublicUrl.mockResolvedValue(resolvedAddress());
|
|
291
|
+
undiciFetch.mockResolvedValue(makeResponse({ status: 304, body: null, headers: { etag: '"abc"' } }));
|
|
292
|
+
const result = await resolveDocument('https://example.com/');
|
|
293
|
+
expect(result.status).toBe(304);
|
|
294
|
+
expect(result.body.byteLength).toBe(0);
|
|
295
|
+
expect(result.etag).toBe('"abc"');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
//# sourceMappingURL=resolve-document.test.js.map
|