From 4e167b02d45af971b9929762b17792ba22f19e7c Mon Sep 17 00:00:00 2001 From: Nguyen Dac Duong Date: Tue, 2 Jun 2026 11:01:23 +0700 Subject: [PATCH 1/2] build: bump base node:18-alpine3.17 -> node:22-alpine (fixes 12C/123H CVEs from EOL base) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 82c5c39..39dae90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine3.17 +FROM node:22-alpine ENV NODE_ENV production From 3a93a848d576b9fd10265c692d2479b2039f1f40 Mon Sep 17 00:00:00 2001 From: Nguyen Dac Duong Date: Tue, 2 Jun 2026 11:11:13 +0700 Subject: [PATCH 2/2] security: fix CRITICAL/HIGH/MEDIUM findings from code review - JSON-parse guard before new Function: JSON configs bypass RCE path (F1 mitigation) - HTML-escape msg in failSvg SVG body (F2, XSS in error output) - Add return after db.get err + fix ReferenceError outputFormat->fmt (F3, crash+hang) - Explicit limit on express.urlencoded (F4, DoS hardening) --- index.js | 18 +++++++++--------- lib/charts.js | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/index.js b/index.js index cf6f695..1aa5c5c 100644 --- a/index.js +++ b/index.js @@ -37,7 +37,7 @@ app.use( }), ); -app.use(express.urlencoded()); +app.use(express.urlencoded({ limit: process.env.EXPRESS_JSON_LIMIT || '100kb', extended: false })); if (process.env.RATE_LIMIT_PER_MIN) { const limitMax = parseInt(process.env.RATE_LIMIT_PER_MIN, 10); @@ -122,7 +122,7 @@ function failSvg(res, msg, statusCode = 500) { -

${msg}

+

${String(msg).replace(/&/g,'&').replace(//g,'>')}

`); } @@ -538,24 +538,24 @@ app.get('/chart/render/:key', async (req, res) => { db.get('SELECT config FROM charts WHERE id = ?', [key], function(err, row) { if (err) { - res.status(500).json({ error: err.message }); + return res.status(500).json({ error: err.message }); // add return — else falls through to row check with null row } if (!row) { return res.status(404).json({ error: 'Template not found' }); } - //return res.status(200).json({status: 'success'}); let chartConfig = JSON.parse(row.config); chartConfig = applyTemplateOverrides(chartConfig, req.query); - if (chartConfig.format === 'pdf') { + const fmt = chartConfig.format; + if (fmt === 'pdf') { renderChartToPdf(req, res, chartConfig); - } else if (chartConfig.format === 'svg') { + } else if (fmt === 'svg') { renderChartToSvg(req, res, chartConfig); - } else if (!chartConfig.format || chartConfig.format === 'png') { + } else if (!fmt || fmt === 'png') { renderChartToPng(req, res, chartConfig); } else { - logger.error(`Request for unsupported format ${outputFormat}`); - res.status(500).end(`Unsupported format ${outputFormat}`); + logger.error(`Request for unsupported format ${fmt}`); // was: outputFormat (ReferenceError) + res.status(500).end(`Unsupported format ${fmt}`); } telemetry.count('chartCount'); diff --git a/lib/charts.js b/lib/charts.js index 367fc7d..fab0794 100644 --- a/lib/charts.js +++ b/lib/charts.js @@ -129,6 +129,22 @@ async function renderChartJs( untrustedChart, ) { let chart; + if (typeof untrustedChart === 'string') { + // Try to parse as strict JSON first — if it succeeds, treat as a safe + // object so we never reach new Function(). This is the common case for + // internal callers that always send JSON chart configs (not JS functions). + try { + const parsed = JSON.parse(untrustedChart); + if (parsed && typeof parsed === 'object') { + untrustedChart = parsed; + } + } catch (_) { + // Not valid JSON — fall through to the new Function path below. + // Log so operators can see if unexpected JS function configs arrive. + logger.warn('chart input is not valid JSON; running via new Function (JS function config)'); + } + } + if (typeof untrustedChart === 'string') { // The chart could contain Javascript - run it in a VM. try {