From 851601e85eec34fd245692f89f226e8a31aeea27 Mon Sep 17 00:00:00 2001 From: Sumit-5002 <173078713+Sumit-5002@users.noreply.github.com> Date: Fri, 27 Mar 2026 06:28:25 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20Harden?= =?UTF-8?q?=20image=20processing=20routes=20against=20SSRF=20and=20injecti?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change improves the security of the image processing API by: - Mitigating SSRF in `/html-to-image` by disabling automatic redirects and manually rejecting 3xx responses. - Ensuring IPv6 addresses are correctly formatted in target URLs for SSRF protection. - Implementing a strict whitelist for allowed image formats (`ALLOWED_IMAGE_FORMATS`) in resize, compress, convert, and crop operations. - Sanitizing error responses to prevent internal library details from leaking to clients. --- server/routes/image.js | 43 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/server/routes/image.js b/server/routes/image.js index 70ca4a6..5d6676f 100644 --- a/server/routes/image.js +++ b/server/routes/image.js @@ -5,6 +5,8 @@ import dns from 'dns'; const router = express.Router(); +const ALLOWED_IMAGE_FORMATS = new Set(['jpeg', 'jpg', 'png', 'webp', 'gif', 'avif', 'tiff']); + const isInternalIP = (ip) => { if (!ip) return false; // Normalize IPv4-mapped IPv6 addresses (e.g., ::ffff:127.0.0.1) @@ -59,6 +61,11 @@ router.post('/resize', async (req, res) => { const { width, height, format = 'jpeg' } = req.body; + if (!ALLOWED_IMAGE_FORMATS.has(format)) { + await fs.unlink(req.file.path); + return res.status(400).json({ error: 'Unsupported image format' }); + } + const w = width ? parseInt(width) : null; const h = height ? parseInt(height) : null; @@ -85,7 +92,7 @@ router.post('/resize', async (req, res) => { res.send(buffer); } catch (error) { console.error('Image resize error:', error); - res.status(500).json({ error: 'Failed to resize image', message: error.message }); + res.status(500).json({ error: 'Failed to resize image' }); } finally { await fs.unlink(req.file.path).catch(err => { if (err.code !== 'ENOENT') console.error('Failed to unlink file:', err.message); @@ -114,6 +121,12 @@ router.post('/compress', async (req, res) => { } const { quality = 80, format = 'jpeg' } = req.body; + + if (!ALLOWED_IMAGE_FORMATS.has(format)) { + await fs.unlink(req.file.path); + return res.status(400).json({ error: 'Unsupported image format' }); + } + const q = parseInt(quality); if (isNaN(q) || q < 1 || q > 100) { @@ -131,7 +144,7 @@ router.post('/compress', async (req, res) => { res.send(buffer); } catch (error) { console.error('Image compress error:', error); - res.status(500).json({ error: 'Failed to compress image', message: error.message }); + res.status(500).json({ error: 'Failed to compress image' }); } finally { await fs.unlink(req.file.path).catch(err => { if (err.code !== 'ENOENT') console.error('Failed to unlink file:', err.message); @@ -160,6 +173,12 @@ router.post('/convert', async (req, res) => { } const { format = 'png', quality } = req.body; + + if (!ALLOWED_IMAGE_FORMATS.has(format)) { + await fs.unlink(req.file.path); + return res.status(400).json({ error: 'Unsupported image format' }); + } + const q = quality ? parseInt(quality) : null; if (q !== null && (isNaN(q) || q < 1 || q > 100)) { @@ -178,7 +197,7 @@ router.post('/convert', async (req, res) => { res.send(buffer); } catch (error) { console.error('Image convert error:', error); - res.status(500).json({ error: 'Failed to convert image', message: error.message }); + res.status(500).json({ error: 'Failed to convert image' }); } finally { await fs.unlink(req.file.path).catch(err => { if (err.code !== 'ENOENT') console.error('Failed to unlink file:', err.message); @@ -208,6 +227,11 @@ router.post('/crop', async (req, res) => { const { left, top, width, height, format = 'jpeg' } = req.body; + if (!ALLOWED_IMAGE_FORMATS.has(format)) { + await fs.unlink(req.file.path); + return res.status(400).json({ error: 'Unsupported image format' }); + } + const l = parseInt(left); const t = parseInt(top); const w = parseInt(width); @@ -234,7 +258,7 @@ router.post('/crop', async (req, res) => { res.send(buffer); } catch (error) { console.error('Image crop error:', error); - res.status(500).json({ error: 'Failed to crop image', message: error.message }); + res.status(500).json({ error: 'Failed to crop image' }); } finally { await fs.unlink(req.file.path).catch(err => { if (err.code !== 'ENOENT') console.error('Failed to unlink file:', err.message); @@ -281,15 +305,22 @@ router.post('/html-to-image', async (req, res) => { } // Mitigate DNS rebinding: Fetch using the resolved IP and a Host header - const targetUrl = `${parsed.protocol}//${resolvedIP}${parsed.pathname}${parsed.search}`; + // Wrap IPv6 addresses in square brackets + const ipPart = resolvedIP.includes(':') ? `[${resolvedIP}]` : resolvedIP; + const targetUrl = `${parsed.protocol}//${ipPart}${parsed.pathname}${parsed.search}`; + const response = await fetch(targetUrl, { - redirect: 'follow', + redirect: 'manual', headers: { 'User-Agent': 'AnyFileForge-Server/1.0 (+html-to-image)', 'Host': parsed.hostname } }); + if (response.status >= 300 && response.status < 400) { + return res.status(403).json({ error: 'Redirects are forbidden for security reasons.' }); + } + if (!response.ok) { return res.status(502).json({ error: `Unable to fetch URL (${response.status}).` }); } From f96ae47bd539b0dcb978fd12a3676179c8ef2edf Mon Sep 17 00:00:00 2001 From: Sumit-5002 <173078713+Sumit-5002@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:09:00 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20Refine?= =?UTF-8?q?=20image=20processing=20security=20and=20address=20PR=20feedbac?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses the following: - Updated `fs.unlink` cleanup in validation paths to be best-effort (`.catch(() => {})`) to avoid blocking error responses. - Fixed SSRF mitigation in `html-to-image` to correctly handle custom ports and use the full `Host` header (hostname + port). - Normalized 'jpg' format to 'jpeg' for response headers and filenames for better standard compliance. - Removed sensitive error messages from 500 responses across all image processing endpoints. --- server/routes/image.js | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/server/routes/image.js b/server/routes/image.js index 5d6676f..ce370e4 100644 --- a/server/routes/image.js +++ b/server/routes/image.js @@ -59,19 +59,21 @@ router.post('/resize', async (req, res) => { return res.status(400).json({ error: 'Image file is required' }); } - const { width, height, format = 'jpeg' } = req.body; + let { width, height, format = 'jpeg' } = req.body; if (!ALLOWED_IMAGE_FORMATS.has(format)) { - await fs.unlink(req.file.path); + await fs.unlink(req.file.path).catch(() => {}); return res.status(400).json({ error: 'Unsupported image format' }); } + if (format === 'jpg') format = 'jpeg'; + const w = width ? parseInt(width) : null; const h = height ? parseInt(height) : null; if ((w !== null && (isNaN(w) || w <= 0 || w > 10000)) || (h !== null && (isNaN(h) || h <= 0 || h > 10000))) { - await fs.unlink(req.file.path); + await fs.unlink(req.file.path).catch(() => {}); return res.status(400).json({ error: 'Invalid dimensions. Width and height must be between 1 and 10000.' }); } @@ -120,17 +122,19 @@ router.post('/compress', async (req, res) => { return res.status(400).json({ error: 'Image file is required' }); } - const { quality = 80, format = 'jpeg' } = req.body; + let { quality = 80, format = 'jpeg' } = req.body; if (!ALLOWED_IMAGE_FORMATS.has(format)) { - await fs.unlink(req.file.path); + await fs.unlink(req.file.path).catch(() => {}); return res.status(400).json({ error: 'Unsupported image format' }); } + if (format === 'jpg') format = 'jpeg'; + const q = parseInt(quality); if (isNaN(q) || q < 1 || q > 100) { - await fs.unlink(req.file.path); + await fs.unlink(req.file.path).catch(() => {}); return res.status(400).json({ error: 'Invalid quality. Must be between 1 and 100.' }); } @@ -172,17 +176,19 @@ router.post('/convert', async (req, res) => { return res.status(400).json({ error: 'Image file is required' }); } - const { format = 'png', quality } = req.body; + let { format = 'png', quality } = req.body; if (!ALLOWED_IMAGE_FORMATS.has(format)) { - await fs.unlink(req.file.path); + await fs.unlink(req.file.path).catch(() => {}); return res.status(400).json({ error: 'Unsupported image format' }); } + if (format === 'jpg') format = 'jpeg'; + const q = quality ? parseInt(quality) : null; if (q !== null && (isNaN(q) || q < 1 || q > 100)) { - await fs.unlink(req.file.path); + await fs.unlink(req.file.path).catch(() => {}); return res.status(400).json({ error: 'Invalid quality. Must be between 1 and 100.' }); } @@ -225,20 +231,22 @@ router.post('/crop', async (req, res) => { return res.status(400).json({ error: 'Image file is required' }); } - const { left, top, width, height, format = 'jpeg' } = req.body; + let { left, top, width, height, format = 'jpeg' } = req.body; if (!ALLOWED_IMAGE_FORMATS.has(format)) { - await fs.unlink(req.file.path); + await fs.unlink(req.file.path).catch(() => {}); return res.status(400).json({ error: 'Unsupported image format' }); } + if (format === 'jpg') format = 'jpeg'; + const l = parseInt(left); const t = parseInt(top); const w = parseInt(width); const h = parseInt(height); if (isNaN(l) || isNaN(t) || isNaN(w) || isNaN(h) || l < 0 || t < 0 || w <= 0 || h <= 0 || w > 10000 || h > 10000) { - await fs.unlink(req.file.path); + await fs.unlink(req.file.path).catch(() => {}); return res.status(400).json({ error: 'Invalid crop parameters.' }); } @@ -307,13 +315,14 @@ router.post('/html-to-image', async (req, res) => { // Mitigate DNS rebinding: Fetch using the resolved IP and a Host header // Wrap IPv6 addresses in square brackets const ipPart = resolvedIP.includes(':') ? `[${resolvedIP}]` : resolvedIP; - const targetUrl = `${parsed.protocol}//${ipPart}${parsed.pathname}${parsed.search}`; + const portPart = parsed.port ? `:${parsed.port}` : ''; + const targetUrl = `${parsed.protocol}//${ipPart}${portPart}${parsed.pathname}${parsed.search}`; const response = await fetch(targetUrl, { redirect: 'manual', headers: { 'User-Agent': 'AnyFileForge-Server/1.0 (+html-to-image)', - 'Host': parsed.hostname + 'Host': parsed.host } });