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("