Skip to content

Commit dcd49a9

Browse files
checkpoint
1 parent 32f4142 commit dcd49a9

File tree

7 files changed

+1121
-15
lines changed

7 files changed

+1121
-15
lines changed

src/lib/HardenedMarkdown.svelte

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<script lang="ts">
2+
import type { HardenedOptions, Renderer, RendererArg } from './types.js';
3+
import Markdown from './Markdown.svelte';
4+
import { get_renderer } from './hast-to-svelte.js';
5+
6+
let {
7+
defaultOrigin = '',
8+
allowedLinkPrefixes = [],
9+
allowedImagePrefixes = [],
10+
...options
11+
}: HardenedOptions = $props();
12+
13+
// Only require defaultOrigin if we have specific prefixes (not wildcard only)
14+
const has_specific_link_prefixes = $derived(
15+
allowedLinkPrefixes.length && !allowedLinkPrefixes.every((p) => p === '*')
16+
);
17+
const has_specific_image_prefixes = $derived(
18+
allowedImagePrefixes.length && !allowedImagePrefixes.every((p) => p === '*')
19+
);
20+
21+
function error_check() {
22+
if (!defaultOrigin && (has_specific_link_prefixes || has_specific_image_prefixes)) {
23+
throw new Error(
24+
'defaultOrigin is required when allowedLinkPrefixes or allowedImagePrefixes are provided'
25+
);
26+
}
27+
}
28+
29+
error_check();
30+
$effect(error_check);
31+
32+
const parse_url = (url: unknown): URL | null => {
33+
if (typeof url !== 'string') return null;
34+
try {
35+
// Try to parse as absolute URL first
36+
const url_object = new URL(url);
37+
return url_object;
38+
} catch (error) {
39+
// If that fails and we have a defaultOrigin, try with it
40+
if (defaultOrigin) {
41+
try {
42+
const url_object = new URL(url, defaultOrigin);
43+
return url_object;
44+
} catch (error) {
45+
return null;
46+
}
47+
}
48+
return null;
49+
}
50+
};
51+
52+
const is_path_relative_url = (url: unknown): boolean => {
53+
if (typeof url !== 'string') return false;
54+
return url.startsWith('/');
55+
};
56+
57+
const transform_url = (url: unknown, allowedPrefixes: string[]): string | null => {
58+
if (!url) return null;
59+
const parsed_url = parse_url(url);
60+
if (!parsed_url) return null;
61+
62+
// Check for wildcard - allow all URLs
63+
if (allowedPrefixes.includes('*')) {
64+
const input_was_relative = is_path_relative_url(url);
65+
const url_string = parse_url(url);
66+
if (url_string) {
67+
if (input_was_relative) {
68+
return url_string.pathname + url_string.search + url_string.hash;
69+
}
70+
return url_string.href;
71+
}
72+
return null;
73+
}
74+
75+
// If the input is path relative, we output a path relative URL as well,
76+
// however, we always run the same checks on an absolute URL and we
77+
// always rescronstruct the output from the parsed URL to ensure that
78+
// the output is always a valid URL.
79+
const input_was_relative = is_path_relative_url(url);
80+
const url_string = parse_url(url);
81+
if (
82+
url_string &&
83+
allowedPrefixes.some((prefix) => {
84+
const parsed_prefix = parse_url(prefix);
85+
if (!parsed_prefix) {
86+
return false;
87+
}
88+
if (parsed_prefix.origin !== url_string.origin) {
89+
return false;
90+
}
91+
return url_string.href.startsWith(parsed_prefix.href);
92+
})
93+
) {
94+
if (input_was_relative) {
95+
return url_string.pathname + url_string.search + url_string.hash;
96+
}
97+
return url_string.href;
98+
}
99+
return null;
100+
};
101+
</script>
102+
103+
<Markdown {...options} {a} {img} />
104+
105+
{#snippet a(arg: RendererArg<'a'>)}
106+
{@const {
107+
props: { href, ...rest_props },
108+
children,
109+
node,
110+
...rest
111+
} = arg}
112+
{@const transformed_url = transform_url(href, allowedLinkPrefixes)}
113+
{#if transformed_url === null}
114+
<!-- TODO should probably be customizable -->
115+
<span class="text-gray-500" title="Blocked URL: {href}">{@render children?.()} [blocked]</span>
116+
{:else}
117+
{@const resolved_a = get_renderer(arg.tagName, options, arg) as unknown as Renderer<'a'>}
118+
{@render resolved_a({
119+
props: { ...rest_props, href: transformed_url, target: '_blank', rel: 'noopener noreferrer' },
120+
children,
121+
node,
122+
...rest
123+
})}
124+
{/if}
125+
{/snippet}
126+
127+
{#snippet img(arg: RendererArg<'img'>)}
128+
{@const {
129+
props: { src, alt, ...rest_props },
130+
node,
131+
...rest
132+
} = arg}
133+
{@const transformed_url = transform_url(src, allowedImagePrefixes)}
134+
{#if transformed_url === null}
135+
<!-- TODO should probably be customizable -->
136+
<span
137+
class="inline-block bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 px-3 py-1 rounded text-sm"
138+
>
139+
[Blocked image: {alt || 'No Description'}]
140+
</span>
141+
{:else}
142+
{@const resolved_img = get_renderer(arg.tagName, options, arg) as unknown as Renderer<'img'>}
143+
{@render resolved_img({
144+
props: { ...rest_props, src: transformed_url, alt },
145+
node,
146+
...rest
147+
})}
148+
{/if}
149+
{/snippet}

0 commit comments

Comments
 (0)