-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
1bf93f0
commit 2688f90
Showing
5 changed files
with
314 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 &', () => { | ||
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 <Cartoons> "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" <in> the 'Wild' & mysterious world of <HTML>`; | ||
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 = `<script>alert('XSS')</script>`; | ||
const expected = `<script>alert('XSS')</script>`; | ||
expect(escapeHtmlString(input)).toBe(expected); | ||
}); | ||
|
||
it('escapes an onclick attribute to prevent XSS', () => { | ||
const input = `<div onclick="alert('XSS')">Click me</div>`; | ||
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 = `<img src="invalid.jpg" onerror="alert('XSS')" />`; | ||
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 = `<b>Hello</b><script>alert('XSS')</script>`; | ||
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 = `<button onmouseover="alert('XSS')">Hover me</button>`; | ||
const expected = `<button onmouseover="alert('XSS')">Hover me</button>`; | ||
expect(escapeHtmlString(input)).toBe(expected); | ||
}); | ||
|
||
it('escapes an inline JavaScript attempt', () => { | ||
const input = `<img src="javascript:alert('XSS')" />`; | ||
const expected = `<img src="javascript:alert('XSS')" />`; | ||
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 = `<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 = `<div id="test" onclick="document.location='http://evil.com'">Click me</div>`; | ||
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 = `<div style="background-image: url('javascript:alert(1)')">Content</div>`; | ||
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 = `<script>alert('XSS')</script><img src="javascript:alert('XSS')" onerror="alert('XSS')"><div style="background-image: url('javascript:alert(1)')"></div>`; | ||
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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="<test>" content=""quoted""/>']); | ||
}); | ||
|
||
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="<script>alert("XSS")</script>"/>' | ||
]); | ||
}); | ||
|
||
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=""; 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("XSS")"/>']); | ||
}); | ||
|
||
it('sanitizes special characters in tag attributes', () => { | ||
const result = headTagsToEscapedStrings([ | ||
{ tag: 'meta', attributes: { name: '<meta>', content: 'content&value"special\'' } } | ||
]); | ||
expect(result).toEqual([ | ||
'<meta name="<meta>" content="content&value"special'"/>' | ||
]); | ||
}); | ||
|
||
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=""></meta><script>alert(1)</script>"/>' | ||
]); | ||
}); | ||
|
||
it('ignores attribute keys with malicious patterns', () => { | ||
const result = headTagsToEscapedStrings([ | ||
{ tag: 'meta', attributes: { '"><script>alert(1)</script>': 'malicious' } } | ||
]); | ||
expect(result).toEqual(['<meta "><script>alert(1)</script>="malicious"/>']); | ||
}); | ||
|
||
it('prevents double-escaping of HTML entities', () => { | ||
const result = headTagsToEscapedStrings([ | ||
{ tag: 'meta', attributes: { content: '<already-escaped>' } } | ||
]); | ||
expect(result).toEqual(['<meta content="<already-escaped>"/>']); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
'&' | ||
) | ||
.replace(/</g, '<') | ||
.replace(/>/g, '>') | ||
.replace(/"/g, '"') | ||
.replace(/'/g, '''); | ||
return escaped; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |