@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.
- package/dist/ai/MessageBox.svelte +2 -1
- package/dist/button/Button.svelte +89 -7
- package/dist/elements/file-viewer/FilePreviewModal.svelte +102 -0
- package/dist/elements/file-viewer/FilePreviewModal.svelte.d.ts +4 -0
- package/dist/elements/file-viewer/FileViewer.svelte +96 -0
- package/dist/elements/file-viewer/FileViewer.svelte.d.ts +4 -0
- package/dist/elements/file-viewer/file-preview-modal-types.d.ts +62 -0
- package/dist/elements/file-viewer/file-preview-modal-types.js +1 -0
- package/dist/elements/file-viewer/file-viewer-types.d.ts +51 -0
- package/dist/elements/file-viewer/file-viewer-types.js +127 -0
- package/dist/elements/image-viewer/ImageViewer.svelte +133 -0
- package/dist/elements/image-viewer/ImageViewer.svelte.d.ts +4 -0
- package/dist/elements/image-viewer/image-viewer-types.d.ts +16 -0
- package/dist/elements/image-viewer/image-viewer-types.js +1 -0
- package/dist/elements/pdf-viewer/PdfViewer.svelte +225 -34
- package/dist/elements/pdf-viewer/pdf-viewer-types.d.ts +13 -4
- package/dist/elements/text-viewer/TextViewer.svelte +141 -0
- package/dist/elements/text-viewer/TextViewer.svelte.d.ts +4 -0
- package/dist/elements/text-viewer/text-viewer-types.d.ts +10 -0
- package/dist/elements/text-viewer/text-viewer-types.js +1 -0
- package/dist/elements/viewer-shared/UnsupportedPreview.svelte +84 -0
- package/dist/elements/viewer-shared/UnsupportedPreview.svelte.d.ts +4 -0
- package/dist/elements/viewer-shared/ViewerToolbar.svelte +128 -0
- package/dist/elements/viewer-shared/ViewerToolbar.svelte.d.ts +4 -0
- package/dist/elements/viewer-shared/unsupported-preview-types.d.ts +17 -0
- package/dist/elements/viewer-shared/unsupported-preview-types.js +1 -0
- package/dist/elements/viewer-shared/viewer-toolbar-types.d.ts +14 -0
- package/dist/elements/viewer-shared/viewer-toolbar-types.js +1 -0
- package/dist/helper/is-safe-http-url.d.ts +33 -0
- package/dist/helper/is-safe-http-url.js +214 -0
- package/dist/helper/pdf-assets.d.ts +42 -0
- package/dist/helper/pdf-assets.js +57 -0
- package/dist/helper/pdf-safety.d.ts +38 -0
- package/dist/helper/pdf-safety.js +85 -0
- package/dist/helper/resource-limits.d.ts +13 -0
- package/dist/helper/resource-limits.js +16 -0
- package/dist/helper/sanitize-html.d.ts +5 -0
- package/dist/helper/sanitize-html.js +12 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- 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 @@
|
|
|
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,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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
}
|