From f81d2a2ed3801e4a30de53cc9e44822be3c86132 Mon Sep 17 00:00:00 2001 From: "Hermes (Tetrax)" <10426516+Tetrax@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:06:57 +0000 Subject: [PATCH 1/2] fix(security): rate-limit costly routes --- app/web/server.js | 10 ++++-- app/web/test/rate-limit.test.js | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/app/web/server.js b/app/web/server.js index 94e2f1c..d7dc2c8 100644 --- a/app/web/server.js +++ b/app/web/server.js @@ -186,8 +186,14 @@ function createRateLimiter(maxRequests = 20, windowMs = 60000) { } const sessionLimiter = createRateLimiter(20, 60000); -app.use('/api/upload', sessionLimiter); -app.use('/api/admin', sessionLimiter); +app.use([ + '/api/upload', + '/api/admin', + '/api/import/workspace', + '/api/import/policies-xlsx', + '/api/deploy/config-upload', + '/api/deploy/dynamic-routes', +], sessionLimiter); app.use(express.static(path.join(__dirname, 'public'), { setHeaders(res, filePath) { diff --git a/app/web/test/rate-limit.test.js b/app/web/test/rate-limit.test.js index 4562dae..f3dfea9 100644 --- a/app/web/test/rate-limit.test.js +++ b/app/web/test/rate-limit.test.js @@ -23,6 +23,7 @@ function freePort() { function request(port, options = {}) { return new Promise((resolve, reject) => { + const body = options.body; const req = http.request({ hostname: '127.0.0.1', port, @@ -35,6 +36,7 @@ function request(port, options = {}) { response.once('end', () => resolve(response.statusCode)); }); req.once('error', reject); + if (body !== undefined) req.write(body); req.end(); }); } @@ -179,3 +181,65 @@ test('respecte la limite upload configurée sans créer de fichier géant', asyn await stop(state); } }); + +function multipartWithoutFile(pathname) { + const boundary = '----FortiFlowNoFileBoundary'; + const body = Buffer.from(`--${boundary}--\r\n`); + return { + method: 'POST', + path: pathname, + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + 'content-length': body.length, + }, + body, + }; +} + +const costlyRouteCases = [ + { + name: 'import workspace avant le parsing raw', + request: port => request(port, { + method: 'POST', + path: '/api/import/workspace', + headers: { 'content-type': 'application/octet-stream', 'content-length': '1' }, + body: Buffer.from('{'), + }), + downstreamStatus: 400, + }, + { + name: 'import policies-xlsx avant Multer', + request: port => request(port, multipartWithoutFile('/api/import/policies-xlsx')), + downstreamStatus: 400, + }, + { + name: 'deploy config-upload avant Multer', + request: port => request(port, multipartWithoutFile('/api/deploy/config-upload')), + downstreamStatus: 400, + }, + { + name: 'deploy dynamic-routes avant express.json', + request: port => request(port, { + method: 'POST', + path: '/api/deploy/dynamic-routes', + headers: { 'content-type': 'application/json', 'content-length': '1' }, + body: Buffer.from('{'), + }), + downstreamStatus: 400, + }, +]; + +for (const routeCase of costlyRouteCases) { + test(`limite ${routeCase.name}`, async () => { + const state = await startServer(); + try { + await waitForReady(state); + const statuses = []; + for (let i = 0; i < 21; i++) statuses.push(await routeCase.request(state.port)); + assert.deepEqual(statuses.slice(0, 20), Array(20).fill(routeCase.downstreamStatus)); + assert.equal(statuses[20], 429); + } finally { + await stop(state); + } + }); +} From ddc74e27da489ff3dcfc507ff65367c81c952c12 Mon Sep 17 00:00:00 2001 From: "Hermes (Tetrax)" <10426516+Tetrax@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:07:04 +0000 Subject: [PATCH 2/2] ci(security): pin Trivy action by SHA --- .github/workflows/security-tests.yml | 4 ++-- app/web/test/security-workflow.test.js | 30 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 app/web/test/security-workflow.test.js diff --git a/.github/workflows/security-tests.yml b/.github/workflows/security-tests.yml index dc379b8..2372e11 100644 --- a/.github/workflows/security-tests.yml +++ b/.github/workflows/security-tests.yml @@ -77,7 +77,7 @@ jobs: - name: Build image for scanning run: docker compose build - name: Trivy gate for fixable CRITICAL vulnerabilities - uses: aquasecurity/trivy-action@v0.36.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: fortiflow-fortiflow:latest format: table @@ -85,7 +85,7 @@ jobs: ignore-unfixed: true severity: CRITICAL - name: Trivy report for HIGH vulnerabilities (informational) - uses: aquasecurity/trivy-action@v0.36.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 continue-on-error: true with: image-ref: fortiflow-fortiflow:latest diff --git a/app/web/test/security-workflow.test.js b/app/web/test/security-workflow.test.js new file mode 100644 index 0000000..53e8b76 --- /dev/null +++ b/app/web/test/security-workflow.test.js @@ -0,0 +1,30 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const WORKFLOW = path.resolve(__dirname, '../../../.github/workflows/security-tests.yml'); +const TRIVY_SHA = 'ed142fd0673e97e23eac54620cfb913e5ce36c25'; + +test( + 'toutes les occurrences Trivy du workflow sont épinglées au SHA vérifié', + { + skip: !fs.existsSync(WORKFLOW) + ? '.github/workflows/security-tests.yml is outside the application image' + : false, + }, + () => { + const workflow = fs.readFileSync(WORKFLOW, 'utf8'); + const occurrences = [...workflow.matchAll( + /^\s*uses:\s*aquasecurity\/trivy-action@([^\s#]+)(?:\s+#\s*(v[^\s]+))?\s*$/gm, + )]; + + assert.equal(occurrences.length, 2, 'le workflow doit conserver ses deux contrôles Trivy'); + for (const [, ref, comment] of occurrences) { + assert.equal(ref, TRIVY_SHA); + assert.equal(comment, 'v0.36.0'); + } + }, +);