feat: harden SSRF protection with comprehensive security improvements
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m4s
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:
parent
0999474fbd
commit
ba888bb580
4 changed files with 236 additions and 0 deletions
|
|
@ -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' })
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue