@k2b/cloud 0.35.0 → 0.36.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/package.json +2 -2
- package/src/access/GroupCoverage.tsx +2 -1
- package/src/access/PermissionEditor.tsx +57 -28
- package/src/access/PrincipalPicker.tsx +2 -0
- package/src/access/messages.ts +2 -0
- package/src/browser/reload.ts +32 -1
- package/src/services/index.ts +8 -0
- package/src/services/network-security.ts +20 -4
- package/src/shared/network-address.ts +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
"@tailwindcss/typography": "0.5.20",
|
|
108
108
|
"@k2b/nessi": "0.18.0",
|
|
109
109
|
"@k2b/ssr": "0.16.0",
|
|
110
|
-
"@k2b/ui": "0.
|
|
110
|
+
"@k2b/ui": "0.26.0",
|
|
111
111
|
"@k2b/stdlib": "0.29.0",
|
|
112
112
|
"@k2b/sync": "7.0.0",
|
|
113
113
|
"@nats-io/jetstream": "3.4.0",
|
|
@@ -68,12 +68,13 @@ const firstPage = (coverage: GroupCoverage) => coverage.members.pages()[0];
|
|
|
68
68
|
* The members toggle beside the group name. Its label never changes, so
|
|
69
69
|
* loading the members moves nothing in the row; the count leads the list.
|
|
70
70
|
*/
|
|
71
|
-
export function GroupCoverageToggle(props: { coverage: GroupCoverage; groupName: string }) {
|
|
71
|
+
export function GroupCoverageToggle(props: { coverage: GroupCoverage; groupName: string; disabled?: boolean }) {
|
|
72
72
|
const locale = useLocale();
|
|
73
73
|
const t = () => accessMessages.resolve([locale()]).t;
|
|
74
74
|
return (
|
|
75
75
|
<button
|
|
76
76
|
type="button"
|
|
77
|
+
disabled={props.disabled}
|
|
77
78
|
aria-expanded={props.coverage.open()}
|
|
78
79
|
aria-controls={props.coverage.panelId}
|
|
79
80
|
// The group name tells several group rows apart.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { mutation } from "@k2b/stdlib/solid";
|
|
2
|
-
import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
|
|
3
|
-
import { createMemo, For, Show } from "solid-js";
|
|
4
|
-
import { createStore } from "solid-js/store";
|
|
2
|
+
import { announce, IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
|
|
3
|
+
import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
|
|
4
|
+
import { createStore, reconcile } from "solid-js/store";
|
|
5
5
|
import { CloudAvatar } from "../account/Avatar";
|
|
6
6
|
import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
|
|
7
7
|
import { groupDisplayName } from "../shared/account-display";
|
|
8
8
|
import { createGroupCoverage, type GroupCoverage, GroupCoveragePanel, GroupCoverageToggle } from "./GroupCoverage";
|
|
9
9
|
import { isManagerEntry } from "./managers";
|
|
10
10
|
import { accessMessages } from "./messages";
|
|
11
|
-
import PrincipalPicker from "./PrincipalPicker";
|
|
11
|
+
import PrincipalPicker, { principalKey } from "./PrincipalPicker";
|
|
12
12
|
import { serviceAccountKindDisplay } from "./service-account-kind";
|
|
13
13
|
|
|
14
14
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -180,6 +180,12 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
180
180
|
// A store keeps each row's identity across level changes, so an expanded member list stays open. It holds
|
|
181
181
|
// copies: store writes go into the objects themselves, and the caller's entries are not the editor's to change.
|
|
182
182
|
const [entries, setEntries] = createStore<AccessEntry[]>(props.initialEntries.map((entry) => ({ ...entry })));
|
|
183
|
+
const [pending, setPending] = createSignal<string | null>(null);
|
|
184
|
+
const grantPending = () => pending() !== null;
|
|
185
|
+
let disposed = false;
|
|
186
|
+
onCleanup(() => {
|
|
187
|
+
disposed = true;
|
|
188
|
+
});
|
|
183
189
|
const canEdit = () => props.canEdit !== false;
|
|
184
190
|
const allowPublic = () => props.allowPublic === true;
|
|
185
191
|
const allowAuthenticated = () => props.allowAuthenticated !== false;
|
|
@@ -196,18 +202,38 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
196
202
|
}
|
|
197
203
|
}
|
|
198
204
|
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
205
|
+
const grant = async (principal: Principal, display: { displayName: string; serviceAccountKind?: ServiceAccountKind }) => {
|
|
206
|
+
if (disposed || grantPending() || updateMut.loading() || revokeMut.loading()) return;
|
|
207
|
+
const key = principalKey(principal);
|
|
208
|
+
if (entries.some((entry) => principalKey(entry.principal) === key)) return;
|
|
209
|
+
const first = allowed(principal)[0];
|
|
210
|
+
if (!first) return;
|
|
211
|
+
const id = `pending:${key}`;
|
|
212
|
+
setPending(id);
|
|
213
|
+
setEntries(entries.length, {
|
|
214
|
+
id,
|
|
215
|
+
principal,
|
|
216
|
+
permission: first.level,
|
|
217
|
+
scope: first.scope,
|
|
218
|
+
createdAt: new Date().toISOString(),
|
|
219
|
+
...display,
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
const entry = await props.grantAccess(principal, first.level, display, first.scope);
|
|
223
|
+
if (disposed) return;
|
|
224
|
+
const index = entries.findIndex((entry) => entry.id === id);
|
|
225
|
+
if (index !== -1) setEntries(index, reconcile({ ...entry }));
|
|
226
|
+
announce(t().accessGranted({ name: display.displayName }));
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if (!disposed) setEntries((current) => current.filter((entry) => entry.id !== id));
|
|
229
|
+
// Prompts are global, so a grant that fails after its dialog closed still says so.
|
|
230
|
+
if (!(typeof err === "object" && err !== null && "name" in err && err.name === "AbortError")) {
|
|
231
|
+
prompts.error(err instanceof Error ? err.message : String(err));
|
|
232
|
+
}
|
|
233
|
+
} finally {
|
|
234
|
+
if (!disposed) setPending(null);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
211
237
|
|
|
212
238
|
type LevelChange = { accessId: string; permission: GrantableLevel; scope: string | undefined };
|
|
213
239
|
const updateMut = mutation.create<LevelChange, LevelChange>({
|
|
@@ -239,11 +265,12 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
239
265
|
},
|
|
240
266
|
onError: (err) => prompts.error(err.message),
|
|
241
267
|
});
|
|
242
|
-
const busy = () =>
|
|
268
|
+
const busy = () => grantPending() || updateMut.loading() || revokeMut.loading();
|
|
243
269
|
// The service refuses to remove the last manager; the row says so up front.
|
|
244
270
|
const lastManagerId = createMemo(() => {
|
|
245
271
|
// A grant on part of the resource never manages it.
|
|
246
|
-
const
|
|
272
|
+
const granted = entries.filter((entry) => entry.id !== pending());
|
|
273
|
+
const managers = (props.effectiveEntries?.(granted) ?? granted).filter((entry) => !entry.scope && isManagerEntry(entry));
|
|
247
274
|
return managers.length === 1 ? managers[0]!.id : null;
|
|
248
275
|
});
|
|
249
276
|
|
|
@@ -260,6 +287,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
260
287
|
entry={entry}
|
|
261
288
|
coverage={coverage}
|
|
262
289
|
canEdit={canEdit()}
|
|
290
|
+
pending={entry.id === pending()}
|
|
263
291
|
disabled={busy()}
|
|
264
292
|
lastManager={entry.id === lastManagerId()}
|
|
265
293
|
allowed={allowed(entry.principal)}
|
|
@@ -288,11 +316,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
288
316
|
allowPublic={allowPublic()}
|
|
289
317
|
allowAuthenticated={allowAuthenticated()}
|
|
290
318
|
allowServiceAccounts={props.allowServiceAccounts}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (first && !busy()) grantMut.mutate({ principal, permission: first.level, scope: first.scope, display });
|
|
295
|
-
}}
|
|
319
|
+
readOnly={grantPending()}
|
|
320
|
+
disabled={updateMut.loading() || revokeMut.loading()}
|
|
321
|
+
onSelect={(principal, display) => void grant(principal, display)}
|
|
296
322
|
/>
|
|
297
323
|
</Show>
|
|
298
324
|
</div>
|
|
@@ -307,6 +333,7 @@ function AccessEntryRow(props: {
|
|
|
307
333
|
entry: AccessEntry;
|
|
308
334
|
canEdit: boolean;
|
|
309
335
|
disabled: boolean;
|
|
336
|
+
pending?: boolean;
|
|
310
337
|
/** The only entry that manages the resource: it keeps its level and cannot be removed. */
|
|
311
338
|
lastManager: boolean;
|
|
312
339
|
allowed: ResolvedLevel[];
|
|
@@ -326,14 +353,14 @@ function AccessEntryRow(props: {
|
|
|
326
353
|
const manageLabel = () => resolveEntryDisplay("admin", undefined, props.allowed, t()).label;
|
|
327
354
|
const removeLabel = () => (props.lastManager ? t().lastManager({ level: manageLabel() }) : t().remove({ name: displayName() }));
|
|
328
355
|
const isInteractive = () =>
|
|
329
|
-
props.canEdit && !props.disabled && !props.singlePicker && props.allowed.some((option) => option.key === entryKey());
|
|
356
|
+
props.canEdit && !props.disabled && !props.pending && !props.singlePicker && props.allowed.some((option) => option.key === entryKey());
|
|
330
357
|
|
|
331
358
|
const badgeClass =
|
|
332
359
|
"flex min-h-7 items-center gap-1 rounded-full border border-transparent bg-[var(--ui-surface-muted)] px-2.5 py-1 text-xs text-secondary";
|
|
333
360
|
|
|
334
361
|
const badgeContent = (
|
|
335
362
|
<>
|
|
336
|
-
<i class={`ti ${display().icon}`} />
|
|
363
|
+
<i class={props.pending ? "ti ti-loader-2 animate-spin" : `ti ${display().icon}`} />
|
|
337
364
|
<span>{display().label}</span>
|
|
338
365
|
<Show when={isInteractive()}>
|
|
339
366
|
<i class="ti ti-chevron-down text-[10px]" />
|
|
@@ -343,7 +370,7 @@ function AccessEntryRow(props: {
|
|
|
343
370
|
|
|
344
371
|
return (
|
|
345
372
|
<div>
|
|
346
|
-
<div class="group/access-row flex items-center gap-2 py-1.5">
|
|
373
|
+
<div class="group/access-row flex items-center gap-2 py-1.5" aria-busy={props.pending ? "true" : undefined}>
|
|
347
374
|
<Show
|
|
348
375
|
when={props.entry.principal.type === "user"}
|
|
349
376
|
fallback={
|
|
@@ -381,7 +408,9 @@ function AccessEntryRow(props: {
|
|
|
381
408
|
>
|
|
382
409
|
{(label) => <span class="truncate text-xs text-dimmed">({label()})</span>}
|
|
383
410
|
</Show>
|
|
384
|
-
<Show when={props.coverage}>
|
|
411
|
+
<Show when={props.coverage}>
|
|
412
|
+
{(coverage) => <GroupCoverageToggle coverage={coverage()} groupName={displayName()} disabled={props.pending} />}
|
|
413
|
+
</Show>
|
|
385
414
|
</div>
|
|
386
415
|
|
|
387
416
|
{/* Permission badge — interactive single-value picker when editable,
|
|
@@ -422,7 +451,7 @@ function AccessEntryRow(props: {
|
|
|
422
451
|
variant="ghost"
|
|
423
452
|
size="xs"
|
|
424
453
|
onClick={props.onRevoke}
|
|
425
|
-
disabled={props.disabled || props.lastManager}
|
|
454
|
+
disabled={props.disabled || props.pending || props.lastManager}
|
|
426
455
|
aria-label={t().remove({ name: displayName() })}
|
|
427
456
|
// A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
|
|
428
457
|
aria-description={props.lastManager ? removeLabel() : undefined}
|
|
@@ -33,6 +33,7 @@ export default function PrincipalPicker(props: {
|
|
|
33
33
|
allowAuthenticated?: boolean;
|
|
34
34
|
allowServiceAccounts?: boolean;
|
|
35
35
|
disabled?: boolean;
|
|
36
|
+
readOnly?: boolean;
|
|
36
37
|
/** `serviceAccountKind` is set for service accounts so a new row can show the same kind as the result. */
|
|
37
38
|
onSelect: (principal: Principal, display: { displayName: string; serviceAccountKind?: ServiceAccountKind }) => void;
|
|
38
39
|
}) {
|
|
@@ -100,6 +101,7 @@ export default function PrincipalPicker(props: {
|
|
|
100
101
|
return (
|
|
101
102
|
<Combobox
|
|
102
103
|
disabled={props.disabled}
|
|
104
|
+
readOnly={props.readOnly}
|
|
103
105
|
placeholder={
|
|
104
106
|
props.allowAuthenticated !== false || props.allowPublic
|
|
105
107
|
? props.allowServiceAccounts
|
package/src/access/messages.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const accessMessages = i18n.define({
|
|
|
14
14
|
publicDescription: "Anyone with the link, including people who are not signed in",
|
|
15
15
|
removeAccessConfirm: ({ name }: { name: string }) => `Remove access for ${name}?`,
|
|
16
16
|
removeAccess: "Remove access",
|
|
17
|
+
accessGranted: ({ name }: { name: string }) => `Added ${name}`,
|
|
17
18
|
userBoundServiceAccount: "User-bound service account",
|
|
18
19
|
resourceBoundServiceAccount: "Resource-bound service account",
|
|
19
20
|
standaloneServiceAccount: "Service account",
|
|
@@ -92,6 +93,7 @@ export const accessMessages = i18n.define({
|
|
|
92
93
|
publicDescription: "Alle mit dem Link, auch ohne Anmeldung",
|
|
93
94
|
removeAccessConfirm: ({ name }) => `Zugriff für ${name} entfernen?`,
|
|
94
95
|
removeAccess: "Zugriff entfernen",
|
|
96
|
+
accessGranted: ({ name }) => `${name} hinzugefügt`,
|
|
95
97
|
userBoundServiceAccount: "Benutzergebundenes Dienstkonto",
|
|
96
98
|
resourceBoundServiceAccount: "Ressourcengebundenes Dienstkonto",
|
|
97
99
|
standaloneServiceAccount: "Dienstkonto",
|
package/src/browser/reload.ts
CHANGED
|
@@ -9,11 +9,26 @@ import { type ToastHandle, toast } from "@k2b/ui";
|
|
|
9
9
|
*/
|
|
10
10
|
export const AUTOMATIC_RELOAD_WINDOW_MS = 30_000;
|
|
11
11
|
|
|
12
|
+
let reloadPending = false;
|
|
13
|
+
/** One notice at a time: every deferred reload has the same remedy. */
|
|
14
|
+
let pageChanged: ToastHandle | undefined;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An open modal dialog holds the reload. A modeless window, such as global search moved aside, leaves the page
|
|
18
|
+
* usable and may stay open for long, so it does not: the page would otherwise stay stale without notice.
|
|
19
|
+
*/
|
|
20
|
+
const modalOpen = () => document.querySelector('dialog[open]:not([aria-modal="false"])') !== null;
|
|
21
|
+
|
|
12
22
|
const storageKey = (key: string) => `cloud.reload.${key}`;
|
|
13
23
|
|
|
14
24
|
/**
|
|
15
25
|
* Reloads the page for an automatic reason (a live event, a failed live
|
|
16
26
|
* subscription, a changed view) at most once per `key` and window in this tab.
|
|
27
|
+
* While a modal dialog is open, it does not reload: once the last one closes,
|
|
28
|
+
* it shows a notice with a reload action. The person may have confirmed work
|
|
29
|
+
* in that dialog whose request a reload would cancel, so only they reload.
|
|
30
|
+
* The call returns `true` in both cases; a dialog that shows live data says
|
|
31
|
+
* itself that it is stale.
|
|
17
32
|
*
|
|
18
33
|
* Returns `false` without reloading when the same key already reloaded within
|
|
19
34
|
* the window, or when `sessionStorage` is unavailable and a repeat therefore
|
|
@@ -22,6 +37,7 @@ const storageKey = (key: string) => `cloud.reload.${key}`;
|
|
|
22
37
|
* need this guard.
|
|
23
38
|
*/
|
|
24
39
|
export const reloadOnce = (key: string, options: { windowMs?: number } = {}): boolean => {
|
|
40
|
+
if (reloadPending) return true;
|
|
25
41
|
const windowMs = options.windowMs ?? AUTOMATIC_RELOAD_WINDOW_MS;
|
|
26
42
|
const now = Date.now();
|
|
27
43
|
try {
|
|
@@ -32,7 +48,20 @@ export const reloadOnce = (key: string, options: { windowMs?: number } = {}): bo
|
|
|
32
48
|
} catch {
|
|
33
49
|
return false;
|
|
34
50
|
}
|
|
35
|
-
|
|
51
|
+
if (typeof document === "undefined" || !modalOpen()) {
|
|
52
|
+
window.location.reload();
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
reloadPending = true;
|
|
56
|
+
const observer = new MutationObserver(() => {
|
|
57
|
+
if (modalOpen()) return;
|
|
58
|
+
observer.disconnect();
|
|
59
|
+
reloadPending = false;
|
|
60
|
+
const t = reloadMessages.resolve([document.documentElement.lang || "en"]).t;
|
|
61
|
+
pageChanged?.dismiss();
|
|
62
|
+
pageChanged = toast(t.pageChanged, { duration: 0, action: { label: t.reload, onClick: () => window.location.reload() } });
|
|
63
|
+
});
|
|
64
|
+
observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["open", "aria-modal"] });
|
|
36
65
|
return true;
|
|
37
66
|
};
|
|
38
67
|
|
|
@@ -41,10 +70,12 @@ const reloadMessages = i18n.define({
|
|
|
41
70
|
messages: {
|
|
42
71
|
en: {
|
|
43
72
|
codeUnavailable: "Part of this page could not load. Reload the page and try again.",
|
|
73
|
+
pageChanged: "This page changed. Reload it to see the latest version.",
|
|
44
74
|
reload: "Reload",
|
|
45
75
|
},
|
|
46
76
|
de: {
|
|
47
77
|
codeUnavailable: "Ein Teil dieser Seite konnte nicht geladen werden. Lade die Seite neu und versuche es erneut.",
|
|
78
|
+
pageChanged: "Diese Seite hat sich geändert. Lade sie neu, um den aktuellen Stand zu sehen.",
|
|
48
79
|
reload: "Neu laden",
|
|
49
80
|
},
|
|
50
81
|
},
|
package/src/services/index.ts
CHANGED
|
@@ -218,3 +218,11 @@ export type {
|
|
|
218
218
|
MailPage,
|
|
219
219
|
MailServiceError,
|
|
220
220
|
} from "../contracts/outgoing-mail";
|
|
221
|
+
|
|
222
|
+
export {
|
|
223
|
+
NetworkTargetBlockedError,
|
|
224
|
+
pinnedNetworkLookup,
|
|
225
|
+
resolvePublicNetworkAddresses,
|
|
226
|
+
resolveAllowedNetworkAddresses,
|
|
227
|
+
} from "./network-security";
|
|
228
|
+
export type { NetworkLookup, PublicNetworkAddress } from "./network-security";
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
2
2
|
import type { LookupFunction } from "node:net";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
EMPTY_NETWORK_ALLOWLIST,
|
|
5
|
+
isAllowlistedNetworkAddress,
|
|
6
|
+
isUnsafeNetworkAddress,
|
|
7
|
+
isUnsafeNetworkHostname,
|
|
8
|
+
type NetworkAllowlist,
|
|
9
|
+
normalizeNetworkHostname,
|
|
10
|
+
parseNetworkAddress,
|
|
11
|
+
} from "../shared/network-address";
|
|
4
12
|
|
|
5
13
|
export type PublicNetworkAddress = { address: string; family: 4 | 6 };
|
|
6
14
|
export type NetworkLookup = (
|
|
@@ -15,12 +23,14 @@ export class NetworkTargetBlockedError extends Error {
|
|
|
15
23
|
}
|
|
16
24
|
}
|
|
17
25
|
|
|
18
|
-
export const
|
|
26
|
+
export const resolveAllowedNetworkAddresses = async (
|
|
19
27
|
rawHostname: string,
|
|
28
|
+
allowlist: NetworkAllowlist,
|
|
20
29
|
lookup: NetworkLookup = (hostname) => dnsLookup(hostname, { all: true, verbatim: true }),
|
|
21
30
|
): Promise<PublicNetworkAddress[]> => {
|
|
22
31
|
const hostname = normalizeNetworkHostname(rawHostname);
|
|
23
|
-
|
|
32
|
+
const trustedHost = allowlist.hosts.has(hostname);
|
|
33
|
+
if (!trustedHost && isUnsafeNetworkHostname(rawHostname)) throw new NetworkTargetBlockedError("Network target host is not allowed");
|
|
24
34
|
|
|
25
35
|
const literal = parseNetworkAddress(rawHostname);
|
|
26
36
|
const addresses = literal ? [literal] : await lookup(hostname, { all: true, verbatim: true });
|
|
@@ -33,12 +43,18 @@ export const resolvePublicNetworkAddresses = async (
|
|
|
33
43
|
if (!literal || literal.family !== entry.family) throw new NetworkTargetBlockedError("Network target returned an invalid address");
|
|
34
44
|
return literal;
|
|
35
45
|
});
|
|
36
|
-
if (
|
|
46
|
+
if (
|
|
47
|
+
!trustedHost &&
|
|
48
|
+
normalized.some((entry) => isUnsafeNetworkAddress(entry.address) && !isAllowlistedNetworkAddress(allowlist, entry.address))
|
|
49
|
+
) {
|
|
37
50
|
throw new NetworkTargetBlockedError("Network target resolved to a private or reserved address");
|
|
38
51
|
}
|
|
39
52
|
return normalized;
|
|
40
53
|
};
|
|
41
54
|
|
|
55
|
+
export const resolvePublicNetworkAddresses = (rawHostname: string, lookup?: NetworkLookup): Promise<PublicNetworkAddress[]> =>
|
|
56
|
+
resolveAllowedNetworkAddresses(rawHostname, EMPTY_NETWORK_ALLOWLIST, lookup);
|
|
57
|
+
|
|
42
58
|
/** Socket lookup using only addresses already checked by the public resolver. */
|
|
43
59
|
export const pinnedNetworkLookup =
|
|
44
60
|
(addresses: readonly PublicNetworkAddress[]): LookupFunction =>
|
|
@@ -106,3 +106,74 @@ export const isUnsafeNetworkHostname = (rawHostname: string): boolean => {
|
|
|
106
106
|
((endsInNumber(hostname) || hostname.includes(":")) && !parseNetworkAddress(hostname))
|
|
107
107
|
);
|
|
108
108
|
};
|
|
109
|
+
|
|
110
|
+
/** Operator-owned exact DNS names and canonical IP networks. Never populate from person input. */
|
|
111
|
+
export type NetworkAllowlist = {
|
|
112
|
+
readonly hosts: ReadonlySet<string>;
|
|
113
|
+
readonly networks: ReadonlyArray<Readonly<{ address: string; family: 4 | 6; bits: number }>>;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const EMPTY_NETWORK_ALLOWLIST: NetworkAllowlist = { hosts: new Set(), networks: [] };
|
|
117
|
+
|
|
118
|
+
export const parseNetworkAllowlist = (entries: readonly string[]): NetworkAllowlist => {
|
|
119
|
+
const hosts = new Set<string>();
|
|
120
|
+
const networks: Array<{ address: string; family: 4 | 6; bits: number }> = [];
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const invalid = (reason = "expected an exact host name, IP address or CIDR") =>
|
|
123
|
+
new Error(`Invalid network allowlist entry "${entry}": ${reason}`);
|
|
124
|
+
const parts = entry.split("/");
|
|
125
|
+
const raw = parts[0] ?? "";
|
|
126
|
+
const literal = parseNetworkAddress(raw);
|
|
127
|
+
if (literal) {
|
|
128
|
+
// Allowlist entries use strict IP notation, even though URL destinations may use legacy IPv4.
|
|
129
|
+
if (literal.family === 4 && ipv4ToNumber(raw) === null) throw invalid();
|
|
130
|
+
if (literal.family === 6 && raw.includes(".") && ipv4ToNumber(raw.replace(/\]$/, "").split(":").at(-1) ?? "") === null)
|
|
131
|
+
throw invalid();
|
|
132
|
+
const width = literal.family === 4 ? 32 : 128;
|
|
133
|
+
if (parts.length > 2 || (parts.length === 2 && !/^(0|[1-9]\d*)$/.test(parts[1] ?? ""))) throw invalid();
|
|
134
|
+
const bits = parts.length === 2 ? Number(parts[1]) : width;
|
|
135
|
+
if (bits > width) throw invalid();
|
|
136
|
+
if (bits === 0) throw invalid("a /0 network allows every address; list the internal hosts or ranges instead");
|
|
137
|
+
const value = literal.family === 4 ? BigInt(ipv4ToNumber(literal.address)!) : ipv6ToBigInt(literal.address);
|
|
138
|
+
const shift = BigInt(width - bits);
|
|
139
|
+
const base = (value >> shift) << shift;
|
|
140
|
+
if (base !== value) {
|
|
141
|
+
const address =
|
|
142
|
+
literal.family === 4
|
|
143
|
+
? [24n, 16n, 8n, 0n].map((shift) => Number((base >> shift) & 255n)).join(".")
|
|
144
|
+
: parseNetworkAddress(
|
|
145
|
+
Array.from({ length: 8 }, (_, index) => ((base >> BigInt((7 - index) * 16)) & 65535n).toString(16)).join(":"),
|
|
146
|
+
)!.address;
|
|
147
|
+
throw invalid(`host bits are set; use ${address}/${bits}`);
|
|
148
|
+
}
|
|
149
|
+
networks.push({ ...literal, bits });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const hostname = normalizeNetworkHostname(raw);
|
|
153
|
+
if (
|
|
154
|
+
parts.length !== 1 ||
|
|
155
|
+
endsInNumber(hostname) ||
|
|
156
|
+
raw.includes(":") ||
|
|
157
|
+
hostname.length > 253 ||
|
|
158
|
+
!hostname.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
|
|
159
|
+
)
|
|
160
|
+
throw invalid();
|
|
161
|
+
hosts.add(hostname);
|
|
162
|
+
}
|
|
163
|
+
return { hosts, networks };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const isAllowlistedNetworkAddress = (allowlist: NetworkAllowlist, rawAddress: string): boolean => {
|
|
167
|
+
const literal = parseNetworkAddress(rawAddress);
|
|
168
|
+
if (!literal) return false;
|
|
169
|
+
if (literal.family === 6) {
|
|
170
|
+
const value = ipv6ToBigInt(literal.address);
|
|
171
|
+
// Only IPv4-mapped IPv6 reaches the embedded IPv4 host directly.
|
|
172
|
+
if (value >> 32n === 0xffffn) {
|
|
173
|
+
const ipv4 = [24n, 16n, 8n, 0n].map((shift) => Number((value >> shift) & 255n)).join(".");
|
|
174
|
+
if (isAllowlistedNetworkAddress(allowlist, ipv4)) return true;
|
|
175
|
+
}
|
|
176
|
+
return allowlist.networks.some((network) => network.family === 6 && ipv6InRange(value, ipv6ToBigInt(network.address), network.bits));
|
|
177
|
+
}
|
|
178
|
+
return allowlist.networks.some((network) => network.family === 4 && ipv4InRange(literal.address, network.address, network.bits));
|
|
179
|
+
};
|