@makolabs/ripple 3.8.1 → 3.9.2

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.
Files changed (41) hide show
  1. package/dist/ai/MessageBox.svelte +2 -1
  2. package/dist/button/Button.svelte +89 -7
  3. package/dist/elements/file-viewer/FilePreviewModal.svelte +102 -0
  4. package/dist/elements/file-viewer/FilePreviewModal.svelte.d.ts +4 -0
  5. package/dist/elements/file-viewer/FileViewer.svelte +96 -0
  6. package/dist/elements/file-viewer/FileViewer.svelte.d.ts +4 -0
  7. package/dist/elements/file-viewer/file-preview-modal-types.d.ts +62 -0
  8. package/dist/elements/file-viewer/file-preview-modal-types.js +1 -0
  9. package/dist/elements/file-viewer/file-viewer-types.d.ts +51 -0
  10. package/dist/elements/file-viewer/file-viewer-types.js +127 -0
  11. package/dist/elements/image-viewer/ImageViewer.svelte +133 -0
  12. package/dist/elements/image-viewer/ImageViewer.svelte.d.ts +4 -0
  13. package/dist/elements/image-viewer/image-viewer-types.d.ts +16 -0
  14. package/dist/elements/image-viewer/image-viewer-types.js +1 -0
  15. package/dist/elements/pdf-viewer/PdfViewer.svelte +225 -34
  16. package/dist/elements/pdf-viewer/pdf-viewer-types.d.ts +13 -4
  17. package/dist/elements/text-viewer/TextViewer.svelte +141 -0
  18. package/dist/elements/text-viewer/TextViewer.svelte.d.ts +4 -0
  19. package/dist/elements/text-viewer/text-viewer-types.d.ts +10 -0
  20. package/dist/elements/text-viewer/text-viewer-types.js +1 -0
  21. package/dist/elements/viewer-shared/UnsupportedPreview.svelte +84 -0
  22. package/dist/elements/viewer-shared/UnsupportedPreview.svelte.d.ts +4 -0
  23. package/dist/elements/viewer-shared/ViewerToolbar.svelte +128 -0
  24. package/dist/elements/viewer-shared/ViewerToolbar.svelte.d.ts +4 -0
  25. package/dist/elements/viewer-shared/unsupported-preview-types.d.ts +17 -0
  26. package/dist/elements/viewer-shared/unsupported-preview-types.js +1 -0
  27. package/dist/elements/viewer-shared/viewer-toolbar-types.d.ts +14 -0
  28. package/dist/elements/viewer-shared/viewer-toolbar-types.js +1 -0
  29. package/dist/helper/is-safe-http-url.d.ts +33 -0
  30. package/dist/helper/is-safe-http-url.js +214 -0
  31. package/dist/helper/pdf-assets.d.ts +42 -0
  32. package/dist/helper/pdf-assets.js +57 -0
  33. package/dist/helper/pdf-safety.d.ts +38 -0
  34. package/dist/helper/pdf-safety.js +85 -0
  35. package/dist/helper/resource-limits.d.ts +13 -0
  36. package/dist/helper/resource-limits.js +16 -0
  37. package/dist/helper/sanitize-html.d.ts +5 -0
  38. package/dist/helper/sanitize-html.js +12 -0
  39. package/dist/index.d.ts +9 -0
  40. package/dist/index.js +8 -0
  41. package/package.json +4 -2
@@ -0,0 +1,141 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+ import { cn } from '../../helper/cls.js';
4
+ import { buildTestId } from '../../helper/testid.js';
5
+ import { isSafeHttpUrl, redactUrl } from '../../helper/is-safe-http-url.js';
6
+ import { MAX_TEXT_BYTES } from '../../helper/resource-limits.js';
7
+ import Spinner from '../spinner/Spinner.svelte';
8
+ import UnsupportedPreview from '../viewer-shared/UnsupportedPreview.svelte';
9
+ import { Color, Size } from '../../variants.js';
10
+ import type { TextViewerProps } from './text-viewer-types.js';
11
+
12
+ let {
13
+ url,
14
+ maxBytes = MAX_TEXT_BYTES,
15
+ loadingLabel = 'Loading file...',
16
+ errorTitle = 'Unable to display file',
17
+ errorActionLabel = 'Open in new tab',
18
+ class: className = '',
19
+ testId
20
+ }: TextViewerProps = $props();
21
+
22
+ // Allow same-origin (incl. dev localhost) OR a public cross-origin host.
23
+ // Block cross-origin private/loopback/metadata hosts (SSRF read-exfil).
24
+ const urlAllowed = $derived(
25
+ isSafeHttpUrl(url, { sameOrigin: true, allowPrivateHosts: true }) ||
26
+ isSafeHttpUrl(url, { allowPrivateHosts: false })
27
+ );
28
+
29
+ let status = $state<'loading' | 'ready' | 'error'>('loading');
30
+ let content = $state('');
31
+ let truncated = $state(false);
32
+ let controller: AbortController | null = null;
33
+
34
+ async function load(active: AbortController) {
35
+ if (!urlAllowed) {
36
+ status = 'error';
37
+ return;
38
+ }
39
+
40
+ try {
41
+ const res = await fetch(url, {
42
+ signal: active.signal,
43
+ redirect: 'error',
44
+ credentials: 'same-origin'
45
+ });
46
+ if (!res.ok || !res.body) throw new Error('bad response');
47
+
48
+ const reader = res.body.getReader();
49
+ const chunks: Uint8Array[] = [];
50
+ let received = 0;
51
+ let didTruncate = false;
52
+
53
+ for (;;) {
54
+ const { done, value } = await reader.read();
55
+ if (done) break;
56
+ if (!value) continue;
57
+ chunks.push(value);
58
+ received += value.byteLength;
59
+ if (received > maxBytes) {
60
+ await reader.cancel();
61
+ didTruncate = true;
62
+ break;
63
+ }
64
+ }
65
+
66
+ const total = chunks.reduce((n, c) => n + c.byteLength, 0);
67
+ const buffer = new Uint8Array(total);
68
+ let offset = 0;
69
+ for (const chunk of chunks) {
70
+ buffer.set(chunk, offset);
71
+ offset += chunk.byteLength;
72
+ }
73
+
74
+ // A newer load() may have superseded this one while we were reading;
75
+ // never write state for a stale (aborted) request.
76
+ if (active.signal.aborted) return;
77
+ content = new TextDecoder().decode(buffer.subarray(0, maxBytes));
78
+ truncated = didTruncate;
79
+ status = 'ready';
80
+ } catch (err) {
81
+ if (err instanceof Error && err.name === 'AbortError') return;
82
+ if (active.signal.aborted) return;
83
+ console.error(
84
+ 'Error loading text:',
85
+ redactUrl(url),
86
+ err instanceof Error ? err.name : 'unknown'
87
+ );
88
+ status = 'error';
89
+ }
90
+ }
91
+
92
+ $effect(() => {
93
+ // re-run when url changes
94
+ void url;
95
+ // Abort any in-flight load before starting a new one so a late-resolving
96
+ // previous request can't write content for the wrong file.
97
+ controller?.abort();
98
+ status = 'loading';
99
+ content = '';
100
+ truncated = false;
101
+ const active = new AbortController();
102
+ controller = active;
103
+ load(active);
104
+ return () => active.abort();
105
+ });
106
+
107
+ onDestroy(() => {
108
+ controller?.abort();
109
+ });
110
+ </script>
111
+
112
+ <div
113
+ class={cn('bg-default-100 flex h-full flex-col', className)}
114
+ data-testid={buildTestId('text-viewer', undefined, testId)}
115
+ >
116
+ {#if status === 'error'}
117
+ <UnsupportedPreview
118
+ url={urlAllowed ? url : undefined}
119
+ title={errorTitle}
120
+ actionLabel={errorActionLabel}
121
+ variant="error"
122
+ {testId}
123
+ />
124
+ {:else if status === 'loading'}
125
+ <div class="flex flex-1 items-center justify-center">
126
+ <Spinner size={Size.LG} color={Color.PRIMARY} label={loadingLabel} />
127
+ </div>
128
+ {:else}
129
+ {#if truncated}
130
+ <div
131
+ class="border-default-200 bg-default-50 text-default-700 border-b px-4 py-2 text-sm"
132
+ data-testid={buildTestId('text-viewer', 'truncated', testId)}
133
+ >
134
+ Showing the first {Math.round(maxBytes / 1024)} KB of this file.
135
+ </div>
136
+ {/if}
137
+ <pre
138
+ class="text-default-900 flex-1 overflow-auto p-4 font-mono text-sm [overflow-wrap:anywhere] whitespace-pre-wrap"
139
+ data-testid={buildTestId('text-viewer', 'content', testId)}>{content}</pre>
140
+ {/if}
141
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { TextViewerProps } from './text-viewer-types.js';
2
+ declare const TextViewer: import("svelte").Component<TextViewerProps, {}, "">;
3
+ type TextViewer = ReturnType<typeof TextViewer>;
4
+ export default TextViewer;
@@ -0,0 +1,10 @@
1
+ import type { ClassValue } from 'tailwind-variants';
2
+ export type TextViewerProps = {
3
+ url: string;
4
+ maxBytes?: number;
5
+ loadingLabel?: string;
6
+ errorTitle?: string;
7
+ errorActionLabel?: string;
8
+ class?: ClassValue;
9
+ testId?: string;
10
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ <script lang="ts">
2
+ import { cn } from '../../helper/cls.js';
3
+ import { buildTestId } from '../../helper/testid.js';
4
+ import Button from '../../button/Button.svelte';
5
+ import { Color, Size } from '../../variants.js';
6
+ import type { UnsupportedPreviewProps } from './unsupported-preview-types.js';
7
+
8
+ let {
9
+ url,
10
+ fileName,
11
+ title = 'Preview not available',
12
+ message,
13
+ actionLabel = 'Open in new tab',
14
+ variant = 'info',
15
+ class: className = '',
16
+ testId
17
+ }: UnsupportedPreviewProps = $props();
18
+ </script>
19
+
20
+ <div
21
+ class={cn('flex h-full w-full items-center justify-center p-6', className)}
22
+ data-testid={buildTestId('unsupported-preview', undefined, testId)}
23
+ >
24
+ <div class="ring-default-200 max-w-md rounded-xl bg-white p-6 text-center shadow-sm ring-1">
25
+ <div
26
+ class={cn(
27
+ 'mx-auto mb-3 flex size-10 items-center justify-center rounded-full',
28
+ variant === 'error' ? 'bg-danger-50 text-danger-600' : 'bg-default-100 text-default-600'
29
+ )}
30
+ aria-hidden="true"
31
+ >
32
+ {#if variant === 'error'}
33
+ <svg
34
+ xmlns="http://www.w3.org/2000/svg"
35
+ class="h-5 w-5"
36
+ viewBox="0 0 24 24"
37
+ fill="none"
38
+ stroke="currentColor"
39
+ stroke-width="2"
40
+ stroke-linecap="round"
41
+ stroke-linejoin="round"
42
+ >
43
+ <circle cx="12" cy="12" r="10" />
44
+ <line x1="12" y1="8" x2="12" y2="12" />
45
+ <line x1="12" y1="16" x2="12.01" y2="16" />
46
+ </svg>
47
+ {:else}
48
+ <svg
49
+ xmlns="http://www.w3.org/2000/svg"
50
+ class="h-5 w-5"
51
+ viewBox="0 0 24 24"
52
+ fill="none"
53
+ stroke="currentColor"
54
+ stroke-width="2"
55
+ stroke-linecap="round"
56
+ stroke-linejoin="round"
57
+ >
58
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
59
+ <polyline points="14 2 14 8 20 8" />
60
+ </svg>
61
+ {/if}
62
+ </div>
63
+
64
+ <p class="text-default-900 mb-1 text-base font-semibold">{title}</p>
65
+ {#if fileName}
66
+ <p class="text-default-700 mb-1 text-sm break-all">{fileName}</p>
67
+ {/if}
68
+ {#if message}
69
+ <p class="text-default-600 mb-4 text-sm">{message}</p>
70
+ {/if}
71
+
72
+ {#if url}
73
+ <Button
74
+ color={Color.PRIMARY}
75
+ size={Size.SM}
76
+ href={url}
77
+ target="_blank"
78
+ rel="noopener noreferrer"
79
+ >
80
+ {actionLabel}
81
+ </Button>
82
+ {/if}
83
+ </div>
84
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { UnsupportedPreviewProps } from './unsupported-preview-types.js';
2
+ declare const UnsupportedPreview: import("svelte").Component<UnsupportedPreviewProps, {}, "">;
3
+ type UnsupportedPreview = ReturnType<typeof UnsupportedPreview>;
4
+ export default UnsupportedPreview;
@@ -0,0 +1,128 @@
1
+ <script lang="ts">
2
+ import { buildTestId } from '../../helper/testid.js';
3
+ import type { ViewerToolbarProps } from './viewer-toolbar-types.js';
4
+
5
+ let {
6
+ component = 'viewer',
7
+ scale,
8
+ minScale,
9
+ maxScale,
10
+ disabled = false,
11
+ leftLabel = '',
12
+ onzoomin: onZoomIn,
13
+ onzoomout: onZoomOut,
14
+ onfittowidth: onFitToWidth,
15
+ testId
16
+ }: ViewerToolbarProps = $props();
17
+
18
+ function handleZoomIn() {
19
+ onZoomIn?.();
20
+ }
21
+ function handleZoomOut() {
22
+ onZoomOut?.();
23
+ }
24
+ function handleFitToWidth() {
25
+ onFitToWidth?.();
26
+ }
27
+
28
+ const btnClass =
29
+ 'text-default-700 hover:bg-default-100 focus-visible:ring-primary-400 rounded-md p-1.5 transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40';
30
+ </script>
31
+
32
+ <div
33
+ class="border-default-200 bg-default-50 flex items-center justify-between border-b px-4 py-2"
34
+ data-testid={buildTestId(component, 'toolbar', testId)}
35
+ >
36
+ <div class="flex items-center gap-2">
37
+ <span class="text-default-700 text-sm" aria-live="polite">{leftLabel}</span>
38
+ </div>
39
+
40
+ <div class="flex items-center gap-1">
41
+ <button
42
+ type="button"
43
+ onclick={handleZoomOut}
44
+ disabled={disabled || scale <= minScale}
45
+ class={btnClass}
46
+ aria-label="Zoom out"
47
+ title="Zoom out"
48
+ data-testid={buildTestId(component, 'zoom-out', testId)}
49
+ >
50
+ <svg
51
+ xmlns="http://www.w3.org/2000/svg"
52
+ class="h-5 w-5"
53
+ viewBox="0 0 24 24"
54
+ fill="none"
55
+ stroke="currentColor"
56
+ stroke-width="2"
57
+ stroke-linecap="round"
58
+ stroke-linejoin="round"
59
+ aria-hidden="true"
60
+ >
61
+ <circle cx="11" cy="11" r="8" />
62
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
63
+ <line x1="8" y1="11" x2="14" y2="11" />
64
+ </svg>
65
+ </button>
66
+
67
+ <span
68
+ class="text-default-700 min-w-[3.5rem] text-center text-sm tabular-nums"
69
+ aria-live="polite"
70
+ data-testid={buildTestId(component, 'scale', testId)}
71
+ >
72
+ {Math.round(scale * 100)}%
73
+ </span>
74
+
75
+ <button
76
+ type="button"
77
+ onclick={handleZoomIn}
78
+ disabled={disabled || scale >= maxScale}
79
+ class={btnClass}
80
+ aria-label="Zoom in"
81
+ title="Zoom in"
82
+ data-testid={buildTestId(component, 'zoom-in', testId)}
83
+ >
84
+ <svg
85
+ xmlns="http://www.w3.org/2000/svg"
86
+ class="h-5 w-5"
87
+ viewBox="0 0 24 24"
88
+ fill="none"
89
+ stroke="currentColor"
90
+ stroke-width="2"
91
+ stroke-linecap="round"
92
+ stroke-linejoin="round"
93
+ aria-hidden="true"
94
+ >
95
+ <circle cx="11" cy="11" r="8" />
96
+ <line x1="11" y1="8" x2="11" y2="14" />
97
+ <line x1="8" y1="11" x2="14" y2="11" />
98
+ </svg>
99
+ </button>
100
+
101
+ <button
102
+ type="button"
103
+ onclick={handleFitToWidth}
104
+ {disabled}
105
+ class={btnClass}
106
+ aria-label="Fit to width"
107
+ title="Fit to width"
108
+ data-testid={buildTestId(component, 'fit-to-width', testId)}
109
+ >
110
+ <svg
111
+ xmlns="http://www.w3.org/2000/svg"
112
+ class="h-5 w-5"
113
+ viewBox="0 0 24 24"
114
+ fill="none"
115
+ stroke="currentColor"
116
+ stroke-width="2"
117
+ stroke-linecap="round"
118
+ stroke-linejoin="round"
119
+ aria-hidden="true"
120
+ >
121
+ <polyline points="15 3 21 3 21 9" />
122
+ <polyline points="9 21 3 21 3 15" />
123
+ <line x1="21" y1="3" x2="14" y2="10" />
124
+ <line x1="3" y1="21" x2="10" y2="14" />
125
+ </svg>
126
+ </button>
127
+ </div>
128
+ </div>
@@ -0,0 +1,4 @@
1
+ import type { ViewerToolbarProps } from './viewer-toolbar-types.js';
2
+ declare const ViewerToolbar: import("svelte").Component<ViewerToolbarProps, {}, "">;
3
+ type ViewerToolbar = ReturnType<typeof ViewerToolbar>;
4
+ export default ViewerToolbar;
@@ -0,0 +1,17 @@
1
+ import type { ClassValue } from 'tailwind-variants';
2
+ export type UnsupportedPreviewProps = {
3
+ /** When set, an "open/download" link is shown (scheme-guarded by Button). */
4
+ url?: string;
5
+ /** File name shown above the message (rendered as text). */
6
+ fileName?: string;
7
+ /** Card heading. @default 'Preview not available' */
8
+ title?: string;
9
+ /** Supporting line under the heading. */
10
+ message?: string;
11
+ /** Label of the open/download link. @default 'Open in new tab' */
12
+ actionLabel?: string;
13
+ /** Visual treatment. @default 'info' */
14
+ variant?: 'info' | 'error';
15
+ class?: ClassValue;
16
+ testId?: string;
17
+ };
@@ -0,0 +1,14 @@
1
+ export type ViewerToolbarProps = {
2
+ /** testid namespace, e.g. 'image-viewer'. @default 'viewer' */
3
+ component?: string;
4
+ scale: number;
5
+ minScale: number;
6
+ maxScale: number;
7
+ disabled?: boolean;
8
+ /** Optional left-aligned label (e.g. a page or dimension hint). */
9
+ leftLabel?: string;
10
+ onzoomin?: () => void;
11
+ onzoomout?: () => void;
12
+ onfittowidth?: () => void;
13
+ testId?: string;
14
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * URL safety helpers for the file-preview viewers. See spec §4 / threat
3
+ * register T0.1, T1.3, T1.4. `isSafeHttpUrl` is a strict allowlist; do not
4
+ * convert it to a denylist.
5
+ */
6
+ /** True for loopback / link-local / private / metadata / encoded-IP hosts. */
7
+ export declare function isBlockedHost(hostname: string): boolean;
8
+ export type SafeUrlOptions = {
9
+ /** Base for resolving relative URLs. Defaults to document.baseURI in a browser. */
10
+ base?: string;
11
+ /** Allow same-origin blob: URLs. */
12
+ allowBlob?: boolean;
13
+ /** Require the resolved origin to be the base origin (or in allowedOrigins). */
14
+ sameOrigin?: boolean;
15
+ /** Extra origins permitted when sameOrigin is set. */
16
+ allowedOrigins?: string[];
17
+ /**
18
+ * Waive private/loopback/metadata host blocking, but ONLY for the base
19
+ * (same-origin) origin or an entry in `allowedOrigins`. This is not a blanket
20
+ * SSRF bypass: cross-origin private hosts stay blocked even when set. Intended
21
+ * for same-origin dev fetches (e.g. a localhost PDF served by the app itself).
22
+ */
23
+ allowPrivateHosts?: boolean;
24
+ };
25
+ /** Strict allowlist: http/https (+ same-origin blob when allowBlob). */
26
+ export declare function isSafeHttpUrl(url: string, opts?: SafeUrlOptions): boolean;
27
+ /** Strip query string + hash for safe logging (signed-URL tokens live there). */
28
+ export declare function redactUrl(url: string, base?: string): string;
29
+ /**
30
+ * Link-href guard for navigation (`<a href>`). Broader than isSafeHttpUrl:
31
+ * allows http/https/mailto/tel/relative/hash, blocks only executable schemes.
32
+ */
33
+ export declare function isSafeLinkHref(href: string, base?: string): boolean;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * URL safety helpers for the file-preview viewers. See spec §4 / threat
3
+ * register T0.1, T1.3, T1.4. `isSafeHttpUrl` is a strict allowlist; do not
4
+ * convert it to a denylist.
5
+ */
6
+ /** Parse a hostname into a 32-bit IPv4 int, or null if it is not an IPv4 form. */
7
+ function toIpv4Int(hostname) {
8
+ const host = hostname.toLowerCase();
9
+ // Dotted forms: a, a.b, a.b.c, a.b.c.d where each part may be dec/hex/octal.
10
+ const parts = host.split('.');
11
+ if (parts.length >= 1 && parts.length <= 4 && parts.every((p) => p.length > 0)) {
12
+ const nums = [];
13
+ for (const part of parts) {
14
+ let n;
15
+ if (/^0x[0-9a-f]+$/.test(part))
16
+ n = parseInt(part, 16);
17
+ else if (/^0[0-7]+$/.test(part))
18
+ n = parseInt(part, 8);
19
+ else if (/^[0-9]+$/.test(part))
20
+ n = parseInt(part, 10);
21
+ else
22
+ return null;
23
+ if (!Number.isFinite(n) || n < 0)
24
+ return null;
25
+ nums.push(n);
26
+ }
27
+ // Single number (e.g. 2130706433) => whole 32-bit address.
28
+ if (nums.length === 1) {
29
+ const n = nums[0];
30
+ return n <= 0xffffffff ? n >>> 0 : null;
31
+ }
32
+ // Last part fills remaining bytes; leading parts must be single bytes.
33
+ const last = nums[nums.length - 1];
34
+ const lead = nums.slice(0, -1);
35
+ if (lead.some((n) => n > 255))
36
+ return null;
37
+ const remaining = 4 - lead.length;
38
+ if (last > 0xffffffff || last >= 256 ** remaining)
39
+ return null;
40
+ let value = 0;
41
+ for (const n of lead)
42
+ value = (value << 8) | n;
43
+ value = (value * 256 ** remaining + last) >>> 0;
44
+ return value;
45
+ }
46
+ return null;
47
+ }
48
+ function ipv4IntInBlockedRange(ip) {
49
+ const a = (ip >>> 24) & 0xff;
50
+ const b = (ip >>> 16) & 0xff;
51
+ if (a === 0)
52
+ return true; // 0.0.0.0/8
53
+ if (a === 10)
54
+ return true; // 10/8
55
+ if (a === 127)
56
+ return true; // 127/8 loopback
57
+ if (a === 169 && b === 254)
58
+ return true; // 169.254/16 link-local
59
+ if (a === 172 && b >= 16 && b <= 31)
60
+ return true; // 172.16/12
61
+ if (a === 192 && b === 168)
62
+ return true; // 192.168/16
63
+ return false;
64
+ }
65
+ /** True for loopback / link-local / private / metadata / encoded-IP hosts. */
66
+ export function isBlockedHost(hostname) {
67
+ if (!hostname)
68
+ return true;
69
+ let host = hostname.toLowerCase().trim();
70
+ // Strip IPv6 brackets if present.
71
+ if (host.startsWith('[') && host.endsWith(']'))
72
+ host = host.slice(1, -1);
73
+ if (host === 'localhost' || host.endsWith('.localhost'))
74
+ return true;
75
+ if (host.endsWith('.internal') || host.startsWith('metadata.'))
76
+ return true;
77
+ // IPv6 loopback + unique-local (fc00::/7 => fc.. or fd..) + link-local (fe80).
78
+ if (host === '::1')
79
+ return true;
80
+ if (host.includes(':')) {
81
+ // IPv4-mapped IPv6 (::ffff:127.0.0.1, normalized by URL parser to
82
+ // ::ffff:7f00:1) tunnels a v4 address through a v6 host — unwrap and
83
+ // re-check against the v4 ranges so mapped loopback/private stays blocked.
84
+ const mapped = /^::ffff:(.+)$/.exec(host);
85
+ if (mapped) {
86
+ const tail = mapped[1];
87
+ const dotted = toIpv4Int(tail);
88
+ if (dotted !== null)
89
+ return ipv4IntInBlockedRange(dotted);
90
+ const groups = tail.split(':');
91
+ if (groups.length === 2 && groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) {
92
+ const v4 = ((parseInt(groups[0], 16) << 16) | parseInt(groups[1], 16)) >>> 0;
93
+ return ipv4IntInBlockedRange(v4);
94
+ }
95
+ }
96
+ if (host.startsWith('fc') || host.startsWith('fd'))
97
+ return true;
98
+ if (host.startsWith('fe80'))
99
+ return true;
100
+ return false;
101
+ }
102
+ const ip = toIpv4Int(host);
103
+ if (ip !== null)
104
+ return ipv4IntInBlockedRange(ip);
105
+ return false;
106
+ }
107
+ // Control chars + space; WHATWG URL strips \t\n\r, so reject before parsing.
108
+ // Matches U+0000–U+0020 (includes tab, newline, CR, space) and U+007F (DEL).
109
+ // eslint-disable-next-line no-control-regex -- detecting control chars is the point
110
+ const CONTROL_OR_SPACE = /[\u0000-\u0020\u007f]/;
111
+ function defaultBase(opts) {
112
+ if (opts.base)
113
+ return opts.base;
114
+ if (typeof document !== 'undefined')
115
+ return document.baseURI;
116
+ return undefined;
117
+ }
118
+ function originOf(value) {
119
+ try {
120
+ return new URL(value).origin;
121
+ }
122
+ catch {
123
+ return undefined;
124
+ }
125
+ }
126
+ /** Strict allowlist: http/https (+ same-origin blob when allowBlob). */
127
+ export function isSafeHttpUrl(url, opts = {}) {
128
+ if (typeof url !== 'string' || url.length === 0)
129
+ return false;
130
+ if (CONTROL_OR_SPACE.test(url))
131
+ return false;
132
+ const base = defaultBase(opts);
133
+ let parsed;
134
+ try {
135
+ parsed = new URL(url, base);
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ if (parsed.username || parsed.password)
141
+ return false;
142
+ const proto = parsed.protocol;
143
+ if (proto === 'blob:') {
144
+ if (!opts.allowBlob)
145
+ return false;
146
+ // blob: origin is embedded in the URL; require it to match the base origin.
147
+ const baseOrigin = base ? originOf(base) : undefined;
148
+ if (!baseOrigin || parsed.origin !== baseOrigin)
149
+ return false;
150
+ return true;
151
+ }
152
+ if (proto !== 'http:' && proto !== 'https:')
153
+ return false;
154
+ const baseOrigin = base
155
+ ? originOf(base)
156
+ : typeof location !== 'undefined'
157
+ ? location.origin
158
+ : undefined;
159
+ if (isBlockedHost(parsed.hostname)) {
160
+ // allowPrivateHosts only waives host-blocking for the base (same-origin)
161
+ // origin or an explicitly allowed origin — never a blanket SSRF bypass to
162
+ // arbitrary loopback/LAN/metadata hosts.
163
+ const privateOk = !!opts.allowPrivateHosts &&
164
+ ((!!baseOrigin && parsed.origin === baseOrigin) ||
165
+ (opts.allowedOrigins?.includes(parsed.origin) ?? false));
166
+ if (!privateOk)
167
+ return false;
168
+ }
169
+ if (opts.sameOrigin || opts.allowedOrigins) {
170
+ const allowed = new Set(opts.allowedOrigins ?? []);
171
+ if (opts.sameOrigin && baseOrigin)
172
+ allowed.add(baseOrigin);
173
+ // Fail closed: a same-origin / allowlist requirement with no resolvable
174
+ // allowed origin (e.g. sameOrigin in a non-browser with no base) must not
175
+ // silently permit every origin.
176
+ if (allowed.size === 0)
177
+ return false;
178
+ if (!allowed.has(parsed.origin))
179
+ return false;
180
+ }
181
+ return true;
182
+ }
183
+ /** Strip query string + hash for safe logging (signed-URL tokens live there). */
184
+ export function redactUrl(url, base) {
185
+ try {
186
+ const resolved = base ?? (typeof document !== 'undefined' ? document.baseURI : undefined);
187
+ const u = new URL(url, resolved);
188
+ return u.origin + u.pathname;
189
+ }
190
+ catch {
191
+ return '[unparseable url]';
192
+ }
193
+ }
194
+ /** Schemes that can execute script or read local files — never valid for a link. */
195
+ const DANGEROUS_LINK_SCHEMES = new Set(['javascript:', 'data:', 'vbscript:', 'file:']);
196
+ /**
197
+ * Link-href guard for navigation (`<a href>`). Broader than isSafeHttpUrl:
198
+ * allows http/https/mailto/tel/relative/hash, blocks only executable schemes.
199
+ */
200
+ export function isSafeLinkHref(href, base) {
201
+ if (typeof href !== 'string' || href.length === 0)
202
+ return false;
203
+ if (CONTROL_OR_SPACE.test(href))
204
+ return false;
205
+ const resolved = base ?? (typeof document !== 'undefined' ? document.baseURI : undefined);
206
+ try {
207
+ const parsed = new URL(href, resolved);
208
+ return !DANGEROUS_LINK_SCHEMES.has(parsed.protocol);
209
+ }
210
+ catch {
211
+ // No usable base + relative href: safe only if it carries no scheme.
212
+ return !/^[a-z][a-z0-9+.-]*:/i.test(href);
213
+ }
214
+ }