feat: harden SSRF protection with comprehensive security improvements
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m4s

- Block IPv4-mapped IPv6 addresses (::ffff:127.0.0.1, etc.)
- Block IPv6 unspecified address (::)
- Add CSS injection sanitization for hideSelectors (no {}<>;)
- Add waitForSelector validation (max 200 chars, no javascript:/script)
- Add CSS parameter hardening (block @import, url() with non-data: schemes)
- Add 21 new security tests following TDD approach
- All 387 tests passing

Fixes potential SSRF bypasses and CSS injection vulnerabilities
This commit is contained in:
SnapAPI Security Hardening 2026-03-05 09:04:59 +01:00
parent 0999474fbd
commit ba888bb580
4 changed files with 236 additions and 0 deletions

View file

@ -282,6 +282,116 @@ describe('Screenshot Service', () => {
}) })
}) })
describe('CSS Injection Prevention', () => {
describe('hideSelectors sanitization', () => {
it('should reject hideSelectors containing curly braces', async () => {
await expect(takeScreenshot({
url: 'https://example.com',
hideSelectors: ['.safe', 'body { background: red; }', '.another']
})).rejects.toThrow('hideSelector contains dangerous characters')
})
it('should reject hideSelectors containing angle brackets', async () => {
await expect(takeScreenshot({
url: 'https://example.com',
hideSelectors: ['<script>alert(1)</script>']
})).rejects.toThrow('hideSelector contains dangerous characters')
})
it('should reject hideSelectors containing semicolon', async () => {
await expect(takeScreenshot({
url: 'https://example.com',
hideSelectors: ['body; background: red']
})).rejects.toThrow('hideSelector contains dangerous characters')
})
it('should accept safe hideSelectors', async () => {
await takeScreenshot({
url: 'https://example.com',
hideSelectors: ['.ad', '#banner', 'div.popup', 'nav ul li']
})
expect(mockPage.addStyleTag).toHaveBeenCalledWith({
content: '.ad { display: none !important }\n#banner { display: none !important }\ndiv.popup { display: none !important }\nnav ul li { display: none !important }'
})
})
})
describe('waitForSelector sanitization', () => {
it('should reject waitForSelector longer than 200 characters', async () => {
const longSelector = 'div'.repeat(100) // 300 chars
await expect(takeScreenshot({
url: 'https://example.com',
waitForSelector: longSelector
})).rejects.toThrow('waitForSelector is too long')
})
it('should reject waitForSelector containing javascript:', async () => {
await expect(takeScreenshot({
url: 'https://example.com',
waitForSelector: 'javascript:alert(1)'
})).rejects.toThrow('waitForSelector contains dangerous content')
})
it('should reject waitForSelector containing script tag', async () => {
await expect(takeScreenshot({
url: 'https://example.com',
waitForSelector: '<script>alert(1)</script>'
})).rejects.toThrow('waitForSelector contains dangerous content')
})
it('should accept safe waitForSelector', async () => {
await takeScreenshot({
url: 'https://example.com',
waitForSelector: '#main-content'
})
expect(mockPage.waitForSelector).toHaveBeenCalledWith('#main-content', { timeout: 10_000 })
})
})
describe('CSS parameter hardening', () => {
it('should reject CSS containing @import', async () => {
await expect(takeScreenshot({
url: 'https://example.com',
css: 'body { color: red; } @import url(http://evil.com/steal.css);'
})).rejects.toThrow('CSS contains dangerous directives')
})
it('should reject CSS containing url() with http scheme', async () => {
await expect(takeScreenshot({
url: 'https://example.com',
css: 'body { background: url(http://evil.com/image.png); }'
})).rejects.toThrow('CSS contains dangerous directives')
})
it('should reject CSS containing url() with https scheme', async () => {
await expect(takeScreenshot({
url: 'https://example.com',
css: 'body { background: url(https://evil.com/image.png); }'
})).rejects.toThrow('CSS contains dangerous directives')
})
it('should allow CSS with data: URLs', async () => {
await takeScreenshot({
url: 'https://example.com',
css: 'body { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==); }'
})
expect(mockPage.addStyleTag).toHaveBeenCalledWith({
content: 'body { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==); }'
})
})
it('should allow safe CSS without external references', async () => {
await takeScreenshot({
url: 'https://example.com',
css: 'body { color: red; margin: 0; padding: 10px; }'
})
expect(mockPage.addStyleTag).toHaveBeenCalledWith({
content: 'body { color: red; margin: 0; padding: 10px; }'
})
})
})
})
describe('Page lifecycle', () => { describe('Page lifecycle', () => {
it('always releases page after successful screenshot', async () => { it('always releases page after successful screenshot', async () => {
await takeScreenshot({ url: 'https://example.com' }) await takeScreenshot({ url: 'https://example.com' })

View file

@ -210,6 +210,71 @@ describe('SSRF Validation', () => {
}) })
}) })
describe('IPv4-mapped IPv6 addresses', () => {
it('should block ::ffff:127.0.0.1 (IPv4-mapped loopback)', async () => {
mockLookup.mockResolvedValueOnce({ address: '::ffff:127.0.0.1', family: 6 })
await expect(validateUrl('http://ipv6-mapped.evil.com')).rejects.toThrow(
'URL resolves to a blocked IP range'
)
})
it('should block ::ffff:10.0.0.1 (IPv4-mapped private)', async () => {
mockLookup.mockResolvedValueOnce({ address: '::ffff:10.0.0.1', family: 6 })
await expect(validateUrl('http://ipv6-private.evil.com')).rejects.toThrow(
'URL resolves to a blocked IP range'
)
})
it('should block ::ffff:192.168.1.1 (IPv4-mapped private)', async () => {
mockLookup.mockResolvedValueOnce({ address: '::ffff:192.168.1.1', family: 6 })
await expect(validateUrl('http://ipv6-home.evil.com')).rejects.toThrow(
'URL resolves to a blocked IP range'
)
})
it('should block ::ffff:172.16.0.1 (IPv4-mapped private)', async () => {
mockLookup.mockResolvedValueOnce({ address: '::ffff:172.16.0.1', family: 6 })
await expect(validateUrl('http://ipv6-corp.evil.com')).rejects.toThrow(
'URL resolves to a blocked IP range'
)
})
it('should block ::ffff:169.254.169.254 (IPv4-mapped metadata)', async () => {
mockLookup.mockResolvedValueOnce({ address: '::ffff:169.254.169.254', family: 6 })
await expect(validateUrl('http://ipv6-metadata.evil.com')).rejects.toThrow(
'URL resolves to a blocked IP range'
)
})
it('should block ::ffff:0:127.0.0.1 (alternative notation)', async () => {
mockLookup.mockResolvedValueOnce({ address: '::ffff:0:127.0.0.1', family: 6 })
await expect(validateUrl('http://ipv6-alt.evil.com')).rejects.toThrow(
'URL resolves to a blocked IP range'
)
})
it('should block :: (IPv6 unspecified)', async () => {
mockLookup.mockResolvedValueOnce({ address: '::', family: 6 })
await expect(validateUrl('http://ipv6-unspecified.evil.com')).rejects.toThrow(
'URL resolves to a blocked IP range'
)
})
it('should allow legitimate IPv6 addresses', async () => {
mockLookup.mockResolvedValueOnce({ address: '2606:4700:4700::1111', family: 6 })
const result = await validateUrl('http://ipv6.cloudflare.com')
expect(result.resolvedIp).toBe('2606:4700:4700::1111')
})
})
describe('Edge cases', () => { describe('Edge cases', () => {
it('should handle URLs with ports', async () => { it('should handle URLs with ports', async () => {
mockLookup.mockReset() mockLookup.mockReset()

View file

@ -28,10 +28,55 @@ const MAX_WIDTH = 3840;
const MAX_HEIGHT = 2160; const MAX_HEIGHT = 2160;
const TIMEOUT_MS = 30_000; const TIMEOUT_MS = 30_000;
// CSS injection prevention
function validateHideSelectors(selectors: string[]): void {
const dangerousChars = /[{}<>;]/;
for (const selector of selectors) {
if (dangerousChars.test(selector)) {
throw new Error("hideSelector contains dangerous characters");
}
}
}
function validateWaitForSelector(selector: string): void {
if (selector.length > 200) {
throw new Error("waitForSelector is too long");
}
if (selector.includes("javascript:") || selector.includes("<script")) {
throw new Error("waitForSelector contains dangerous content");
}
}
function validateCSS(css: string): void {
// Check for @import
if (/@import\s+/i.test(css)) {
throw new Error("CSS contains dangerous directives");
}
// Check for url() with non-data: schemes
const urlPattern = /url\s*\(\s*(?!data:)[^)]*\)/gi;
if (urlPattern.test(css)) {
throw new Error("CSS contains dangerous directives");
}
}
export async function takeScreenshot(opts: ScreenshotOptions): Promise<ScreenshotResult> { export async function takeScreenshot(opts: ScreenshotOptions): Promise<ScreenshotResult> {
// Validate URL for SSRF // Validate URL for SSRF
await validateUrl(opts.url); await validateUrl(opts.url);
// Validate CSS injection prevention
if (opts.hideSelectors && opts.hideSelectors.length > 0) {
validateHideSelectors(opts.hideSelectors);
}
if (opts.waitForSelector) {
validateWaitForSelector(opts.waitForSelector);
}
if (opts.css && opts.css.trim()) {
validateCSS(opts.css);
}
const format = opts.format || "png"; const format = opts.format || "png";
const width = Math.min(opts.width || 1280, MAX_WIDTH); const width = Math.min(opts.width || 1280, MAX_WIDTH);
const height = Math.min(opts.height || 800, MAX_HEIGHT); const height = Math.min(opts.height || 800, MAX_HEIGHT);

View file

@ -13,6 +13,22 @@ const BLOCKED_RANGES = [
/^fe80:/i, /^fe80:/i,
/^fc00:/i, /^fc00:/i,
/^fd00:/i, /^fd00:/i,
// IPv6 unspecified
/^::$/,
// IPv4-mapped IPv6 addresses - block dangerous IPv4 ranges mapped to IPv6
/^::ffff:127\./i, // loopback
/^::ffff:10\./i, // private 10.x.x.x
/^::ffff:172\.(1[6-9]|2[0-9]|3[01])\./i, // private 172.16-31.x.x
/^::ffff:192\.168\./i, // private 192.168.x.x
/^::ffff:169\.254\./i, // link-local/metadata
/^::ffff:0\./i, // zero network
// Alternative IPv4-mapped notation
/^::ffff:0:127\./i, // ::ffff:0:127.x.x.x
/^::ffff:0:10\./i, // ::ffff:0:10.x.x.x
/^::ffff:0:172\.(1[6-9]|2[0-9]|3[01])\./i,
/^::ffff:0:192\.168\./i,
/^::ffff:0:169\.254\./i,
/^::ffff:0:0\./i,
]; ];
const BLOCKED_HOSTS = [ const BLOCKED_HOSTS = [