From 14d22f1867227cb383c4efcb90052448bd1e90ff Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Tue, 14 Jul 2026 07:20:10 -0700 Subject: [PATCH] Rate-limit static client routes Protect the CSRF token and SPA file fallback with the established request limit. --- server/index.js | 17 ++++----- server/middlewares/clientAssets.js | 19 +++++++++ server/middlewares/clientAssets.test.js | 51 +++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 server/middlewares/clientAssets.js create mode 100644 server/middlewares/clientAssets.test.js diff --git a/server/index.js b/server/index.js index 6820216..9976667 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,7 @@ const cors = require('cors'); const morgan = require('morgan'); const passport = require('passport'); const lusca = require('lusca'); +const rateLimit = require('express-rate-limit'); const config = require('./runtimeConfig'); const { connect } = require('./database'); @@ -12,6 +13,8 @@ const { createHealthHandler, createHealthReporter } = require('./health'); const { initializePostgres, pool, startPostgresMaintenance } = require('./postgres'); const session = require('./middlewares/session'); const { createCorsOptions } = require('./middlewares/cors'); +const { createClientAssetsRouter } = require('./middlewares/clientAssets'); +const { apiRateLimitOptions } = require('./middlewares/apiRateLimit'); const logger = require('./logger'); const auth = require('./auth'); const api = require('./api'); @@ -76,13 +79,9 @@ const init = async () => { app.use(cors(createCorsOptions(config.cors.origin))); - app.get('/api/csrf-token', (req, res) => res.json({ csrfToken: req.csrfToken() })); - - app.use(express.static(path.join(__dirname, '../client/build'))); - - app.get('/', (req, res) => { - res.sendFile(path.join(__dirname, '../client/build/index.html')); - }); + app.get('/api/csrf-token', rateLimit(apiRateLimitOptions()), (req, res) => ( + res.json({ csrfToken: req.csrfToken() }) + )); app.use('/auth', auth(app, passport)); app.use('/api', api(app, passport)); @@ -98,9 +97,7 @@ const init = async () => { res.sendFile(announcementsPath); }); - app.use('/*', (req, res) => { - res.sendFile(path.join(__dirname, '../client/build/index.html')); - }); + app.use(createClientAssetsRouter(path.join(__dirname, '../client/build'))); app.listen(config.server.port, '0.0.0.0', (err) => { if (err) { diff --git a/server/middlewares/clientAssets.js b/server/middlewares/clientAssets.js new file mode 100644 index 0000000..ceb2ab2 --- /dev/null +++ b/server/middlewares/clientAssets.js @@ -0,0 +1,19 @@ +const express = require('express'); +const path = require('path'); +const rateLimit = require('express-rate-limit'); +const { apiRateLimitOptions } = require('./apiRateLimit'); + +const createClientAssetsRouter = (buildPath, rateLimitOptions = apiRateLimitOptions()) => { + const router = express.Router(); + + router.use(rateLimit(rateLimitOptions)); + router.use(express.static(buildPath)); + const indexPath = path.join(buildPath, 'index.html'); + + router.get('/', (req, res) => res.sendFile(indexPath)); + router.use((req, res) => res.sendFile(indexPath)); + + return router; +}; + +module.exports = { createClientAssetsRouter }; diff --git a/server/middlewares/clientAssets.test.js b/server/middlewares/clientAssets.test.js new file mode 100644 index 0000000..4987eb0 --- /dev/null +++ b/server/middlewares/clientAssets.test.js @@ -0,0 +1,51 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const http = require('http'); +const path = require('path'); +const express = require('express'); +const { createClientAssetsRouter } = require('./clientAssets'); +const { apiRateLimitOptions } = require('./apiRateLimit'); + +const request = (server) => new Promise((resolve, reject) => { + const req = http.request({ + host: '127.0.0.1', + method: 'GET', + path: '/missing-client-route', + port: server.address().port, + }, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => resolve({ body, status: res.statusCode })); + }); + req.on('error', reject); + req.end(); +}); + +describe('client asset rate limiting', () => { + let server; + + beforeEach((done) => { + const app = express(); + app.use(createClientAssetsRouter( + path.join(__dirname, '../../client'), + apiRateLimitOptions({ limit: 1, windowMs: 60_000 }), + )); + server = app.listen(0, '127.0.0.1', () => done()); + }); + + afterEach((done) => { + server.close(() => done()); + }); + + it('serves the client fallback', async () => { + const response = await request(server); + + expect(response.status).toBe(200); + expect(response.body).toContain(''); + expect(await request(server)).toEqual({ + body: '{"code":"rate_limited","message":"Too many requests. Please try again later."}', + status: 429, + }); + }); +});