@flow-like/widget-bundler 0.1.1
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 +388 -0
- package/package.json +40 -0
- package/src/add.ts +109 -0
- package/src/bundle-format.ts +104 -0
- package/src/cli.ts +156 -0
- package/src/contract-types.ts +759 -0
- package/src/csp-reason.ts +307 -0
- package/src/csp-source.ts +361 -0
- package/src/csp.ts +54 -0
- package/src/dev/contract-endpoint.ts +103 -0
- package/src/dev/form-model.ts +63 -0
- package/src/dev/harness-html.ts +864 -0
- package/src/dev/server.ts +297 -0
- package/src/extract.ts +1620 -0
- package/src/generated/widget-source-data.ts +982 -0
- package/src/html.ts +60 -0
- package/src/index.ts +17 -0
- package/src/inline.ts +247 -0
- package/src/pack.ts +502 -0
- package/src/psl.ts +145 -0
- package/src/validate-cmd.ts +268 -0
- package/src/vite.ts +109 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Mirror of `validate_widget_csp_reason` / `fold_widget_csp_reason` in
|
|
2
|
+
// packages/wasm/schema/src/widget_policy.rs and of reason rule 8 in
|
|
3
|
+
// packages/wasm/schema/src/widget_sources/text.rs. Driven by the reason
|
|
4
|
+
// sections of packages/wasm/schema/tests/fixtures/widget_csp.json and
|
|
5
|
+
// widget_source_classification.json.
|
|
6
|
+
|
|
7
|
+
import { isIcannTld } from "./psl";
|
|
8
|
+
|
|
9
|
+
export type WidgetCspReasonRejection =
|
|
10
|
+
| "reason-empty"
|
|
11
|
+
| "reason-not-nfc"
|
|
12
|
+
| "reason-forbidden-character"
|
|
13
|
+
| "reason-whitespace"
|
|
14
|
+
| "reason-length"
|
|
15
|
+
| "reason-too-few-letters"
|
|
16
|
+
| "reason-mixed-script"
|
|
17
|
+
| "reason-contains-address"
|
|
18
|
+
| "reason-mentions-product"
|
|
19
|
+
| "reason-claims-assurance"
|
|
20
|
+
| "reason-claims-attribution"
|
|
21
|
+
| "reason-duplicate";
|
|
22
|
+
|
|
23
|
+
export const CSP_REASON_REJECTION_MESSAGES: Readonly<
|
|
24
|
+
Record<WidgetCspReasonRejection, string>
|
|
25
|
+
> = {
|
|
26
|
+
"reason-empty": "reason is empty",
|
|
27
|
+
"reason-not-nfc": "reason must be NFC-normalized",
|
|
28
|
+
"reason-forbidden-character":
|
|
29
|
+
"reason may only contain letters, marks (at most 2 in a row), numbers, spaces and , . : ; ( ) - ' / &",
|
|
30
|
+
"reason-whitespace":
|
|
31
|
+
"reason must not start or end with a space or contain two spaces in a row",
|
|
32
|
+
"reason-length": "reason must be 8 to 120 characters long",
|
|
33
|
+
"reason-too-few-letters": "reason must contain at least 3 letters",
|
|
34
|
+
"reason-mixed-script":
|
|
35
|
+
"reason mixes ASCII letters with letters of another script in one word",
|
|
36
|
+
"reason-contains-address":
|
|
37
|
+
"reason must not contain web addresses, email addresses or domain names",
|
|
38
|
+
"reason-mentions-product": "reason must not mention Flow-Like",
|
|
39
|
+
"reason-claims-assurance":
|
|
40
|
+
"reason must not claim that sources are verified, trusted, approved, official, certified, secure or safe",
|
|
41
|
+
"reason-claims-attribution":
|
|
42
|
+
"reason of a purpose with inputs must not claim who provides the addresses",
|
|
43
|
+
"reason-duplicate": "reasons of two purposes must differ",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** `<message> (<code>)`, the form Rust uses inside contract errors. */
|
|
47
|
+
export function cspReasonProblem(rejection: WidgetCspReasonRejection): string {
|
|
48
|
+
return `${CSP_REASON_REJECTION_MESSAGES[rejection]} (${rejection})`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MIN_CHARS = 8;
|
|
52
|
+
const MAX_CHARS = 120;
|
|
53
|
+
const MAX_UTF16_UNITS = MAX_CHARS * 2;
|
|
54
|
+
const MIN_LETTERS = 3;
|
|
55
|
+
const MAX_MARK_RUN = 2;
|
|
56
|
+
const PUNCTUATION = new Set([",", ".", ":", ";", "(", ")", "-", "'", "/", "&"]);
|
|
57
|
+
const PRODUCT = "flowlike";
|
|
58
|
+
const ASSURANCE_WORDS = new Set([
|
|
59
|
+
"verified",
|
|
60
|
+
"trusted",
|
|
61
|
+
"approved",
|
|
62
|
+
"official",
|
|
63
|
+
"certified",
|
|
64
|
+
"secure",
|
|
65
|
+
"securely",
|
|
66
|
+
"safe",
|
|
67
|
+
"safely",
|
|
68
|
+
]);
|
|
69
|
+
const ATTRIBUTION_WORDS = new Set([
|
|
70
|
+
"app",
|
|
71
|
+
"apps",
|
|
72
|
+
"application",
|
|
73
|
+
"admin",
|
|
74
|
+
"admins",
|
|
75
|
+
"administrator",
|
|
76
|
+
"administrators",
|
|
77
|
+
"organization",
|
|
78
|
+
"organisation",
|
|
79
|
+
"company",
|
|
80
|
+
"workspace",
|
|
81
|
+
"project",
|
|
82
|
+
"owner",
|
|
83
|
+
]);
|
|
84
|
+
const IDEOGRAPHIC_FULL_STOP = "\u3002";
|
|
85
|
+
|
|
86
|
+
const ALPHABETIC = /^\p{Alphabetic}$/u;
|
|
87
|
+
const MARK = /^\p{M}$/u;
|
|
88
|
+
const NUMERIC = /^\p{N}$/u;
|
|
89
|
+
const LONE_SURROGATE = /\p{Cs}/u;
|
|
90
|
+
// Default_Ignorable_Code_Point of Unicode 15.1, the ranges of
|
|
91
|
+
// `is_default_ignorable` in widget_policy.rs.
|
|
92
|
+
const DEFAULT_IGNORABLE_RANGES: readonly (readonly [number, number])[] = [
|
|
93
|
+
[0x00ad, 0x00ad],
|
|
94
|
+
[0x034f, 0x034f],
|
|
95
|
+
[0x061c, 0x061c],
|
|
96
|
+
[0x115f, 0x1160],
|
|
97
|
+
[0x17b4, 0x17b5],
|
|
98
|
+
[0x180b, 0x180f],
|
|
99
|
+
[0x200b, 0x200f],
|
|
100
|
+
[0x202a, 0x202e],
|
|
101
|
+
[0x2060, 0x206f],
|
|
102
|
+
[0x3164, 0x3164],
|
|
103
|
+
[0xfe00, 0xfe0f],
|
|
104
|
+
[0xfeff, 0xfeff],
|
|
105
|
+
[0xffa0, 0xffa0],
|
|
106
|
+
[0xfff0, 0xfff8],
|
|
107
|
+
[0x1bca0, 0x1bca3],
|
|
108
|
+
[0x1d173, 0x1d17a],
|
|
109
|
+
[0xe0000, 0xe0fff],
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const isAlphabetic = (c: string) => ALPHABETIC.test(c);
|
|
113
|
+
const isMark = (c: string) => MARK.test(c);
|
|
114
|
+
const isNumeric = (c: string) => NUMERIC.test(c);
|
|
115
|
+
export const isDefaultIgnorable = (c: string) => {
|
|
116
|
+
const code = c.codePointAt(0) ?? 0;
|
|
117
|
+
return DEFAULT_IGNORABLE_RANGES.some(
|
|
118
|
+
([first, last]) => code >= first && code <= last,
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
const isAscii = (c: string) => (c.codePointAt(0) ?? 0) < 0x80;
|
|
122
|
+
const isAsciiAlphanumeric = (c: string | undefined) =>
|
|
123
|
+
c !== undefined && /^[A-Za-z0-9]$/.test(c);
|
|
124
|
+
const isLetterOrMark = (c: string) => isAlphabetic(c) || isMark(c);
|
|
125
|
+
const isLatinExtended = (c: string) => {
|
|
126
|
+
const code = c.codePointAt(0) ?? 0;
|
|
127
|
+
return (code >= 0xc0 && code <= 0x24f) || (code >= 0x1e00 && code <= 0x1eff);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
function isJoinable(c: string | undefined): boolean {
|
|
131
|
+
return c !== undefined && !isAscii(c) && isLetterOrMark(c);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function charactersAllowed(chars: readonly string[]): boolean {
|
|
135
|
+
let markRun = 0;
|
|
136
|
+
for (const [position, c] of chars.entries()) {
|
|
137
|
+
const joiner =
|
|
138
|
+
(c === "\u200c" || c === "\u200d") &&
|
|
139
|
+
position > 0 &&
|
|
140
|
+
isJoinable(chars[position - 1]) &&
|
|
141
|
+
isJoinable(chars[position + 1]);
|
|
142
|
+
if (joiner) {
|
|
143
|
+
markRun = 0;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (isDefaultIgnorable(c)) return false;
|
|
147
|
+
if (isMark(c)) {
|
|
148
|
+
markRun += 1;
|
|
149
|
+
if (markRun > MAX_MARK_RUN) return false;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
markRun = 0;
|
|
153
|
+
if (!(isAlphabetic(c) || isNumeric(c) || c === " " || PUNCTUATION.has(c))) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Reason rule 3: the character allowlist. Default-ignorable code points are
|
|
162
|
+
* refused, except U+200C/U+200D between non-ASCII letters or marks. TS also
|
|
163
|
+
* rejects lone surrogates.
|
|
164
|
+
*/
|
|
165
|
+
export function reasonCharactersAllowed(reason: string): boolean {
|
|
166
|
+
return !LONE_SURROGATE.test(reason) && charactersAllowed(Array.from(reason));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function splitRuns(
|
|
170
|
+
chars: readonly string[],
|
|
171
|
+
keep: (c: string) => boolean,
|
|
172
|
+
): string[][] {
|
|
173
|
+
const runs: string[][] = [[]];
|
|
174
|
+
for (const c of chars) {
|
|
175
|
+
if (keep(c)) runs[runs.length - 1]?.push(c);
|
|
176
|
+
else runs.push([]);
|
|
177
|
+
}
|
|
178
|
+
return runs;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Reason rule 7: a letter run mixing ASCII and non-Latin letters. */
|
|
182
|
+
export function reasonHasMixedScript(reason: string): boolean {
|
|
183
|
+
return splitRuns(Array.from(reason), isLetterOrMark).some(
|
|
184
|
+
(run) =>
|
|
185
|
+
run.some((c) => isAscii(c) && isAlphabetic(c)) &&
|
|
186
|
+
run.some((c) => isAlphabetic(c) && !isAscii(c) && !isLatinExtended(c)),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function replaceIdeographicStops(chars: readonly string[]): string[] {
|
|
191
|
+
return chars.map((c, position) =>
|
|
192
|
+
c === IDEOGRAPHIC_FULL_STOP &&
|
|
193
|
+
isAsciiAlphanumeric(chars[position - 1]) &&
|
|
194
|
+
isAsciiAlphanumeric(chars[position + 1])
|
|
195
|
+
? "."
|
|
196
|
+
: c,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The form rules 9–12 of spec §14.2.4 compare, as `fold_widget_csp_reason`:
|
|
202
|
+
* `f` (NFKC, U+3002 between ASCII alphanumerics read as `.`, lowercase),
|
|
203
|
+
* decomposed with NFKD and stripped of marks and default-ignorable code
|
|
204
|
+
* points.
|
|
205
|
+
*/
|
|
206
|
+
export function foldWidgetCspReason(reason: string): string {
|
|
207
|
+
const folded = replaceIdeographicStops(Array.from(reason.normalize("NFKC")))
|
|
208
|
+
.join("")
|
|
209
|
+
.toLowerCase()
|
|
210
|
+
.normalize("NFKD");
|
|
211
|
+
return Array.from(folded)
|
|
212
|
+
.filter((c) => !isMark(c) && !isDefaultIgnorable(c))
|
|
213
|
+
.join("");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Whether a reason has more than 120 scalars, without walking a long one. */
|
|
217
|
+
function exceedsMaxLength(reason: string): boolean {
|
|
218
|
+
if (reason.length > MAX_UTF16_UNITS) return true;
|
|
219
|
+
let scalars = 0;
|
|
220
|
+
for (const _ of reason) scalars += 1;
|
|
221
|
+
return scalars > MAX_CHARS;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function reasonWords(folded: string): string[] {
|
|
225
|
+
return splitRuns(
|
|
226
|
+
Array.from(folded),
|
|
227
|
+
(c) => isAlphabetic(c) || isNumeric(c) || isMark(c),
|
|
228
|
+
)
|
|
229
|
+
.filter((run) => run.length > 0)
|
|
230
|
+
.map((run) => run.join(""));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Reason rules 1–7 and 9–11 of §14.2.4, in order; the first failure is the
|
|
235
|
+
* code. A reason over 120 scalars fails with `reason-length` before any other
|
|
236
|
+
* rule looks at it. Rule 8 ({@link reasonContainsAddress}) and rule 12
|
|
237
|
+
* (duplicates across purposes) run separately.
|
|
238
|
+
*/
|
|
239
|
+
export function validateWidgetCspReason(
|
|
240
|
+
reason: string,
|
|
241
|
+
hasInputs: boolean,
|
|
242
|
+
): WidgetCspReasonRejection | null {
|
|
243
|
+
if (reason.length === 0) return "reason-empty";
|
|
244
|
+
if (exceedsMaxLength(reason)) return "reason-length";
|
|
245
|
+
if (reason.normalize("NFC") !== reason) return "reason-not-nfc";
|
|
246
|
+
if (!reasonCharactersAllowed(reason)) return "reason-forbidden-character";
|
|
247
|
+
if (reason.startsWith(" ") || reason.endsWith(" ") || reason.includes(" ")) {
|
|
248
|
+
return "reason-whitespace";
|
|
249
|
+
}
|
|
250
|
+
const chars = Array.from(reason);
|
|
251
|
+
if (chars.length < MIN_CHARS) return "reason-length";
|
|
252
|
+
if (chars.filter(isAlphabetic).length < MIN_LETTERS) {
|
|
253
|
+
return "reason-too-few-letters";
|
|
254
|
+
}
|
|
255
|
+
if (reasonHasMixedScript(reason)) return "reason-mixed-script";
|
|
256
|
+
const skeleton = foldWidgetCspReason(reason);
|
|
257
|
+
const lettersAndNumbers = Array.from(skeleton)
|
|
258
|
+
.filter((c) => isAlphabetic(c) || isNumeric(c))
|
|
259
|
+
.join("");
|
|
260
|
+
if (lettersAndNumbers.includes(PRODUCT)) return "reason-mentions-product";
|
|
261
|
+
const words = reasonWords(skeleton);
|
|
262
|
+
if (words.some((word) => ASSURANCE_WORDS.has(word))) {
|
|
263
|
+
return "reason-claims-assurance";
|
|
264
|
+
}
|
|
265
|
+
if (hasInputs && words.some((word) => ATTRIBUTION_WORDS.has(word))) {
|
|
266
|
+
return "reason-claims-attribution";
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Reason rule 8 `reason-contains-address` as `reason_contains_address`: a
|
|
273
|
+
* URL scheme, `@`, `www.`, or a dotted token whose last label has at least two
|
|
274
|
+
* characters and is non-ASCII or an ICANN TLD. Publish and bundler only.
|
|
275
|
+
*/
|
|
276
|
+
export function reasonContainsAddress(reason: string): boolean {
|
|
277
|
+
const folded = replaceIdeographicStops(
|
|
278
|
+
Array.from(reason.normalize("NFKC").toLowerCase()),
|
|
279
|
+
).join("");
|
|
280
|
+
if (
|
|
281
|
+
folded.includes("://") ||
|
|
282
|
+
folded.includes("@") ||
|
|
283
|
+
folded.includes("www.")
|
|
284
|
+
) {
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
return splitRuns(
|
|
288
|
+
Array.from(folded),
|
|
289
|
+
(c) => isLetterOrMark(c) || isNumeric(c) || c === "-" || c === ".",
|
|
290
|
+
).some((run) => {
|
|
291
|
+
const labels = run
|
|
292
|
+
.join("")
|
|
293
|
+
.replace(/^\.+|\.+$/g, "")
|
|
294
|
+
.split(".");
|
|
295
|
+
const last = labels[labels.length - 1] ?? "";
|
|
296
|
+
return (
|
|
297
|
+
labels.length >= 2 &&
|
|
298
|
+
Array.from(last).length >= 2 &&
|
|
299
|
+
(!Array.from(last).every(isAscii) || isIcannTld(last))
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Normalization applied to authored reasons: NFC, collapsed whitespace, trimmed. */
|
|
305
|
+
export function normalizeCspReason(reason: string): string {
|
|
306
|
+
return reason.normalize("NFC").replace(/\s+/g, " ").trim();
|
|
307
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// Mirror of the source grammar, limits and input path grammar in
|
|
2
|
+
// packages/wasm/schema/src/widget_policy.rs. Both implementations are driven
|
|
3
|
+
// by packages/wasm/schema/tests/fixtures/widget_csp.json. No local runtime
|
|
4
|
+
// imports: tests load this module under Node as well.
|
|
5
|
+
|
|
6
|
+
import { domainToASCII } from "node:url";
|
|
7
|
+
import type {
|
|
8
|
+
WidgetCsp,
|
|
9
|
+
WidgetCspDirective,
|
|
10
|
+
WidgetCspPurpose,
|
|
11
|
+
} from "@flow-like/widget-sdk";
|
|
12
|
+
|
|
13
|
+
export const MAX_WIDGET_CSP_SOURCES = 16;
|
|
14
|
+
export const MAX_WIDGET_CSP_SOURCE_BYTES = 1536;
|
|
15
|
+
export const MAX_WIDGET_CSP_PURPOSES = 8;
|
|
16
|
+
export const MAX_WIDGET_NETWORK_INPUTS = 8;
|
|
17
|
+
export const MAX_WIDGET_INPUT_PATH_SEGMENTS = 6;
|
|
18
|
+
export const MAX_WIDGET_TEMPLATE_SUBDOMAINS = 16;
|
|
19
|
+
|
|
20
|
+
/** Widget inputs the host injects itself; network inputs never read them. */
|
|
21
|
+
export const HOST_RESERVED_INPUT_KEYS: readonly string[] = [
|
|
22
|
+
"publicMediaGrants",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Contract keys in the order serde serializes them (`CspDirective::ALL`) */
|
|
26
|
+
export const CSP_DIRECTIVES = [
|
|
27
|
+
"connectSrc",
|
|
28
|
+
"imgSrc",
|
|
29
|
+
"fontSrc",
|
|
30
|
+
"mediaSrc",
|
|
31
|
+
"styleSrc",
|
|
32
|
+
] as const satisfies readonly WidgetCspDirective[];
|
|
33
|
+
|
|
34
|
+
export type CspDirective = WidgetCspDirective;
|
|
35
|
+
|
|
36
|
+
export const CSP_PURPOSE_KEYS = [
|
|
37
|
+
"reason",
|
|
38
|
+
...CSP_DIRECTIVES,
|
|
39
|
+
"inputs",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
export const CSP_NETWORK_INPUT_KEYS = [
|
|
43
|
+
"path",
|
|
44
|
+
"directives",
|
|
45
|
+
"template",
|
|
46
|
+
] as const;
|
|
47
|
+
export const CSP_TEMPLATE_KEYS = ["subdomains", "subdomainsInput"] as const;
|
|
48
|
+
|
|
49
|
+
export const CSP_ALLOWED_SCHEMES: Readonly<
|
|
50
|
+
Record<CspDirective, readonly string[]>
|
|
51
|
+
> = {
|
|
52
|
+
connectSrc: ["https", "wss"],
|
|
53
|
+
imgSrc: ["https"],
|
|
54
|
+
fontSrc: ["https"],
|
|
55
|
+
mediaSrc: ["https"],
|
|
56
|
+
styleSrc: ["https"],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type CspSourceRejection =
|
|
60
|
+
| "empty"
|
|
61
|
+
| "non-ascii"
|
|
62
|
+
| "wildcard"
|
|
63
|
+
| "keyword"
|
|
64
|
+
| "forbidden-character"
|
|
65
|
+
| "uppercase"
|
|
66
|
+
| "userinfo"
|
|
67
|
+
| "query-or-fragment"
|
|
68
|
+
| "ip-literal"
|
|
69
|
+
| "missing-scheme"
|
|
70
|
+
| "scheme-not-allowed"
|
|
71
|
+
| "port"
|
|
72
|
+
| "path"
|
|
73
|
+
| "invalid-host"
|
|
74
|
+
| "reserved-name";
|
|
75
|
+
|
|
76
|
+
export const CSP_SOURCE_REJECTION_MESSAGES: Readonly<
|
|
77
|
+
Record<CspSourceRejection, string>
|
|
78
|
+
> = {
|
|
79
|
+
empty: "source is empty",
|
|
80
|
+
"non-ascii": "non-ASCII characters are not allowed (use punycode)",
|
|
81
|
+
wildcard: 'wildcards are only allowed as a leading "*." label of the host',
|
|
82
|
+
keyword: "keywords, nonces and hashes are not allowed",
|
|
83
|
+
"forbidden-character": "contains a forbidden character",
|
|
84
|
+
uppercase: "uppercase characters are not allowed",
|
|
85
|
+
userinfo: "userinfo is not allowed",
|
|
86
|
+
"query-or-fragment": "queries and fragments are not allowed",
|
|
87
|
+
"ip-literal": "IP literals are not allowed",
|
|
88
|
+
"missing-scheme": "must have the form scheme://host",
|
|
89
|
+
"scheme-not-allowed": "scheme is not allowed for this directive",
|
|
90
|
+
port: "ports are not allowed",
|
|
91
|
+
path: "paths are not allowed",
|
|
92
|
+
"invalid-host": "host is not a valid public DNS name",
|
|
93
|
+
"reserved-name": "host uses a reserved or local-only name",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const MAX_HOST_LEN = 253;
|
|
97
|
+
|
|
98
|
+
const RESERVED_NAME_SUFFIXES = [
|
|
99
|
+
"localhost",
|
|
100
|
+
"local",
|
|
101
|
+
"internal",
|
|
102
|
+
"lan",
|
|
103
|
+
"home.arpa",
|
|
104
|
+
"test",
|
|
105
|
+
"example",
|
|
106
|
+
"invalid",
|
|
107
|
+
"onion",
|
|
108
|
+
"nip.io",
|
|
109
|
+
"sslip.io",
|
|
110
|
+
"traefik.me",
|
|
111
|
+
"localtest.me",
|
|
112
|
+
"lvh.me",
|
|
113
|
+
"localhost.direct",
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const SOURCE_CHARSET = /^[a-z0-9.:/*-]+$/;
|
|
117
|
+
const WILDCARD_FORM = /^[a-z]+:\/\/\*\.[^*]+$/;
|
|
118
|
+
const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
119
|
+
const MEMBER_KEY = /^[A-Za-z_][A-Za-z0-9_]*/;
|
|
120
|
+
|
|
121
|
+
export function isCspDirective(key: string): key is CspDirective {
|
|
122
|
+
return (CSP_DIRECTIVES as readonly string[]).includes(key);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function hasNonAscii(value: string): boolean {
|
|
126
|
+
for (let index = 0; index < value.length; index++) {
|
|
127
|
+
if (value.charCodeAt(index) > 0x7f) return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** ASCII whitespace or control characters, `;`, `,` and quotes */
|
|
133
|
+
function hasForbiddenDelimiter(value: string): boolean {
|
|
134
|
+
for (let index = 0; index < value.length; index++) {
|
|
135
|
+
const code = value.charCodeAt(index);
|
|
136
|
+
if (code <= 0x20 || code === 0x7f || ";,\"'".includes(value[index] ?? "")) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Checks one declared source against the grammar
|
|
145
|
+
* `scheme "://" [ "*." ] host` and returns the first failing check, in the
|
|
146
|
+
* same order as `validate_csp_source` in Rust. Public suffix checks on
|
|
147
|
+
* wildcard bases live in `validateWildcardBases`.
|
|
148
|
+
*/
|
|
149
|
+
export function validateCspSource(
|
|
150
|
+
directive: CspDirective,
|
|
151
|
+
source: string,
|
|
152
|
+
): CspSourceRejection | null {
|
|
153
|
+
if (source.length === 0) return "empty";
|
|
154
|
+
if (hasNonAscii(source)) return "non-ascii";
|
|
155
|
+
if (source.includes("*") && !WILDCARD_FORM.test(source)) return "wildcard";
|
|
156
|
+
if (source.startsWith("'")) return "keyword";
|
|
157
|
+
if (hasForbiddenDelimiter(source)) return "forbidden-character";
|
|
158
|
+
if (/[A-Z]/.test(source)) return "uppercase";
|
|
159
|
+
if (source.includes("@")) return "userinfo";
|
|
160
|
+
if (/[?#]/.test(source)) return "query-or-fragment";
|
|
161
|
+
if (/[[\]]/.test(source)) return "ip-literal";
|
|
162
|
+
if (!SOURCE_CHARSET.test(source)) return "forbidden-character";
|
|
163
|
+
const separator = source.indexOf("://");
|
|
164
|
+
if (separator <= 0) return "missing-scheme";
|
|
165
|
+
if (!CSP_ALLOWED_SCHEMES[directive].includes(source.slice(0, separator))) {
|
|
166
|
+
return "scheme-not-allowed";
|
|
167
|
+
}
|
|
168
|
+
const rest = source.slice(separator + 3);
|
|
169
|
+
const hostEnd = rest.search(/[:/]/);
|
|
170
|
+
if (hostEnd !== -1) return rest[hostEnd] === ":" ? "port" : "path";
|
|
171
|
+
return rest.startsWith("*.")
|
|
172
|
+
? validateHost(rest.slice(2), MAX_HOST_LEN - 2)
|
|
173
|
+
: validateHost(rest, MAX_HOST_LEN);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function validateHost(host: string, maxLen: number): CspSourceRejection | null {
|
|
177
|
+
if (host.length === 0 || host.length > maxLen) return "invalid-host";
|
|
178
|
+
const labels = host.split(".");
|
|
179
|
+
if (labels.some((label) => label.length === 0)) return "invalid-host";
|
|
180
|
+
const tld = labels[labels.length - 1] ?? "";
|
|
181
|
+
if (/^[0-9]+$/.test(tld)) return "ip-literal";
|
|
182
|
+
if (!labels.every((label) => DNS_LABEL.test(label))) return "invalid-host";
|
|
183
|
+
if (RESERVED_NAME_SUFFIXES.some((suffix) => hostMatches(host, suffix))) {
|
|
184
|
+
return "reserved-name";
|
|
185
|
+
}
|
|
186
|
+
if (labels.length < 2 || !isTld(tld)) return "invalid-host";
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isTld(label: string): boolean {
|
|
191
|
+
return label.startsWith("xn--")
|
|
192
|
+
? /^[a-z0-9-]{1,59}$/.test(label.slice(4))
|
|
193
|
+
: /^[a-z]{2,63}$/.test(label);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function hostMatches(host: string, reserved: string): boolean {
|
|
197
|
+
return (
|
|
198
|
+
host === reserved ||
|
|
199
|
+
(host.endsWith(reserved) && host[host.length - reserved.length - 1] === ".")
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function isDnsLabel(label: string): boolean {
|
|
204
|
+
return DNS_LABEL.test(label);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const ENCODER = new TextEncoder();
|
|
208
|
+
|
|
209
|
+
/** UTF-8 byte order, which is how Rust orders `String`s. */
|
|
210
|
+
export function compareCspSources(a: string, b: string): number {
|
|
211
|
+
if (a === b) return 0;
|
|
212
|
+
const left = ENCODER.encode(a);
|
|
213
|
+
const right = ENCODER.encode(b);
|
|
214
|
+
const length = Math.min(left.length, right.length);
|
|
215
|
+
for (let index = 0; index < length; index++) {
|
|
216
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
217
|
+
if (difference !== 0) return difference;
|
|
218
|
+
}
|
|
219
|
+
return left.length - right.length;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function isStrictlyAscending(
|
|
223
|
+
items: readonly string[],
|
|
224
|
+
compare: (a: string, b: string) => number = compareCspSources,
|
|
225
|
+
): boolean {
|
|
226
|
+
return items.every(
|
|
227
|
+
(item, index) => index === 0 || compare(items[index - 1] ?? "", item) < 0,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** An ASCII character a DNS host cannot contain; non-ASCII is left to IDNA */
|
|
232
|
+
const FORBIDDEN_HOST_ASCII = /[^a-z0-9.--]/;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* `domainToASCII` runs the runtime's own URL host parser: Node strips tabs,
|
|
236
|
+
* splits on backslashes, percent-decodes and refuses mapped hosts that end in
|
|
237
|
+
* a number or contain forbidden code points, Bun does none of that. Only a
|
|
238
|
+
* host without such ASCII whose result is a well-formed DNS name is taken,
|
|
239
|
+
* so both runtimes agree.
|
|
240
|
+
*/
|
|
241
|
+
function punycodeHost(host: string): string | null {
|
|
242
|
+
if (FORBIDDEN_HOST_ASCII.test(host)) return null;
|
|
243
|
+
const ascii = domainToASCII(host);
|
|
244
|
+
const rejection = validateHost(ascii, MAX_HOST_LEN);
|
|
245
|
+
return rejection === null || rejection === "reserved-name" ? ascii : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Authoring normalization: lowercases the source and converts an
|
|
250
|
+
* internationalized host (or wildcard base) to punycode. Anything the grammar
|
|
251
|
+
* rejects is left for `validateCspSource` to report.
|
|
252
|
+
*/
|
|
253
|
+
export function normalizeCspSource(source: string): string {
|
|
254
|
+
const lowered = source.toLowerCase();
|
|
255
|
+
const separator = lowered.indexOf("://");
|
|
256
|
+
if (separator <= 0) return lowered;
|
|
257
|
+
const prefix = lowered.slice(0, separator + 3);
|
|
258
|
+
const rest = lowered.slice(separator + 3);
|
|
259
|
+
const hostEnd = rest.search(/[:/?#]/);
|
|
260
|
+
const authority = hostEnd === -1 ? rest : rest.slice(0, hostEnd);
|
|
261
|
+
const wildcard = authority.startsWith("*.") ? "*." : "";
|
|
262
|
+
const host = authority.slice(wildcard.length);
|
|
263
|
+
if (!hasNonAscii(host)) return lowered;
|
|
264
|
+
const ascii = punycodeHost(host);
|
|
265
|
+
if (ascii === null) return lowered;
|
|
266
|
+
return `${prefix}${wildcard}${ascii}${hostEnd === -1 ? "" : rest.slice(hostEnd)}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Sorted ascending in UTF-8 byte order without duplicates. */
|
|
270
|
+
export function canonicalCspSources(sources: readonly string[]): string[] {
|
|
271
|
+
return [...new Set(sources)].sort(compareCspSources);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function isCspEmpty(csp: WidgetCsp): boolean {
|
|
275
|
+
return CSP_DIRECTIVES.every(
|
|
276
|
+
(directive) => (csp[directive]?.length ?? 0) === 0,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function cspSourceCount(csp: WidgetCsp): number {
|
|
281
|
+
return CSP_DIRECTIVES.reduce(
|
|
282
|
+
(count, directive) => count + (csp[directive]?.length ?? 0),
|
|
283
|
+
0,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Σ(len + 1) over every entry: the bytes the sources add to a header. */
|
|
288
|
+
export function cspSourceBytes(csp: WidgetCsp): number {
|
|
289
|
+
return CSP_DIRECTIVES.reduce(
|
|
290
|
+
(bytes, directive) =>
|
|
291
|
+
bytes +
|
|
292
|
+
(csp[directive] ?? []).reduce(
|
|
293
|
+
(sum, source) => sum + ENCODER.encode(source).length + 1,
|
|
294
|
+
0,
|
|
295
|
+
),
|
|
296
|
+
0,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Per-directive union of every purpose's sources (`flatten_csp_purposes`). */
|
|
301
|
+
export function flattenCspPurposes(
|
|
302
|
+
purposes: readonly WidgetCspPurpose[],
|
|
303
|
+
): WidgetCsp {
|
|
304
|
+
const csp: WidgetCsp = {};
|
|
305
|
+
for (const directive of CSP_DIRECTIVES) {
|
|
306
|
+
const sources = canonicalCspSources(
|
|
307
|
+
purposes.flatMap((purpose) => purpose[directive] ?? []),
|
|
308
|
+
);
|
|
309
|
+
if (sources.length > 0) csp[directive] = sources;
|
|
310
|
+
}
|
|
311
|
+
return csp;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export type WidgetInputPathSegment =
|
|
315
|
+
| { kind: "key"; key: string }
|
|
316
|
+
| { kind: "items" }
|
|
317
|
+
| { kind: "values" };
|
|
318
|
+
|
|
319
|
+
export interface WidgetInputPath {
|
|
320
|
+
root: string;
|
|
321
|
+
segments: WidgetInputPathSegment[];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Parses `root *( "." key / "[]" / ".*" )` with `key = [A-Za-z_][A-Za-z0-9_]*`. */
|
|
325
|
+
export function parseWidgetInputPath(path: string): WidgetInputPath | null {
|
|
326
|
+
const root = MEMBER_KEY.exec(path)?.[0];
|
|
327
|
+
if (root === undefined) return null;
|
|
328
|
+
const segments: WidgetInputPathSegment[] = [];
|
|
329
|
+
let rest = path.slice(root.length);
|
|
330
|
+
while (rest.length > 0) {
|
|
331
|
+
if (rest.startsWith("[]")) {
|
|
332
|
+
segments.push({ kind: "items" });
|
|
333
|
+
rest = rest.slice(2);
|
|
334
|
+
} else if (rest.startsWith(".*")) {
|
|
335
|
+
segments.push({ kind: "values" });
|
|
336
|
+
rest = rest.slice(2);
|
|
337
|
+
} else {
|
|
338
|
+
const key = rest.startsWith(".")
|
|
339
|
+
? MEMBER_KEY.exec(rest.slice(1))?.[0]
|
|
340
|
+
: undefined;
|
|
341
|
+
if (key === undefined) return null;
|
|
342
|
+
segments.push({ kind: "key", key });
|
|
343
|
+
rest = rest.slice(1 + key.length);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return { root, segments };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function isValidWidgetInputPath(path: string): boolean {
|
|
350
|
+
const parsed = parseWidgetInputPath(path);
|
|
351
|
+
return (
|
|
352
|
+
parsed !== null && parsed.segments.length < MAX_WIDGET_INPUT_PATH_SEGMENTS
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function compareCspDirectives(a: string, b: string): number {
|
|
357
|
+
return (
|
|
358
|
+
CSP_DIRECTIVES.indexOf(a as CspDirective) -
|
|
359
|
+
CSP_DIRECTIVES.indexOf(b as CspDirective)
|
|
360
|
+
);
|
|
361
|
+
}
|