Skip to content

Commit

Permalink
Escape HTML and add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
arcataroger committed Dec 9, 2024
1 parent 1bf93f0 commit 2688f90
Show file tree
Hide file tree
Showing 5 changed files with 314 additions and 16 deletions.
31 changes: 15 additions & 16 deletions src/lib/components/Head/Head.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,62 +7,61 @@
/** the HTML attributes to attach to the meta tag */
attributes?: Record<string, string> | null | undefined;
}
export interface SeoTitleTag {
tag: 'title';
content: string | null;
attributes?: null;
}
export interface RegularMetaAttributes {
name: string;
content: string;
}
export interface OgMetaAttributes {
property: string;
content: string;
}
export interface SeoMetaTag {
tag: 'meta';
content?: null;
attributes: RegularMetaAttributes | OgMetaAttributes;
}
export interface FaviconAttributes {
sizes: string;
type: string;
rel: string;
href: string;
}
export interface AppleTouchIconAttributes {
sizes: string;
rel: 'apple-touch-icon';
href: string;
}
export interface SeoLinkTag {
tag: 'link';
content?: null;
attributes: FaviconAttributes | AppleTouchIconAttributes;
}
export type SeoTag = SeoTitleTag | SeoMetaTag;
export type FaviconTag = SeoMetaTag | SeoLinkTag;
export type SeoOrFaviconTag = SeoTag | FaviconTag;
export type HeadTags = (TitleMetaLinkTag | SeoOrFaviconTag)[];
</script>

<script lang="ts">
export let data: Array<TitleMetaLinkTag | SeoOrFaviconTag> = [];
// Statically output HTML instead of using conditionals inside <svelte:head>
const renderedTags = data
.map(({ tag, attributes, content }) => {
const attrs = attributes
? Object.entries(attributes)
.map(([key, value]) => `${key}="${value}"`)
.join(' ')
: '';
if (content) {
return `<${tag} ${attrs}>${content}</${tag}>`;
} else {
return `<${tag} ${attrs} />`;
}
})
.join('\n');
import { headTagsToEscapedStrings } from '$lib/util/headTagToEscapedStrings';
export let data: HeadTags = [];
// To work around hydration errors, we render the tags as static escaped strings
const renderedTags: string = data?.length > 0 ? headTagsToEscapedStrings(data).join('\n') : '';
</script>

<svelte:head>
Expand Down
113 changes: 113 additions & 0 deletions src/lib/util/__tests__/escapeHtmlString.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { escapeHtmlString } from '$lib/util/escapeHtmlString';

describe('escapeHtmlString', () => {
// Basic functionality tests
it('escapes & to &amp;', () => {
expect(escapeHtmlString('&')).toBe('&amp;');
});

it('escapes < to &lt;', () => {
expect(escapeHtmlString('<')).toBe('&lt;');
});

it('escapes > to &gt;', () => {
expect(escapeHtmlString('>')).toBe('&gt;');
});

it('escapes " to &quot;', () => {
expect(escapeHtmlString('"')).toBe('&quot;');
});

it("escapes ' to &apos;", () => {
expect(escapeHtmlString("'")).toBe('&apos;');
});

it('escapes multiple special characters in a string', () => {
expect(escapeHtmlString('Tom & Jerry <Cartoons> "Funny"')).toBe(
'Tom &amp; Jerry &lt;Cartoons&gt; &quot;Funny&quot;'
);
});

it('returns the original string if no special characters are present', () => {
expect(escapeHtmlString('Hello, World!')).toBe('Hello, World!');
});

it('handles an empty string', () => {
expect(escapeHtmlString('')).toBe('');
});

// Complex test case
it('escapes all special characters in a complex string', () => {
const input = `Tom & Jerry's "Adventure" <in> the 'Wild' & mysterious world of <HTML>`;
const expected = `Tom &amp; Jerry&apos;s &quot;Adventure&quot; &lt;in&gt; the &apos;Wild&apos; &amp; mysterious world of &lt;HTML&gt;`;
expect(escapeHtmlString(input)).toBe(expected);
});

// Dangerous edge cases
it('escapes a script tag to prevent XSS', () => {
const input = `<script>alert('XSS')</script>`;
const expected = `&lt;script&gt;alert(&apos;XSS&apos;)&lt;/script&gt;`;
expect(escapeHtmlString(input)).toBe(expected);
});

it('escapes an onclick attribute to prevent XSS', () => {
const input = `<div onclick="alert('XSS')">Click me</div>`;
const expected = `&lt;div onclick=&quot;alert(&apos;XSS&apos;)&quot;&gt;Click me&lt;/div&gt;`;
expect(escapeHtmlString(input)).toBe(expected);
});

it('escapes an img tag with an onerror attribute to prevent XSS', () => {
const input = `<img src="invalid.jpg" onerror="alert('XSS')" />`;
const expected = `&lt;img src=&quot;invalid.jpg&quot; onerror=&quot;alert(&apos;XSS&apos;)&quot; /&gt;`;
expect(escapeHtmlString(input)).toBe(expected);
});

it('escapes a mix of script tags and HTML', () => {
const input = `<b>Hello</b><script>alert('XSS')</script>`;
const expected = `&lt;b&gt;Hello&lt;/b&gt;&lt;script&gt;alert(&apos;XSS&apos;)&lt;/script&gt;`;
expect(escapeHtmlString(input)).toBe(expected);
});

// OWASP vulnerabilities
it('escapes a SQL injection-like string', () => {
const input = `SELECT * FROM users WHERE name = 'admin' --`;
const expected = `SELECT * FROM users WHERE name = &apos;admin&apos; --`;
expect(escapeHtmlString(input)).toBe(expected);
});

it('escapes an XSS attempt with event handlers', () => {
const input = `<button onmouseover="alert('XSS')">Hover me</button>`;
const expected = `&lt;button onmouseover=&quot;alert(&apos;XSS&apos;)&quot;&gt;Hover me&lt;/button&gt;`;
expect(escapeHtmlString(input)).toBe(expected);
});

it('escapes an inline JavaScript attempt', () => {
const input = `<img src="javascript:alert('XSS')" />`;
const expected = `&lt;img src=&quot;javascript:alert(&apos;XSS&apos;)&quot; /&gt;`;
expect(escapeHtmlString(input)).toBe(expected);
});

it('escapes a string with mixed HTML and text', () => {
const input = `<h1>Welcome</h1><p>This is a <strong>test</strong></p>`;
const expected = `&lt;h1&gt;Welcome&lt;/h1&gt;&lt;p&gt;This is a &lt;strong&gt;test&lt;/strong&gt;&lt;/p&gt;`;
expect(escapeHtmlString(input)).toBe(expected);
});

it('escapes a string that tries to manipulate the DOM', () => {
const input = `<div id="test" onclick="document.location='http://evil.com'">Click me</div>`;
const expected = `&lt;div id=&quot;test&quot; onclick=&quot;document.location=&apos;http://evil.com&apos;&quot;&gt;Click me&lt;/div&gt;`;
expect(escapeHtmlString(input)).toBe(expected);
});

it('escapes dangerous attributes like style with JavaScript', () => {
const input = `<div style="background-image: url('javascript:alert(1)')">Content</div>`;
const expected = `&lt;div style=&quot;background-image: url(&apos;javascript:alert(1)&apos;)&quot;&gt;Content&lt;/div&gt;`;
expect(escapeHtmlString(input)).toBe(expected);
});

it('escapes a complex combination of threats', () => {
const input = `<script>alert('XSS')</script><img src="javascript:alert('XSS')" onerror="alert('XSS')"><div style="background-image: url('javascript:alert(1)')"></div>`;
const expected = `&lt;script&gt;alert(&apos;XSS&apos;)&lt;/script&gt;&lt;img src=&quot;javascript:alert(&apos;XSS&apos;)&quot; onerror=&quot;alert(&apos;XSS&apos;)&quot;&gt;&lt;div style=&quot;background-image: url(&apos;javascript:alert(1)&apos;)&quot;&gt;&lt;/div&gt;`;
expect(escapeHtmlString(input)).toBe(expected);
});
});
135 changes: 135 additions & 0 deletions src/lib/util/__tests__/headTagToEscapedStrings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { headTagsToEscapedStrings, type GenericHeadTag } from '$lib/util/headTagToEscapedStrings';

const metaTagsSample: GenericHeadTag[] = [
{
tag: 'meta',
attributes: { charset: 'UTF-8' }
},
{
tag: 'meta',
attributes: { name: 'viewport', content: 'width=device-width, initial-scale=1' }
},
{
tag: 'link',
attributes: { rel: 'stylesheet', href: '/styles.css' }
},
{
tag: 'script',
content: 'console.log("Hello");'
}
];

describe('headTagsToEscapedStrings - simple tests', () => {
it('returns an empty array for empty input', () => {
const result = headTagsToEscapedStrings([]);
expect(result).toEqual([]);
});

it('handles meta tags with attributes', () => {
const result = headTagsToEscapedStrings([{ tag: 'meta', attributes: { charset: 'UTF-8' } }]);
expect(result).toEqual(['<meta charset="UTF-8"/>']);
});

it('handles tags with multiple attributes', () => {
const result = headTagsToEscapedStrings([
{
tag: 'meta',
attributes: { name: 'viewport', content: 'width=device-width, initial-scale=1' }
}
]);
expect(result).toEqual([
'<meta name="viewport" content="width=device-width, initial-scale=1"/>'
]);
});

it('handles tags without attributes or content', () => {
const result = headTagsToEscapedStrings([{ tag: 'meta' }]);
expect(result).toEqual(['<meta/>']);
});

it('escapes attributes correctly', () => {
const result = headTagsToEscapedStrings([
{ tag: 'meta', attributes: { name: '<test>', content: '"quoted"' } }
]);
expect(result).toEqual(['<meta name="&lt;test&gt;" content="&quot;quoted&quot;"/>']);
});

it('skips empty attribute values', () => {
const result = headTagsToEscapedStrings([
{ tag: 'meta', attributes: { name: 'test', content: '' } }
]);
expect(result).toEqual(['<meta name="test"/>']);
});

it('handles tags with content', () => {
const result = headTagsToEscapedStrings([{ tag: 'script', content: 'console.log("Hello");' }]);
expect(result).toEqual(['<script>console.log("Hello");</script>']);
});

it('processes a mixed list of tags', () => {
const result = headTagsToEscapedStrings(metaTagsSample);
expect(result).toEqual([
'<meta charset="UTF-8"/>',
'<meta name="viewport" content="width=device-width, initial-scale=1"/>',
'<link rel="stylesheet" href="/styles.css"/>',
'<script>console.log("Hello");</script>'
]);
});
});

describe('headTagsToEscapedStrings - vulnerability tests', () => {
it('escapes script injection attempts in attributes', () => {
const result = headTagsToEscapedStrings([
{ tag: 'meta', attributes: { name: 'viewport', content: '<script>alert("XSS")</script>' } }
]);
expect(result).toEqual([
'<meta name="viewport" content="&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;"/>'
]);
});

it('handles SQL injection payloads in attributes', () => {
const result = headTagsToEscapedStrings([
{ tag: 'meta', attributes: { name: 'author', content: '"; DROP TABLE users;--' } }
]);
expect(result).toEqual(['<meta name="author" content="&quot;; DROP TABLE users;--"/>']);
});

it('handles event handler injection attempts in attributes', () => {
const result = headTagsToEscapedStrings([
{ tag: 'img', attributes: { src: 'x', onerror: 'alert("XSS")' } }
]);
expect(result).toEqual(['<img src="x" onerror="alert(&quot;XSS&quot;)"/>']);
});

it('sanitizes special characters in tag attributes', () => {
const result = headTagsToEscapedStrings([
{ tag: 'meta', attributes: { name: '<meta>', content: 'content&value"special\'' } }
]);
expect(result).toEqual([
'<meta name="&lt;meta&gt;" content="content&amp;value&quot;special&apos;"/>'
]);
});

it('prevents malformed HTML from breaking the output', () => {
const result = headTagsToEscapedStrings([
{ tag: 'meta', attributes: { name: 'test', content: '"></meta><script>alert(1)</script>' } }
]);
expect(result).toEqual([
'<meta name="test" content="&quot;&gt;&lt;/meta&gt;&lt;script&gt;alert(1)&lt;/script&gt;"/>'
]);
});

it('ignores attribute keys with malicious patterns', () => {
const result = headTagsToEscapedStrings([
{ tag: 'meta', attributes: { '"><script>alert(1)</script>': 'malicious' } }
]);
expect(result).toEqual(['<meta &quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;="malicious"/>']);
});

it('prevents double-escaping of HTML entities', () => {
const result = headTagsToEscapedStrings([
{ tag: 'meta', attributes: { content: '&lt;already-escaped&gt;' } }
]);
expect(result).toEqual(['<meta content="&lt;already-escaped&gt;"/>']);
});
});
13 changes: 13 additions & 0 deletions src/lib/util/escapeHtmlString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Replaces special chars (&<>"') with HTML entities */
export const escapeHtmlString = (html: string) => {
const escaped = html
.replace(
/&(?!amp;|lt;|gt;|quot;|apos;)/g, // Don't re-encode these entities
'&amp;'
)
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
return escaped;
};
38 changes: 38 additions & 0 deletions src/lib/util/headTagToEscapedStrings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { escapeHtmlString } from '$lib/util/escapeHtmlString';
import type { HeadTags } from '$lib/components/Head/Head.svelte';

export interface GenericHeadTag {
tag: string;
name?: string | null;
attributes?: Record<string, string | null | undefined> | null;
content?: string | null;
}

export type SupportedHeadTags = HeadTags | GenericHeadTag[];

export const headTagsToEscapedStrings = (headTags: SupportedHeadTags): string[] => {
const tagsAsEscapedStrings = headTags.map((metaTag) => {
const { tag, attributes, content } = metaTag;

const serializedAttributes: string[] =
attributes && typeof attributes === 'object'
? Object.entries(attributes) // Object.entries enumerates only the object's own props, not inherited ones
.flatMap(([key, value]) =>
value
? // Escape key & value and concat them into an attribute
`${escapeHtmlString(key)}="${escapeHtmlString(value)}"`
: // Or skip empty values by returning blank array to flatMap
[]
)
: [];

const attributesString: string =
serializedAttributes?.length > 0 ? ` ${serializedAttributes.join(' ')}` : '';

return content
? `<${tag}${attributesString}>${content}</${tag}>`
: `<${tag}${attributesString}/>`;
});

return tagsAsEscapedStrings;
};

0 comments on commit 2688f90

Please sign in to comment.