@fora-protocol/sdk 1.0.3
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/LICENSE +201 -0
- package/README.md +37 -0
- package/gen/ts/vocab/functiontokens.js +78 -0
- package/gen/ts/vocab/functiontokens.ts +84 -0
- package/gen/ts/vocab/geographytokens.js +30 -0
- package/gen/ts/vocab/geographytokens.ts +37 -0
- package/gen/ts/vocab/pricingunits.js +56 -0
- package/gen/ts/vocab/pricingunits.ts +63 -0
- package/gen/ts/vocab/quotametrics.js +40 -0
- package/gen/ts/vocab/quotametrics.ts +47 -0
- package/gen/ts/vocab/usertypes.js +40 -0
- package/gen/ts/vocab/usertypes.ts +46 -0
- package/gen/ts/wire/base.js +245 -0
- package/gen/ts/wire/base.ts +255 -0
- package/gen/ts/wire/names.js +41 -0
- package/gen/ts/wire/names.ts +42 -0
- package/gen/ts/wire/schemas.js +111 -0
- package/gen/ts/wire/schemas.ts +216 -0
- package/package.json +164 -0
- package/sdk/ts/client/content.d.ts +74 -0
- package/sdk/ts/client/content.js +466 -0
- package/sdk/ts/client/errors.d.ts +79 -0
- package/sdk/ts/client/errors.js +148 -0
- package/sdk/ts/client/index.d.ts +241 -0
- package/sdk/ts/client/index.js +740 -0
- package/sdk/ts/client/route.d.ts +41 -0
- package/sdk/ts/client/route.js +107 -0
- package/sdk/ts/client/send.d.ts +64 -0
- package/sdk/ts/client/send.js +142 -0
- package/sdk/ts/client/transport.d.ts +187 -0
- package/sdk/ts/client/transport.js +381 -0
- package/sdk/ts/core/multisig-parse.d.ts +47 -0
- package/sdk/ts/core/multisig-parse.js +234 -0
- package/sdk/ts/core/sign-request.d.ts +83 -0
- package/sdk/ts/core/sign-request.js +146 -0
- package/sdk/ts/core/sign.d.ts +31 -0
- package/sdk/ts/core/sign.js +82 -0
- package/sdk/ts/core/signing-transport.d.ts +75 -0
- package/sdk/ts/core/signing-transport.js +172 -0
- package/sdk/ts/core/verifier.d.ts +177 -0
- package/sdk/ts/core/verifier.js +269 -0
- package/sdk/ts/core/verify-multisig-request.d.ts +72 -0
- package/sdk/ts/core/verify-multisig-request.js +112 -0
- package/sdk/ts/core/verify-request.d.ts +181 -0
- package/sdk/ts/core/verify-request.js +284 -0
- package/sdk/ts/core/window.d.ts +22 -0
- package/sdk/ts/core/window.js +43 -0
- package/sdk/ts/core/wire-canon.d.ts +20 -0
- package/sdk/ts/core/wire-canon.js +174 -0
- package/sdk/ts/hono/middleware.d.ts +38 -0
- package/sdk/ts/hono/middleware.js +64 -0
- package/sdk/ts/resolvers/errors.d.ts +130 -0
- package/sdk/ts/resolvers/errors.js +150 -0
- package/sdk/ts/resolvers/http.d.ts +98 -0
- package/sdk/ts/resolvers/http.js +274 -0
- package/sdk/ts/resolvers/index.d.ts +9 -0
- package/sdk/ts/resolvers/index.js +15 -0
- package/sdk/ts/resolvers/jwks.d.ts +2 -0
- package/sdk/ts/resolvers/jwks.js +43 -0
- package/sdk/ts/resolvers/offer-key-cache.d.ts +68 -0
- package/sdk/ts/resolvers/offer-key-cache.js +152 -0
- package/sdk/ts/resolvers/registration-requirements.d.ts +47 -0
- package/sdk/ts/resolvers/registration-requirements.js +220 -0
- package/sdk/ts/resolvers/ssrf.d.ts +28 -0
- package/sdk/ts/resolvers/ssrf.js +235 -0
- package/sdk/ts/resolvers/static.d.ts +7 -0
- package/sdk/ts/resolvers/static.js +16 -0
- package/sdk/ts/resolvers/wba.d.ts +134 -0
- package/sdk/ts/resolvers/wba.js +506 -0
- package/sdk/ts/resolvers/wellknown.d.ts +28 -0
- package/sdk/ts/resolvers/wellknown.js +169 -0
- package/sdk/ts/src/acceptance.d.ts +46 -0
- package/sdk/ts/src/acceptance.js +162 -0
- package/sdk/ts/src/base64url.d.ts +19 -0
- package/sdk/ts/src/base64url.js +55 -0
- package/sdk/ts/src/crossfield.d.ts +28 -0
- package/sdk/ts/src/crossfield.js +215 -0
- package/sdk/ts/src/endpoint-rule.d.ts +30 -0
- package/sdk/ts/src/endpoint-rule.js +63 -0
- package/sdk/ts/src/errordetail.d.ts +115 -0
- package/sdk/ts/src/errordetail.js +256 -0
- package/sdk/ts/src/hashurl.d.ts +5 -0
- package/sdk/ts/src/hashurl.js +15 -0
- package/sdk/ts/src/host-ref.d.ts +70 -0
- package/sdk/ts/src/host-ref.js +320 -0
- package/sdk/ts/src/hosts.d.ts +138 -0
- package/sdk/ts/src/hosts.js +214 -0
- package/sdk/ts/src/idempotency.d.ts +11 -0
- package/sdk/ts/src/idempotency.js +27 -0
- package/sdk/ts/src/jsondepth.d.ts +27 -0
- package/sdk/ts/src/jsondepth.js +77 -0
- package/sdk/ts/src/licenseterm.d.ts +113 -0
- package/sdk/ts/src/licenseterm.js +433 -0
- package/sdk/ts/src/money.d.ts +21 -0
- package/sdk/ts/src/money.js +60 -0
- package/sdk/ts/src/offer-sign.d.ts +11 -0
- package/sdk/ts/src/offer-sign.js +31 -0
- package/sdk/ts/src/opaque-url.d.ts +3 -0
- package/sdk/ts/src/opaque-url.js +20 -0
- package/sdk/ts/src/pop.d.ts +51 -0
- package/sdk/ts/src/pop.js +166 -0
- package/sdk/ts/src/regschema.d.ts +229 -0
- package/sdk/ts/src/regschema.js +1315 -0
- package/sdk/ts/src/scopes.d.ts +20 -0
- package/sdk/ts/src/scopes.js +45 -0
- package/sdk/ts/src/signurl.d.ts +23 -0
- package/sdk/ts/src/signurl.js +107 -0
- package/sdk/ts/src/thumbprint.d.ts +14 -0
- package/sdk/ts/src/thumbprint.js +24 -0
- package/sdk/ts/src/verify.d.ts +49 -0
- package/sdk/ts/src/verify.js +109 -0
- package/sdk/ts/src/wire-names.d.ts +1 -0
- package/sdk/ts/src/wire-names.js +16 -0
- package/sdk/ts/src/wire.d.ts +68 -0
- package/sdk/ts/src/wire.js +114 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { ErrorDetailSchema } from "../../../gen/ts/wire/schemas.js";
|
|
2
|
+
import { snakeFromJsonName } from "./wire-names.js";
|
|
3
|
+
/** The fully-qualified proto name Connect stamps on an ErrorDetail transport detail. */
|
|
4
|
+
export const ERROR_DETAIL_TYPE = "fora.v1.ErrorDetail";
|
|
5
|
+
/**
|
|
6
|
+
* The ErrorDetail `reason` oneof members, in proto field-number order (the same
|
|
7
|
+
* order the Go helpers.Reason accessor walks). Exactly one is populated when a
|
|
8
|
+
* typed reason is present; all are absent for a generic transport-class failure.
|
|
9
|
+
*/
|
|
10
|
+
export const REASON_FIELDS = [
|
|
11
|
+
"transaction_denial",
|
|
12
|
+
"catalog_rejection",
|
|
13
|
+
"registration_failure",
|
|
14
|
+
"dispute_failure",
|
|
15
|
+
"domain_verification_failure",
|
|
16
|
+
"retrieval_auth_failure",
|
|
17
|
+
"usage_report_rejection",
|
|
18
|
+
];
|
|
19
|
+
function isRecord(v) {
|
|
20
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Parse a canonical proto-JSON ErrorDetail object into the generated model. The
|
|
24
|
+
* payload is the proto-JSON object (snake_case keys, enums as NAME strings). This
|
|
25
|
+
* is the low-level decode the corpus replay exercises and the building block of
|
|
26
|
+
* {@link errorDetailFrom}.
|
|
27
|
+
*/
|
|
28
|
+
export function parseErrorDetail(payload) {
|
|
29
|
+
return ErrorDetailSchema.parse(payload);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Return the active typed reason (oneof family + enum NAME), or null when no reason
|
|
33
|
+
* is set. Mirrors the Go helpers.Reason accessor so callers branch on the family +
|
|
34
|
+
* value, never on the human message string.
|
|
35
|
+
*/
|
|
36
|
+
export function reason(detail) {
|
|
37
|
+
const record = detail;
|
|
38
|
+
for (const field of REASON_FIELDS) {
|
|
39
|
+
const block = record[field];
|
|
40
|
+
if (block != null && block.reason != null) {
|
|
41
|
+
return { field, value: block.reason };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function detailsOf(err) {
|
|
47
|
+
if (Array.isArray(err))
|
|
48
|
+
return err;
|
|
49
|
+
if (isRecord(err) && Array.isArray(err["details"]))
|
|
50
|
+
return err["details"];
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
// The one ErrorDetail member whose KEYS belong to whoever emitted it rather than to the
|
|
54
|
+
// proto: `metadata` is a map<string,string>, so a key inside it is data and must reach
|
|
55
|
+
// the caller byte-for-byte. Nothing else in the ErrorDetail subtree is a map or a
|
|
56
|
+
// Struct, which is what makes a single name enough; the conformance suite holds that
|
|
57
|
+
// fact so a future map field cannot quietly widen what the walk below rewrites.
|
|
58
|
+
const OPEN_MAP_MEMBERS = new Set(["metadata"]);
|
|
59
|
+
/**
|
|
60
|
+
* Rewrite a lowerCamelCase proto-JSON object into the proto's own field names.
|
|
61
|
+
*
|
|
62
|
+
* Connect's error-detail `debug` projection is the one place a FORA payload arrives in
|
|
63
|
+
* lowerCamelCase, and no server option changes it: connect-go renders it with its own
|
|
64
|
+
* protojson codec at default options, inside a method on an unexported type, so the
|
|
65
|
+
* snake_case codec a FORA deployment registers reaches the response BODY and not the
|
|
66
|
+
* error beside it. The generated ErrorDetailSchema accepts snake_case only — which is
|
|
67
|
+
* the FORA wire — and strips unknown keys, so without this the reason block is silently
|
|
68
|
+
* dropped and a refusal the Exchange named precisely reads back as no reason at all.
|
|
69
|
+
*
|
|
70
|
+
* Values under an open map keep their keys verbatim: those are the emitter's, not the
|
|
71
|
+
* proto's. The rewrite is textual and depends on protojson's spelling being invertible,
|
|
72
|
+
* which the conformance suite proves for every field in the contract.
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* How deep a peer's error detail may nest.
|
|
76
|
+
*
|
|
77
|
+
* BOUNDED because the payload is a peer's and the reader runs on every non-2xx. Recursing
|
|
78
|
+
* to the engine's own stack limit turns a nested object into a RangeError thrown out of a
|
|
79
|
+
* library the caller never invoked, which is not one of the failures this package says it
|
|
80
|
+
* throws. A real ErrorDetail nests two deep; the bound is the same 32 the protocol already
|
|
81
|
+
* sets for a third party's JSON in AccountRegistration.data_schema, so one number covers
|
|
82
|
+
* "how deep may a stranger's document be".
|
|
83
|
+
*/
|
|
84
|
+
const MAX_DETAIL_DEPTH = 32;
|
|
85
|
+
/** Thrown past MAX_DETAIL_DEPTH. Module-private: it never reaches a caller, because the
|
|
86
|
+
* reader answers "no detail" instead. */
|
|
87
|
+
class TooDeep extends Error {
|
|
88
|
+
}
|
|
89
|
+
function protoNames(payload, budget = MAX_DETAIL_DEPTH) {
|
|
90
|
+
if (budget <= 0)
|
|
91
|
+
throw new TooDeep();
|
|
92
|
+
if (Array.isArray(payload))
|
|
93
|
+
return payload.map((v) => protoNames(v, budget - 1));
|
|
94
|
+
if (!isRecord(payload))
|
|
95
|
+
return payload;
|
|
96
|
+
const out = {};
|
|
97
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
98
|
+
const name = snakeFromJsonName(key);
|
|
99
|
+
const member = OPEN_MAP_MEMBERS.has(name) ? value : protoNames(value, budget - 1);
|
|
100
|
+
// defineProperty, not assignment: a key named "__proto__" would otherwise invoke
|
|
101
|
+
// the prototype setter and set this object's prototype instead of a member on it.
|
|
102
|
+
Object.defineProperty(out, name, {
|
|
103
|
+
value: member,
|
|
104
|
+
enumerable: true,
|
|
105
|
+
writable: true,
|
|
106
|
+
configurable: true,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Extract the first FORA ErrorDetail from a Connect error (or its details array).
|
|
113
|
+
* `err` is either a Connect error object (carrying a `details` array) or the
|
|
114
|
+
* details iterable itself. Each detail entry is the Connect wire form
|
|
115
|
+
* `{ "type": "fora.v1.ErrorDetail", ... }`; the ErrorDetail proto-JSON is read from
|
|
116
|
+
* the entry's `debug` projection (Connect includes it for JSON clients) or from a
|
|
117
|
+
* `value` already decoded to an object. Returns null when `err` carries no
|
|
118
|
+
* ErrorDetail — the TS analog of the Go `(detail, false)`.
|
|
119
|
+
*
|
|
120
|
+
* The opaque binary `value` of a detail is intentionally NOT decoded here: the JSON
|
|
121
|
+
* SDKs have no protobuf binary codec, so they consume the proto-JSON form.
|
|
122
|
+
*
|
|
123
|
+
* Both payload forms are read through protoNames, because `debug` arrives
|
|
124
|
+
* lowerCamelCase and a decoded `value` — which only a caller that owns a binary codec
|
|
125
|
+
* can supply — may be either. A snake_case object passes through it unchanged.
|
|
126
|
+
*/
|
|
127
|
+
export function errorDetailFrom(err) {
|
|
128
|
+
for (const entry of detailsOf(err)) {
|
|
129
|
+
if (!isRecord(entry) || entry["type"] !== ERROR_DETAIL_TYPE)
|
|
130
|
+
continue;
|
|
131
|
+
const debug = entry["debug"];
|
|
132
|
+
const value = entry["value"];
|
|
133
|
+
const payload = isRecord(debug) ? debug : isRecord(value) ? value : null;
|
|
134
|
+
if (payload === null)
|
|
135
|
+
continue;
|
|
136
|
+
let normalized;
|
|
137
|
+
try {
|
|
138
|
+
normalized = protoNames(payload);
|
|
139
|
+
}
|
|
140
|
+
catch (cause) {
|
|
141
|
+
// A detail entry that cannot be normalized is not an answer, and it came from a
|
|
142
|
+
// peer that just refused the call. The scan continues: `details` is a list, and
|
|
143
|
+
// an entry this reader cannot use says nothing about the next.
|
|
144
|
+
if (cause instanceof TooDeep)
|
|
145
|
+
continue;
|
|
146
|
+
throw cause;
|
|
147
|
+
}
|
|
148
|
+
const parsed = ErrorDetailSchema.safeParse(normalized);
|
|
149
|
+
if (parsed.success)
|
|
150
|
+
return parsed.data;
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Build an ErrorDetail carrying a typed DenialReason (ExecuteTransaction denial).
|
|
156
|
+
* TS peer of Go `helpers.TransactionDenialDetail`. Sets only the `transaction_denial`
|
|
157
|
+
* reason block; the message's extra sub-fields (offer_id, restriction_mismatches) and
|
|
158
|
+
* metadata are omitted — mutate the returned object post-construction if needed.
|
|
159
|
+
*/
|
|
160
|
+
export function transactionDenialDetail(domain, message, reason) {
|
|
161
|
+
return ErrorDetailSchema.parse({
|
|
162
|
+
domain,
|
|
163
|
+
message,
|
|
164
|
+
transaction_denial: { reason },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Build an ErrorDetail carrying a typed RetrievalAuthFailureReason.
|
|
169
|
+
* TS peer of Go `helpers.RetrievalAuthFailureDetail` (signed-URL / proof-of-possession
|
|
170
|
+
* check failed).
|
|
171
|
+
*/
|
|
172
|
+
export function retrievalAuthFailureDetail(domain, message, reason) {
|
|
173
|
+
return ErrorDetailSchema.parse({
|
|
174
|
+
domain,
|
|
175
|
+
message,
|
|
176
|
+
retrieval_auth_failure: { reason },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Build an ErrorDetail carrying a typed CatalogRejectionReason.
|
|
181
|
+
* TS peer of Go `helpers.CatalogRejectionDetail`. Sets only the `catalog_rejection`
|
|
182
|
+
* reason block; `rejected_paths` is omitted.
|
|
183
|
+
*/
|
|
184
|
+
export function catalogRejectionDetail(domain, message, reason) {
|
|
185
|
+
return ErrorDetailSchema.parse({
|
|
186
|
+
domain,
|
|
187
|
+
message,
|
|
188
|
+
catalog_rejection: { reason },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Build an ErrorDetail carrying a typed RegistrationFailureReason.
|
|
193
|
+
* TS peer of Go `helpers.RegistrationFailureDetail` (agent/provider registration
|
|
194
|
+
* refused).
|
|
195
|
+
*
|
|
196
|
+
* `fieldErrors` carries the offending `registration_data` members when the reason
|
|
197
|
+
* is `REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA`. It is optional
|
|
198
|
+
* rather than positional so the six reasons that carry no per-member detail keep
|
|
199
|
+
* the three-argument call, matching Go's variadic. Passing it with any other
|
|
200
|
+
* reason is a caller error — the field's contract says the list is empty then.
|
|
201
|
+
*/
|
|
202
|
+
export function registrationFailureDetail(domain, message, reason, fieldErrors) {
|
|
203
|
+
const detail = ErrorDetailSchema.parse({
|
|
204
|
+
domain,
|
|
205
|
+
message,
|
|
206
|
+
registration_failure: {
|
|
207
|
+
reason,
|
|
208
|
+
...(fieldErrors?.length ? { field_errors: [...fieldErrors] } : {}),
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
// An empty path is "the whole object", and canonical proto-JSON omits an empty
|
|
212
|
+
// scalar — so the wire form of a root-pointer entry carries no `path` key at
|
|
213
|
+
// all. The generated Zod schema declares `.default("")`, which materializes the
|
|
214
|
+
// member on parse, so it is dropped back off here. This is the exact inverse of
|
|
215
|
+
// the read side normalizing an absent path to "".
|
|
216
|
+
const rf = detail
|
|
217
|
+
.registration_failure;
|
|
218
|
+
for (const fe of rf?.field_errors ?? []) {
|
|
219
|
+
if (fe.path === "")
|
|
220
|
+
delete fe.path;
|
|
221
|
+
}
|
|
222
|
+
return detail;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Build an ErrorDetail carrying a typed DisputeFailureReason.
|
|
226
|
+
* TS peer of Go `helpers.DisputeFailureDetail` (DisputeTransaction filing refused).
|
|
227
|
+
*/
|
|
228
|
+
export function disputeFailureDetail(domain, message, reason) {
|
|
229
|
+
return ErrorDetailSchema.parse({
|
|
230
|
+
domain,
|
|
231
|
+
message,
|
|
232
|
+
dispute_failure: { reason },
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Build an ErrorDetail carrying a typed DomainVerificationFailureReason.
|
|
237
|
+
* TS peer of Go `helpers.DomainVerificationFailureDetail` (domain verification failed).
|
|
238
|
+
*/
|
|
239
|
+
export function domainVerificationFailureDetail(domain, message, reason) {
|
|
240
|
+
return ErrorDetailSchema.parse({
|
|
241
|
+
domain,
|
|
242
|
+
message,
|
|
243
|
+
domain_verification_failure: { reason },
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Build an ErrorDetail carrying a typed UsageReportRejectionReason.
|
|
248
|
+
* TS peer of Go `helpers.UsageReportRejectionDetail` (ReportUsage filing rejected).
|
|
249
|
+
*/
|
|
250
|
+
export function usageReportRejectionDetail(domain, message, reason) {
|
|
251
|
+
return ErrorDetailSchema.parse({
|
|
252
|
+
domain,
|
|
253
|
+
message,
|
|
254
|
+
usage_report_rejection: { reason },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// HashURL — TS port of the sdk/go oracle (helpers/signedurl.go HashURL). It
|
|
2
|
+
// returns the SHA-256 digest of a signed URL (the transaction_log.signed_url_hash
|
|
3
|
+
// value, 32 bytes). The hash is over the URL string bytes VERBATIM (opaque
|
|
4
|
+
// bytes, no WHATWG renormalization — consistent with the opaque-URL signing
|
|
5
|
+
// contract). The TS face is async (crypto.subtle.digest); the shared vector
|
|
6
|
+
// encodes the digest as hex.
|
|
7
|
+
import { utf8Bytes } from "./base64url.js";
|
|
8
|
+
/**
|
|
9
|
+
* hashUrl returns the raw 32-byte SHA-256 digest of the signed URL's verbatim
|
|
10
|
+
* UTF-8 bytes.
|
|
11
|
+
*/
|
|
12
|
+
export async function hashUrl(signed) {
|
|
13
|
+
const digest = await crypto.subtle.digest("SHA-256", utf8Bytes(signed));
|
|
14
|
+
return new Uint8Array(digest);
|
|
15
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** A reference that cannot be read as a host at all. The Go oracle exposes an
|
|
2
|
+
* errors.Is sentinel here; this port throws, as the audience check above does,
|
|
3
|
+
* because a sentinel is not how a caller in this language distinguishes causes.
|
|
4
|
+
*
|
|
5
|
+
* The reference is redacted before it is named. Every refusal the parse raises passes
|
|
6
|
+
* through here, including the ones a credential-bearing value reaches — a control
|
|
7
|
+
* character, a backslash in the userinfo, a malformed escape — so this is the one
|
|
8
|
+
* place that has to do it. */
|
|
9
|
+
export declare function invalidHost(ref: string, why: string): Error;
|
|
10
|
+
/** Replace any userinfo in a reference with a marker, so a message built from it
|
|
11
|
+
* cannot carry a credential.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately conservative, and deliberately NOT the rule: it runs on strings the
|
|
14
|
+
* parse has already refused, so it cannot assume they are well formed, and
|
|
15
|
+
* over-redacting a message costs nothing while under-redacting is the bug. It
|
|
16
|
+
* redacts at the FIRST reading in authorityStarts that finds an "@", rather than
|
|
17
|
+
* deciding on one reading — a credential any reader could see is one that must not
|
|
18
|
+
* reach a log. It looks only for an "@" in what could be the authority, which is why
|
|
19
|
+
* an "@" in a path is left alone.
|
|
20
|
+
*
|
|
21
|
+
* The whole userinfo goes, not just the password. Go's own url.URL.Redacted() keeps
|
|
22
|
+
* the username, which is right for a URL the caller owns — but this value arrives in
|
|
23
|
+
* a third-party manifest, where the username is as much the operator's secret as the
|
|
24
|
+
* password is.
|
|
25
|
+
*
|
|
26
|
+
* The oracle redacts here too, in its own words — the shared corpus records a verdict
|
|
27
|
+
* and never a message, so the three languages agree on the property and not on the
|
|
28
|
+
* wording. Its copy lives in an internal package because three tiers build these
|
|
29
|
+
* messages there and one of them would otherwise be left echoing. */
|
|
30
|
+
export declare function redactUserinfo(ref: string): string;
|
|
31
|
+
/** A `%` that does not begin a valid escape. Exported for the delivery leg, which reads
|
|
32
|
+
* the same rule over a signed URL's path and fragment; a second transcription of it is how
|
|
33
|
+
* two readers of one value end up disagreeing. Not on the package export map, so it stays
|
|
34
|
+
* in-repo. */
|
|
35
|
+
export declare const escapePair: RegExp;
|
|
36
|
+
export interface ParsedRef {
|
|
37
|
+
/** The authority with userinfo removed and the port kept, exactly as written. */
|
|
38
|
+
host: string;
|
|
39
|
+
/** The authority's host alone: no port, IPv6 brackets stripped, case preserved. */
|
|
40
|
+
hostname: string;
|
|
41
|
+
/** The port as written, or "" when none was. Never a default filled in. */
|
|
42
|
+
port: string;
|
|
43
|
+
/** Whether the caller actually WROTE a scheme. Anchoring needs this: a scheme
|
|
44
|
+
* decides which port counts as the default, so a value that named none must not
|
|
45
|
+
* be treated as having named https. */
|
|
46
|
+
hadScheme: boolean;
|
|
47
|
+
/** The scheme written, or "https" for a reference that named none. */
|
|
48
|
+
scheme: string;
|
|
49
|
+
/** Whether the authority carried userinfo. The endpoint rule refuses a
|
|
50
|
+
* credential and needs the answer from THIS reading of the reference: decided
|
|
51
|
+
* over a second, differently-shaped parse it disagrees with the anchor check on
|
|
52
|
+
* exactly the shape it exists to stop. */
|
|
53
|
+
hasUserinfo: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** Read a bare domain, a host:port pair, or a full URL into its authority. A ref
|
|
56
|
+
* with no scheme is read as though it carried https, since a bare domain is
|
|
57
|
+
* otherwise indistinguishable from a path. One parse behind both host predicates,
|
|
58
|
+
* so neither can disagree with the other about what a reference even is. */
|
|
59
|
+
export declare function parseRef(ref: string): ParsedRef;
|
|
60
|
+
/** anchoredParsed is the anchor comparison over two references that have ALREADY
|
|
61
|
+
* been read. It exists so a caller holding a parse can reach the verdict without
|
|
62
|
+
* triggering a second one — the endpoint rule needs the userinfo answer and the
|
|
63
|
+
* anchor answer from one reading of the same string.
|
|
64
|
+
*
|
|
65
|
+
* A side that named no scheme borrows the other's, which decides only WHICH port
|
|
66
|
+
* counts as the default. Hostname and port are compared as two values rather than
|
|
67
|
+
* one joined string: joined, the label boundary would have to find ".a.com" at the
|
|
68
|
+
* end of "sub.a.com:8443" and would refuse a subdomain for having a port — the
|
|
69
|
+
* right answer reached through the wrong comparison is still the wrong comparison. */
|
|
70
|
+
export declare function anchoredParsed(a: ParsedRef, c: ParsedRef): boolean;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// The one reading of a host reference, shared by the routing predicates in
|
|
2
|
+
// hosts.ts and by the endpoint rule in the resolvers tier.
|
|
3
|
+
//
|
|
4
|
+
// It is a module of its own, and deliberately absent from both package export maps
|
|
5
|
+
// (the same shape src/opaque-url.ts already has), for the reason the Go oracle's
|
|
6
|
+
// internal endpointrule package gives: the rule is checked in two places for two
|
|
7
|
+
// different reasons, and written twice it drifts. Stated once here, a refusal
|
|
8
|
+
// decided over one reading and enforced over another cannot happen — which is
|
|
9
|
+
// exactly the defect that shipped when the two halves used different parsers.
|
|
10
|
+
//
|
|
11
|
+
// Nothing here is public API. hosts.ts re-exports the predicates built on it;
|
|
12
|
+
// this module's names are internal to the SDK.
|
|
13
|
+
/** A reference that cannot be read as a host at all. The Go oracle exposes an
|
|
14
|
+
* errors.Is sentinel here; this port throws, as the audience check above does,
|
|
15
|
+
* because a sentinel is not how a caller in this language distinguishes causes.
|
|
16
|
+
*
|
|
17
|
+
* The reference is redacted before it is named. Every refusal the parse raises passes
|
|
18
|
+
* through here, including the ones a credential-bearing value reaches — a control
|
|
19
|
+
* character, a backslash in the userinfo, a malformed escape — so this is the one
|
|
20
|
+
* place that has to do it. */
|
|
21
|
+
export function invalidHost(ref, why) {
|
|
22
|
+
return new Error(`hosts: reference is not a usable host: ${why}: ${JSON.stringify(redactUserinfo(ref))}`);
|
|
23
|
+
}
|
|
24
|
+
/** Split what follows an authority's start into the authority and everything after.
|
|
25
|
+
*
|
|
26
|
+
* The authority ends at the first delimiter that starts a path, query or fragment —
|
|
27
|
+
* the three things a reference can carry beyond it.
|
|
28
|
+
*
|
|
29
|
+
* One function because the parse and the redaction have to agree on where it ends.
|
|
30
|
+
* Written twice, a change to the delimiter set moves one copy and not the other, and
|
|
31
|
+
* the two then read different authorities — a split reading inside the module that
|
|
32
|
+
* exists to prevent split readings. */
|
|
33
|
+
function splitAuthority(rest) {
|
|
34
|
+
const end = rest.search(/[/?#]/);
|
|
35
|
+
return end < 0 ? [rest, ""] : [rest.slice(0, end), rest.slice(end)];
|
|
36
|
+
}
|
|
37
|
+
/** Every index at which an authority could plausibly begin, most authoritative
|
|
38
|
+
* first, de-duplicated.
|
|
39
|
+
*
|
|
40
|
+
* A reference the parse has REFUSED has no one true reading — that is what being
|
|
41
|
+
* refused means — so the redaction cannot pick a single one and be safe. Each of
|
|
42
|
+
* these is a reading something could take:
|
|
43
|
+
*
|
|
44
|
+
* - past a valid scheme at the front, which is how the parse reads it;
|
|
45
|
+
* - index 2 behind a bare `//`, which opens an authority while naming no scheme and
|
|
46
|
+
* carries no `://` anywhere;
|
|
47
|
+
* - index 0, a schemeless `host` or `user:pw@host`;
|
|
48
|
+
* - past the first `://` in the string, which is not a scheme separator to this
|
|
49
|
+
* parse but is what a laxer reader downstream may take it for.
|
|
50
|
+
*
|
|
51
|
+
* Taking only the first of these traded one leak for another: reading solely by
|
|
52
|
+
* search let a path segment supply the start, so `u:pw@evil.example/x://a.example`
|
|
53
|
+
* came back untouched — and reading solely by the parse's rule then left
|
|
54
|
+
* `1https://u:pw@a.example/` untouched instead, because a scheme may not begin with
|
|
55
|
+
* a digit. */
|
|
56
|
+
function authorityStarts(ref) {
|
|
57
|
+
const scheme = schemeAtFront.exec(ref);
|
|
58
|
+
const starts = [];
|
|
59
|
+
if (scheme !== null) {
|
|
60
|
+
starts.push(scheme[0].length);
|
|
61
|
+
}
|
|
62
|
+
if (ref.startsWith("//")) {
|
|
63
|
+
starts.push(2);
|
|
64
|
+
}
|
|
65
|
+
starts.push(0);
|
|
66
|
+
const sep = ref.indexOf("://");
|
|
67
|
+
if (sep >= 0) {
|
|
68
|
+
starts.push(sep + 3);
|
|
69
|
+
}
|
|
70
|
+
return [...new Set(starts)];
|
|
71
|
+
}
|
|
72
|
+
/** Replace any userinfo in a reference with a marker, so a message built from it
|
|
73
|
+
* cannot carry a credential.
|
|
74
|
+
*
|
|
75
|
+
* Deliberately conservative, and deliberately NOT the rule: it runs on strings the
|
|
76
|
+
* parse has already refused, so it cannot assume they are well formed, and
|
|
77
|
+
* over-redacting a message costs nothing while under-redacting is the bug. It
|
|
78
|
+
* redacts at the FIRST reading in authorityStarts that finds an "@", rather than
|
|
79
|
+
* deciding on one reading — a credential any reader could see is one that must not
|
|
80
|
+
* reach a log. It looks only for an "@" in what could be the authority, which is why
|
|
81
|
+
* an "@" in a path is left alone.
|
|
82
|
+
*
|
|
83
|
+
* The whole userinfo goes, not just the password. Go's own url.URL.Redacted() keeps
|
|
84
|
+
* the username, which is right for a URL the caller owns — but this value arrives in
|
|
85
|
+
* a third-party manifest, where the username is as much the operator's secret as the
|
|
86
|
+
* password is.
|
|
87
|
+
*
|
|
88
|
+
* The oracle redacts here too, in its own words — the shared corpus records a verdict
|
|
89
|
+
* and never a message, so the three languages agree on the property and not on the
|
|
90
|
+
* wording. Its copy lives in an internal package because three tiers build these
|
|
91
|
+
* messages there and one of them would otherwise be left echoing. */
|
|
92
|
+
export function redactUserinfo(ref) {
|
|
93
|
+
for (const start of authorityStarts(ref)) {
|
|
94
|
+
const [authority, beyond] = splitAuthority(ref.slice(start));
|
|
95
|
+
const at = authority.lastIndexOf("@");
|
|
96
|
+
if (at >= 0) {
|
|
97
|
+
return `${ref.slice(0, start)}[redacted]${authority.slice(at)}${beyond}`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return ref;
|
|
101
|
+
}
|
|
102
|
+
/** Refuse brackets that enclose anything but an IPv6 address.
|
|
103
|
+
*
|
|
104
|
+
* Per RFC 3986 only an IPv6 address may be bracketed, and the oracle enforces it:
|
|
105
|
+
* `netip.ParseAddr` must succeed AND the result must not be IPv4, so `[]`,
|
|
106
|
+
* `[not-an-ip]`, `[1:2]` and `[127.0.0.1]` are all refused — the last as an "invalid
|
|
107
|
+
* IP-literal", since a v4 address in brackets is a spelling no parser is obliged to
|
|
108
|
+
* accept.
|
|
109
|
+
*
|
|
110
|
+
* Stripping the brackets without reading what is between them is what the platform
|
|
111
|
+
* parsers this module replaces did NOT do: both of them validate the literal, so the
|
|
112
|
+
* hand-rolled split had to take that job over with them. Left out, a value the oracle
|
|
113
|
+
* refuses at the caller boundary is accepted here and only surfaces later as a
|
|
114
|
+
* transport failure.
|
|
115
|
+
*
|
|
116
|
+
* `new URL` is the validator, and this is NOT the reading the module header refuses.
|
|
117
|
+
* The header refuses platform parsers because they NORMALIZE what they return —
|
|
118
|
+
* `[::ffff:127.0.0.1]` comes back as `[::ffff:7f00:1]`, a different string for the
|
|
119
|
+
* same address — which is precisely why nothing is read back out of it here. Only
|
|
120
|
+
* whether it threw is used, and the shared vectors hold that verdict to the oracle's.
|
|
121
|
+
* An IPv6 grammar written out by hand is the alternative, and the shapes that catch
|
|
122
|
+
* one out — `::ffff:0:127.0.0.1`, `64:ff9b::192.0.2.1`, `::1.2.3.4` — are exactly
|
|
123
|
+
* where a port would drift. */
|
|
124
|
+
function vetIpLiteral(ref, literal) {
|
|
125
|
+
try {
|
|
126
|
+
new URL(`https://[${literal}]`);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw invalidHost(ref, "brackets enclose no IP literal");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// An authority admits a CLOSED set of ASCII characters; everything outside it is
|
|
133
|
+
// refused. Stating the set rather than a list of separators is what makes the
|
|
134
|
+
// refusal structural: a separator nobody thought of is already outside it. Code
|
|
135
|
+
// points at or above 0x80 are admitted — the oracle's parser keeps them, so a name
|
|
136
|
+
// in a non-ASCII script is a usable host even though the wire's domain rule
|
|
137
|
+
// refuses it.
|
|
138
|
+
const hostAscii = new Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~!$&'()*+,;=:[]<>\"");
|
|
139
|
+
// Userinfo admits a DIFFERENT closed set from the host: no `"`, `<`, `>` or `]`.
|
|
140
|
+
// Reusing the host set here would close the backslash hole and still under-refuse
|
|
141
|
+
// four characters the oracle rejects — and the gap is not cosmetic. WHATWG treats
|
|
142
|
+
// `\` as a fourth authority delimiter in special schemes, so a backslash smuggled
|
|
143
|
+
// into userinfo ends the authority early and the fetch reaches an entirely
|
|
144
|
+
// different host from the one the anchor check just approved.
|
|
145
|
+
const userinfoAscii = new Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~!$&'()*+,;=:");
|
|
146
|
+
// A scheme, and only at the front. The oracle reads `://` as a separator solely
|
|
147
|
+
// when a valid scheme precedes it at position 0; anywhere else the text is a path.
|
|
148
|
+
// Locating the separator by search instead let a path segment supply the host —
|
|
149
|
+
// `evil.example/x://a.example` answered `a.example`.
|
|
150
|
+
const schemeAtFront = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
|
|
151
|
+
/** A `%` that does not begin a valid escape. Exported for the delivery leg, which reads
|
|
152
|
+
* the same rule over a signed URL's path and fragment; a second transcription of it is how
|
|
153
|
+
* two readers of one value end up disagreeing. Not on the package export map, so it stays
|
|
154
|
+
* in-repo. */
|
|
155
|
+
export const escapePair = /%(?![0-9A-Fa-f]{2})/;
|
|
156
|
+
// A control character is refused outright rather than removed. This is the reason
|
|
157
|
+
// the platform parser cannot be used here: `new URL` strips tabs, newlines and
|
|
158
|
+
// leading control characters per WHATWG, so it answers "host" where the oracle
|
|
159
|
+
// answers "not a host". The port is read textually below for the same kind of
|
|
160
|
+
// reason — `new URL` folds a scheme's default port away at PARSE time, which is
|
|
161
|
+
// earlier than this rule decides which scheme is even in play.
|
|
162
|
+
const controlChar = /[\u0000-\u001F\u007F]/;
|
|
163
|
+
const digitsOnly = /^[0-9]*$/;
|
|
164
|
+
/** Read a bare domain, a host:port pair, or a full URL into its authority. A ref
|
|
165
|
+
* with no scheme is read as though it carried https, since a bare domain is
|
|
166
|
+
* otherwise indistinguishable from a path. One parse behind both host predicates,
|
|
167
|
+
* so neither can disagree with the other about what a reference even is. */
|
|
168
|
+
export function parseRef(ref) {
|
|
169
|
+
if (ref.trim() === "") {
|
|
170
|
+
throw invalidHost(ref, "empty reference");
|
|
171
|
+
}
|
|
172
|
+
if (controlChar.test(ref)) {
|
|
173
|
+
throw invalidHost(ref, "control character");
|
|
174
|
+
}
|
|
175
|
+
// `://` is a separator only behind a valid scheme at the front. A reference that
|
|
176
|
+
// carries the sequence anywhere else is not schemeless — it is malformed, which
|
|
177
|
+
// is what the oracle answers, so treating it as schemeless and prepending https
|
|
178
|
+
// would trade one wrong answer for another.
|
|
179
|
+
const hadScheme = ref.includes("://");
|
|
180
|
+
if (hadScheme && !schemeAtFront.test(ref)) {
|
|
181
|
+
throw invalidHost(ref, 'no valid scheme before "://"');
|
|
182
|
+
}
|
|
183
|
+
const work = hadScheme ? ref : `https://${ref}`;
|
|
184
|
+
const sep = work.indexOf("://");
|
|
185
|
+
const scheme = hadScheme ? work.slice(0, sep).toLowerCase() : "https";
|
|
186
|
+
const [authority, beyond] = splitAuthority(work.slice(sep + 3));
|
|
187
|
+
// Escapes are read PER COMPONENT, because the oracle reads them per component.
|
|
188
|
+
// A malformed escape is refused in a path and in a fragment, and admitted in a
|
|
189
|
+
// query, which is not unescaped at parse time. Checking the whole reference
|
|
190
|
+
// instead refused `?q=a%20b` — an ordinary, fully conformant endpoint.
|
|
191
|
+
//
|
|
192
|
+
// The fragment is cut FIRST, and that ordering is the rule rather than a detail:
|
|
193
|
+
// everything after the first "#" is fragment, so a "?" inside one does not start
|
|
194
|
+
// a query. Reading the query first leaves `/#a?b=%zz` looking like a query and
|
|
195
|
+
// admits a malformed escape the oracle refuses.
|
|
196
|
+
const hash = beyond.indexOf("#");
|
|
197
|
+
const fragment = hash < 0 ? "" : beyond.slice(hash + 1);
|
|
198
|
+
const beforeFragment = hash < 0 ? beyond : beyond.slice(0, hash);
|
|
199
|
+
const query = beforeFragment.indexOf("?");
|
|
200
|
+
const path = query < 0 ? beforeFragment : beforeFragment.slice(0, query);
|
|
201
|
+
if (escapePair.test(path)) {
|
|
202
|
+
throw invalidHost(ref, "malformed percent-escape in path");
|
|
203
|
+
}
|
|
204
|
+
if (escapePair.test(fragment)) {
|
|
205
|
+
throw invalidHost(ref, "malformed percent-escape in fragment");
|
|
206
|
+
}
|
|
207
|
+
// Userinfo is split at the LAST "@", so an "@" inside a credential does not
|
|
208
|
+
// become part of the host.
|
|
209
|
+
const at = authority.lastIndexOf("@");
|
|
210
|
+
const hasUserinfo = at >= 0;
|
|
211
|
+
const userinfo = hasUserinfo ? authority.slice(0, at) : "";
|
|
212
|
+
const host = hasUserinfo ? authority.slice(at + 1) : authority;
|
|
213
|
+
if (host === "") {
|
|
214
|
+
throw invalidHost(ref, "no host");
|
|
215
|
+
}
|
|
216
|
+
for (const ch of userinfo) {
|
|
217
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
218
|
+
// "%" and "@" are excluded from the set check on purpose: escapes are read
|
|
219
|
+
// below, and an "@" before the last one is part of the credential, not a
|
|
220
|
+
// second separator.
|
|
221
|
+
if (cp < 0x80 && ch !== "%" && ch !== "@" && !userinfoAscii.has(ch)) {
|
|
222
|
+
throw invalidHost(ref, `invalid character ${JSON.stringify(ch)} in userinfo`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (escapePair.test(userinfo)) {
|
|
226
|
+
throw invalidHost(ref, "malformed percent-escape in userinfo");
|
|
227
|
+
}
|
|
228
|
+
for (const ch of host) {
|
|
229
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
230
|
+
// A percent-escape is refused in the HOST, and only there. The oracle admits
|
|
231
|
+
// just the escapes decoding to a byte at or above 0x80, plus %25 — none of
|
|
232
|
+
// which a domain name carries — so refusing the lot costs nothing a caller
|
|
233
|
+
// can use and removes an unescaping step the three languages would each get
|
|
234
|
+
// subtly wrong. Every value the two answers differ on refuses downstream
|
|
235
|
+
// anyway: a host holding a raw high byte anchors to no bare domain.
|
|
236
|
+
if (ch === "%") {
|
|
237
|
+
throw invalidHost(ref, "percent-escape in the host component");
|
|
238
|
+
}
|
|
239
|
+
if (cp < 0x80 && !hostAscii.has(ch)) {
|
|
240
|
+
throw invalidHost(ref, `invalid character ${JSON.stringify(ch)} in host`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
let hostname;
|
|
244
|
+
let port;
|
|
245
|
+
if (host.startsWith("[")) {
|
|
246
|
+
const close = host.indexOf("]");
|
|
247
|
+
if (close < 0) {
|
|
248
|
+
throw invalidHost(ref, "missing ']' in host");
|
|
249
|
+
}
|
|
250
|
+
hostname = host.slice(1, close);
|
|
251
|
+
vetIpLiteral(ref, hostname);
|
|
252
|
+
const after = host.slice(close + 1);
|
|
253
|
+
if (after === "") {
|
|
254
|
+
port = "";
|
|
255
|
+
}
|
|
256
|
+
else if (after.startsWith(":")) {
|
|
257
|
+
port = after.slice(1);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
throw invalidHost(ref, "trailing characters after ']' in host");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
if (host.includes("[")) {
|
|
265
|
+
throw invalidHost(ref, "missing ']' in host");
|
|
266
|
+
}
|
|
267
|
+
// From the FIRST colon onward, so a value that merely ENDS in digits does
|
|
268
|
+
// not pass as a port: "a.example::443" and "a.example:44:3" are refused.
|
|
269
|
+
const colon = host.indexOf(":");
|
|
270
|
+
hostname = colon < 0 ? host : host.slice(0, colon);
|
|
271
|
+
port = colon < 0 ? "" : host.slice(colon + 1);
|
|
272
|
+
}
|
|
273
|
+
if (!digitsOnly.test(port)) {
|
|
274
|
+
throw invalidHost(ref, `invalid port ${JSON.stringify(port)} after host`);
|
|
275
|
+
}
|
|
276
|
+
return { host, hostname, port, hadScheme, scheme, hasUserinfo };
|
|
277
|
+
}
|
|
278
|
+
// The port a scheme reaches when none is written.
|
|
279
|
+
const defaultPorts = { http: "80", https: "443" };
|
|
280
|
+
/** canonicalPort renders "the same port" as one string, so a port written out in
|
|
281
|
+
* full and the same port left implicit compare equal. An unknown scheme has no
|
|
282
|
+
* default to fold, so its port is kept verbatim. */
|
|
283
|
+
function canonicalPort(scheme, port) {
|
|
284
|
+
if (port === "") {
|
|
285
|
+
return "";
|
|
286
|
+
}
|
|
287
|
+
const def = defaultPorts[scheme.toLowerCase()];
|
|
288
|
+
return def !== undefined && port === def ? "" : port;
|
|
289
|
+
}
|
|
290
|
+
/** sameOrSubdomain reports whether candidate equals anchor or is a subdomain of
|
|
291
|
+
* it. Comparison is case-insensitive and tolerant of ONE trailing root dot — not
|
|
292
|
+
* of every trailing dot, which would make a doubled root dot compare equal to a
|
|
293
|
+
* name that never carried one. A subdomain match requires a full dot-delimited
|
|
294
|
+
* label boundary, so "evil-a.com" is NOT treated as a subdomain of "a.com" — the
|
|
295
|
+
* check a bare suffix match gets wrong, and the one an attacker registers a domain
|
|
296
|
+
* to exploit. */
|
|
297
|
+
function sameOrSubdomain(anchor, candidate) {
|
|
298
|
+
const a = anchor.toLowerCase().replace(/\.$/, "");
|
|
299
|
+
const c = candidate.toLowerCase().replace(/\.$/, "");
|
|
300
|
+
if (a === "") {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
return c === a || c.endsWith(`.${a}`);
|
|
304
|
+
}
|
|
305
|
+
/** anchoredParsed is the anchor comparison over two references that have ALREADY
|
|
306
|
+
* been read. It exists so a caller holding a parse can reach the verdict without
|
|
307
|
+
* triggering a second one — the endpoint rule needs the userinfo answer and the
|
|
308
|
+
* anchor answer from one reading of the same string.
|
|
309
|
+
*
|
|
310
|
+
* A side that named no scheme borrows the other's, which decides only WHICH port
|
|
311
|
+
* counts as the default. Hostname and port are compared as two values rather than
|
|
312
|
+
* one joined string: joined, the label boundary would have to find ".a.com" at the
|
|
313
|
+
* end of "sub.a.com:8443" and would refuse a subdomain for having a port — the
|
|
314
|
+
* right answer reached through the wrong comparison is still the wrong comparison. */
|
|
315
|
+
export function anchoredParsed(a, c) {
|
|
316
|
+
const anchorScheme = a.hadScheme ? a.scheme : c.scheme;
|
|
317
|
+
const candidateScheme = c.hadScheme ? c.scheme : a.scheme;
|
|
318
|
+
return (sameOrSubdomain(a.hostname, c.hostname) &&
|
|
319
|
+
canonicalPort(anchorScheme, a.port) === canonicalPort(candidateScheme, c.port));
|
|
320
|
+
}
|