Skip to content

Commit 37559fe

Browse files
feat: harden
1 parent dcd49a9 commit 37559fe

File tree

4 files changed

+39
-160
lines changed

4 files changed

+39
-160
lines changed

src/lib/HardenedMarkdown.svelte

Lines changed: 17 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -1,149 +1,28 @@
11
<script lang="ts">
2-
import type { HardenedOptions, Renderer, RendererArg } from './types.js';
2+
import type { HardenedOptions } from './types.js';
33
import Markdown from './Markdown.svelte';
4-
import { get_renderer } from './hast-to-svelte.js';
4+
import { harden } from './harden.js';
55
66
let {
77
defaultOrigin = '',
88
allowedLinkPrefixes = [],
99
allowedImagePrefixes = [],
10+
rehypePlugins = [],
1011
...options
1112
}: 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-
};
10113
</script>
10214

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}
15+
<Markdown
16+
{...options}
17+
rehypePlugins={[
18+
...rehypePlugins,
19+
[
20+
harden,
21+
{
22+
defaultOrigin,
23+
allowedLinkPrefixes,
24+
allowedImagePrefixes
25+
}
26+
]
27+
]}
28+
/>

src/lib/Markdown.svelte.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Markdown from './Markdown.svelte';
44
import rehype_raw from 'rehype-raw';
55
import remark_gfm from 'remark-gfm';
66
import { create_children_element_renderer } from './MarkdownTestRenderers.svelte';
7+
import '../../vitest.js';
78

89
describe('Markdown', () => {
910
it('should support `null` as children', async () => {

src/lib/harden.ts

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
1-
import type { Nodes as HastNodes } from 'hast';
1+
import type { Nodes as HastNodes, Root as HastRoot } from 'hast';
22
import { CONTINUE, SKIP, visit, type BuildVisitor } from 'unist-util-visit';
33

4-
export function harden(
5-
nodes: HastNodes,
6-
{
7-
defaultOrigin = '',
8-
allowedLinkPrefixes = [],
9-
allowedImagePrefixes = []
10-
}: {
11-
defaultOrigin?: string;
12-
allowedLinkPrefixes?: string[];
13-
allowedImagePrefixes?: string[];
14-
}
15-
): HastNodes {
4+
export function harden({
5+
defaultOrigin = '',
6+
allowedLinkPrefixes = [],
7+
allowedImagePrefixes = []
8+
}: {
9+
defaultOrigin?: string;
10+
allowedLinkPrefixes?: string[];
11+
allowedImagePrefixes?: string[];
12+
}) {
1613
// Only require defaultOrigin if we have specific prefixes (not wildcard only)
1714
const has_specific_link_prefixes =
1815
allowedLinkPrefixes.length && !allowedLinkPrefixes.every((p) => p === '*');
@@ -25,23 +22,22 @@ export function harden(
2522
);
2623
}
2724

28-
const visitor = create_visitor(defaultOrigin, allowedLinkPrefixes, allowedImagePrefixes);
29-
visit(nodes, visitor);
30-
return nodes;
25+
return (tree: HastRoot) => {
26+
const visitor = create_visitor(defaultOrigin, allowedLinkPrefixes, allowedImagePrefixes);
27+
visit(tree, visitor);
28+
};
3129
}
3230

3331
function parse_url(url: unknown, default_origin: string): URL | null {
3432
if (typeof url !== 'string') return null;
3533
try {
3634
// Try to parse as absolute URL first
37-
const url_object = new URL(url);
38-
return url_object;
35+
return new URL(url);
3936
} catch {
4037
// If that fails and we have a defaultOrigin, try with it
4138
if (default_origin) {
4239
try {
43-
const url_object = new URL(url, default_origin);
44-
return url_object;
40+
return new URL(url, default_origin);
4541
} catch {
4642
return null;
4743
}
@@ -55,6 +51,8 @@ function is_path_relative_url(url: unknown): boolean {
5551
return url.startsWith('/');
5652
}
5753

54+
const safe_protocols = new Set(['https:', 'http:', 'irc:', 'ircs:', 'mailto:', 'xmpp:']);
55+
5856
function transform_url(
5957
url: unknown,
6058
allowedPrefixes: string[],
@@ -63,6 +61,7 @@ function transform_url(
6361
if (!url) return null;
6462
const parsed_url = parse_url(url, default_origin);
6563
if (!parsed_url) return null;
64+
if (!safe_protocols.has(parsed_url.protocol)) return null;
6665

6766
// Check for wildcard - allow all URLs
6867
if (allowedPrefixes.includes('*')) {
@@ -171,7 +170,7 @@ const create_visitor = (
171170
children: [
172171
{
173172
type: 'text',
174-
value: 'Blocked image: ' + String(node.properties.alt || 'No Description')
173+
value: '[Blocked image: ' + String(node.properties.alt || 'No Description') + ']'
175174
}
176175
]
177176
};

src/lib/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export type URLTransform = (
4848
/** URL. */
4949
url: string,
5050
/** Property name (example: `'href'`). */
51-
key: string,
51+
property: string,
5252
/** Node. */
5353
element: Readonly<Element>
5454
) => string | null | undefined;

0 commit comments

Comments
 (0)