diff --git a/src/lib/components/Head/Head.svelte b/src/lib/components/Head/Head.svelte index 1077b96..3dcd4ff 100644 --- a/src/lib/components/Head/Head.svelte +++ b/src/lib/components/Head/Head.svelte @@ -7,62 +7,61 @@ /** the HTML attributes to attach to the meta tag */ attributes?: Record | 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)[]; diff --git a/src/lib/util/__tests__/escapeHtmlString.test.ts b/src/lib/util/__tests__/escapeHtmlString.test.ts new file mode 100644 index 0000000..3dfa903 --- /dev/null +++ b/src/lib/util/__tests__/escapeHtmlString.test.ts @@ -0,0 +1,113 @@ +import { escapeHtmlString } from '$lib/util/escapeHtmlString'; + +describe('escapeHtmlString', () => { + // Basic functionality tests + it('escapes & to &', () => { + expect(escapeHtmlString('&')).toBe('&'); + }); + + it('escapes < to <', () => { + expect(escapeHtmlString('<')).toBe('<'); + }); + + it('escapes > to >', () => { + expect(escapeHtmlString('>')).toBe('>'); + }); + + it('escapes " to "', () => { + expect(escapeHtmlString('"')).toBe('"'); + }); + + it("escapes ' to '", () => { + expect(escapeHtmlString("'")).toBe('''); + }); + + it('escapes multiple special characters in a string', () => { + expect(escapeHtmlString('Tom & Jerry "Funny"')).toBe( + 'Tom & Jerry <Cartoons> "Funny"' + ); + }); + + 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" the 'Wild' & mysterious world of `; + const expected = `Tom & Jerry's "Adventure" <in> the 'Wild' & mysterious world of <HTML>`; + expect(escapeHtmlString(input)).toBe(expected); + }); + + // Dangerous edge cases + it('escapes a script tag to prevent XSS', () => { + const input = ``; + const expected = `<script>alert('XSS')</script>`; + expect(escapeHtmlString(input)).toBe(expected); + }); + + it('escapes an onclick attribute to prevent XSS', () => { + const input = `
Click me
`; + const expected = `<div onclick="alert('XSS')">Click me</div>`; + expect(escapeHtmlString(input)).toBe(expected); + }); + + it('escapes an img tag with an onerror attribute to prevent XSS', () => { + const input = ``; + const expected = `<img src="invalid.jpg" onerror="alert('XSS')" />`; + expect(escapeHtmlString(input)).toBe(expected); + }); + + it('escapes a mix of script tags and HTML', () => { + const input = `Hello`; + const expected = `<b>Hello</b><script>alert('XSS')</script>`; + 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 = 'admin' --`; + expect(escapeHtmlString(input)).toBe(expected); + }); + + it('escapes an XSS attempt with event handlers', () => { + const input = ``; + const expected = `<button onmouseover="alert('XSS')">Hover me</button>`; + expect(escapeHtmlString(input)).toBe(expected); + }); + + it('escapes an inline JavaScript attempt', () => { + const input = ``; + const expected = `<img src="javascript:alert('XSS')" />`; + expect(escapeHtmlString(input)).toBe(expected); + }); + + it('escapes a string with mixed HTML and text', () => { + const input = `

Welcome

This is a test

`; + const expected = `<h1>Welcome</h1><p>This is a <strong>test</strong></p>`; + expect(escapeHtmlString(input)).toBe(expected); + }); + + it('escapes a string that tries to manipulate the DOM', () => { + const input = `
Click me
`; + const expected = `<div id="test" onclick="document.location='http://evil.com'">Click me</div>`; + expect(escapeHtmlString(input)).toBe(expected); + }); + + it('escapes dangerous attributes like style with JavaScript', () => { + const input = `
Content
`; + const expected = `<div style="background-image: url('javascript:alert(1)')">Content</div>`; + expect(escapeHtmlString(input)).toBe(expected); + }); + + it('escapes a complex combination of threats', () => { + const input = `
`; + const expected = `<script>alert('XSS')</script><img src="javascript:alert('XSS')" onerror="alert('XSS')"><div style="background-image: url('javascript:alert(1)')"></div>`; + expect(escapeHtmlString(input)).toBe(expected); + }); +}); diff --git a/src/lib/util/__tests__/headTagToEscapedStrings.test.ts b/src/lib/util/__tests__/headTagToEscapedStrings.test.ts new file mode 100644 index 0000000..54e4bdf --- /dev/null +++ b/src/lib/util/__tests__/headTagToEscapedStrings.test.ts @@ -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(['']); + }); + + it('handles tags with multiple attributes', () => { + const result = headTagsToEscapedStrings([ + { + tag: 'meta', + attributes: { name: 'viewport', content: 'width=device-width, initial-scale=1' } + } + ]); + expect(result).toEqual([ + '' + ]); + }); + + it('handles tags without attributes or content', () => { + const result = headTagsToEscapedStrings([{ tag: 'meta' }]); + expect(result).toEqual(['']); + }); + + it('escapes attributes correctly', () => { + const result = headTagsToEscapedStrings([ + { tag: 'meta', attributes: { name: '', content: '"quoted"' } } + ]); + expect(result).toEqual(['']); + }); + + it('skips empty attribute values', () => { + const result = headTagsToEscapedStrings([ + { tag: 'meta', attributes: { name: 'test', content: '' } } + ]); + expect(result).toEqual(['']); + }); + + it('handles tags with content', () => { + const result = headTagsToEscapedStrings([{ tag: 'script', content: 'console.log("Hello");' }]); + expect(result).toEqual(['']); + }); + + it('processes a mixed list of tags', () => { + const result = headTagsToEscapedStrings(metaTagsSample); + expect(result).toEqual([ + '', + '', + '', + '' + ]); + }); +}); + +describe('headTagsToEscapedStrings - vulnerability tests', () => { + it('escapes script injection attempts in attributes', () => { + const result = headTagsToEscapedStrings([ + { tag: 'meta', attributes: { name: 'viewport', content: '' } } + ]); + expect(result).toEqual([ + '' + ]); + }); + + it('handles SQL injection payloads in attributes', () => { + const result = headTagsToEscapedStrings([ + { tag: 'meta', attributes: { name: 'author', content: '"; DROP TABLE users;--' } } + ]); + expect(result).toEqual(['']); + }); + + it('handles event handler injection attempts in attributes', () => { + const result = headTagsToEscapedStrings([ + { tag: 'img', attributes: { src: 'x', onerror: 'alert("XSS")' } } + ]); + expect(result).toEqual(['']); + }); + + it('sanitizes special characters in tag attributes', () => { + const result = headTagsToEscapedStrings([ + { tag: 'meta', attributes: { name: '', content: 'content&value"special\'' } } + ]); + expect(result).toEqual([ + '' + ]); + }); + + it('prevents malformed HTML from breaking the output', () => { + const result = headTagsToEscapedStrings([ + { tag: 'meta', attributes: { name: 'test', content: '">' } } + ]); + expect(result).toEqual([ + '' + ]); + }); + + it('ignores attribute keys with malicious patterns', () => { + const result = headTagsToEscapedStrings([ + { tag: 'meta', attributes: { '">': 'malicious' } } + ]); + expect(result).toEqual(['']); + }); + + it('prevents double-escaping of HTML entities', () => { + const result = headTagsToEscapedStrings([ + { tag: 'meta', attributes: { content: '<already-escaped>' } } + ]); + expect(result).toEqual(['']); + }); +}); diff --git a/src/lib/util/escapeHtmlString.ts b/src/lib/util/escapeHtmlString.ts new file mode 100644 index 0000000..ee27ec6 --- /dev/null +++ b/src/lib/util/escapeHtmlString.ts @@ -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 + '&' + ) + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + return escaped; +}; diff --git a/src/lib/util/headTagToEscapedStrings.ts b/src/lib/util/headTagToEscapedStrings.ts new file mode 100644 index 0000000..8f68d4d --- /dev/null +++ b/src/lib/util/headTagToEscapedStrings.ts @@ -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 | 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}${attributesString}/>`; + }); + + return tagsAsEscapedStrings; +};