From f68c94a18ecfe54a1914a050e41eb63eb67e6efd Mon Sep 17 00:00:00 2001 From: giteaadmin Date: Wed, 27 Aug 2025 16:03:27 -0400 Subject: [PATCH] fix: express api security --- package-lock.json | 26 ++++++++++++++++++++++++ package.json | 1 + src/server.js | 50 +++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 930f512..bc85f44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", "cors": "^2.8.5", "express": "^4.21.2", + "express-rate-limit": "^8.0.1", "fs-extra": "^11.3.1", "luxon": "^3.7.1" }, @@ -948,6 +949,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.0.1.tgz", + "integrity": "sha512-aZVCnybn7TVmxO4BtlmnvX+nuz8qHW124KKJ8dumsBsmv5ZLxE0pYu7S2nwyRBGHHCAzdmnGyrc5U/rksSPO7Q==", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1401,6 +1419,14 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index a916a30..b12cea9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2", "cors": "^2.8.5", "express": "^4.21.2", + "express-rate-limit": "^8.0.1", "fs-extra": "^11.3.1", "luxon": "^3.7.1" }, diff --git a/src/server.js b/src/server.js index 3355a00..5b60883 100644 --- a/src/server.js +++ b/src/server.js @@ -2,11 +2,37 @@ const express = require('express'); const cors = require('cors'); const fs = require('fs-extra'); const path = require('path'); +const rateLimit = require('express-rate-limit'); const app = express(); const port = 3000; -app.set('trust proxy', true); +// trust proxy: +// Used by express-rate-limit to obtain the client's IP address. +// '1' means that the first hop (your reverse proxy) is trusted. +app.set('trust proxy', 1); + +// --- Security Middleware --- + +// 1. CORS Configuration: Only allow requests from your own domain. +// We create a list of allowed origins, so the API works in both +// development (localhost) and production (the APP_URL). +const allowedOrigins = ['http://localhost:8080']; +if (process.env.APP_URL) { + allowedOrigins.push(process.env.APP_URL); +} +const corsOptions = { + origin: allowedOrigins, + optionsSuccessStatus: 200 +}; +app.use(cors(corsOptions)); + +// 2. Rate Limiting: Apply to all API requests to prevent abuse. +const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // Limit each IP to 100 requests per windowMs +}); +app.use('/api/', apiLimiter); // Path to the file where metadata counts will be stored const dbPath = path.join(__dirname, '_data', 'views.json'); @@ -25,17 +51,25 @@ if (initialLikesData.length === 0) { fs.writeJsonSync(likesDbPath, {}); } - -app.use(cors()); app.use(express.json()); // In production, the server also serves the static site from the `_site` directory. app.use(express.static('_site')); +// 3. Input Validation Middleware +const validateSlug = (req, res, next) => { + const { slug } = req.params; + // Basic slug validation: allow alphanumeric characters, hyphens, underscores, and dots. + if (!/^[a-z0-9_.-]+$/.test(slug)) { + return res.status(400).json({ error: 'Invalid slug format.' }); + } + next(); +}; + // --- API Endpoints --- // GET: Fetch the view count for a specific post slug -app.get('/api/views/:slug', async (req, res) => { +app.get('/api/views/:slug', validateSlug, async (req, res) => { try { const { slug } = req.params; const views = await fs.readJson(dbPath); @@ -48,7 +82,7 @@ app.get('/api/views/:slug', async (req, res) => { }); // GET: Fetch the like count for a specific post slug -app.get('/api/likes/:slug', async (req, res) => { +app.get('/api/likes/:slug', validateSlug, async (req, res) => { try { const { slug } = req.params; const likes = await fs.readJson(likesDbPath); @@ -62,7 +96,7 @@ app.get('/api/likes/:slug', async (req, res) => { // POST: Increment the view count for a specific post slug -app.post('/api/views/:slug', async (req, res) => { +app.post('/api/views/:slug', validateSlug, async (req, res) => { try { const { slug } = req.params; const views = await fs.readJson(dbPath); @@ -76,7 +110,7 @@ app.post('/api/views/:slug', async (req, res) => { }); // POST: Increment the like count for a specific post slug -app.post('/api/likes/:slug', async (req, res) => { +app.post('/api/likes/:slug', validateSlug, async (req, res) => { try { const { slug } = req.params; const likes = await fs.readJson(likesDbPath); @@ -90,7 +124,7 @@ app.post('/api/likes/:slug', async (req, res) => { }); // DELETE: Decrement the like count for a specific post slug (unlike) -app.delete('/api/likes/:slug', async (req, res) => { +app.delete('/api/likes/:slug', validateSlug, async (req, res) => { try { const { slug } = req.params; const likes = await fs.readJson(likesDbPath);