From 4d6fd1e348b79b2a2f45b77e3e73f6d2389e5eb6 Mon Sep 17 00:00:00 2001 From: Frank John Begornia Date: Thu, 2 Apr 2026 15:46:58 +0800 Subject: [PATCH] Implement IP allowlisting for QR generation and enhance .env file handling --- .gitea/workflows/deploy.yml | 13 ++++++++++ README.md | 16 ++++++++++++ docker-compose.prod.yml | 2 ++ server.js | 52 +++++++++++++++++++++++++++++++++++-- 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 745ee44..f28d825 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -75,6 +75,19 @@ jobs: cd "$DEPLOY_DIR" + echo "Checking .env file" + if [ ! -f .env ]; then + echo ".env file not found at $DEPLOY_DIR/.env" + echo "Please create it first with optional/custom variables such as:" + echo " - TRUST_PROXY" + echo " - ALLOWED_QR_IPS" + exit 1 + fi + + echo "Fixing .env permissions" + sudo chown $USER:$USER .env + sudo chmod 600 .env + echo "Ensure networks" docker network inspect traefik-public >/dev/null 2>&1 || \ docker network create traefik-public diff --git a/README.md b/README.md index 2d7215e..ddfb5b1 100644 --- a/README.md +++ b/README.md @@ -70,3 +70,19 @@ Notes: - Internal service port is `3000` - TLS uses Traefik Let's Encrypt via `tls.certresolver=le` - Includes HTTP -> HTTPS redirect via Traefik labels + +## Restrict QR generation by IP + +The `/api/qr` endpoints support IP allowlisting via environment variable. + +- `ALLOWED_QR_IPS`: comma-separated list of allowed client IPs +- `TRUST_PROXY`: keep this `true` behind Traefik so client IP is read from forwarded headers + +Example `.env` values for production: + +```env +ALLOWED_QR_IPS=203.0.113.10,198.51.100.22 +TRUST_PROXY=true +``` + +If `ALLOWED_QR_IPS` is empty, IP filtering is disabled. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 90bd648..5da8aa2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -9,6 +9,8 @@ services: environment: - NODE_ENV=production - PORT=3000 + - TRUST_PROXY=${TRUST_PROXY:-true} + - ALLOWED_QR_IPS=${ALLOWED_QR_IPS:-} healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"] interval: 30s diff --git a/server.js b/server.js index 6add550..f4e23b2 100644 --- a/server.js +++ b/server.js @@ -3,9 +3,57 @@ const QRCode = require("qrcode"); const app = express(); const PORT = process.env.PORT || 3000; +const TRUST_PROXY = process.env.TRUST_PROXY !== "false"; +const ALLOWED_QR_IPS = new Set( + (process.env.ALLOWED_QR_IPS || "") + .split(",") + .map((ip) => ip.trim()) + .filter(Boolean) + .map((ip) => normalizeIp(ip)) +); + +app.set("trust proxy", TRUST_PROXY); app.use(express.json({ limit: "100kb" })); +function normalizeIp(ip) { + if (!ip || typeof ip !== "string") { + return ""; + } + + const trimmed = ip.trim(); + const withoutMappedV4Prefix = trimmed.startsWith("::ffff:") ? trimmed.slice(7) : trimmed; + + const ipv4WithPortMatch = withoutMappedV4Prefix.match(/^(\d+\.\d+\.\d+\.\d+):(\d+)$/); + if (ipv4WithPortMatch) { + return ipv4WithPortMatch[1]; + } + + return withoutMappedV4Prefix; +} + +function getClientIp(req) { + const forwardedFor = req.headers["x-forwarded-for"]; + if (typeof forwardedFor === "string" && forwardedFor.trim()) { + return normalizeIp(forwardedFor.split(",")[0]); + } + + return normalizeIp(req.ip || req.socket?.remoteAddress || ""); +} + +function enforceQrIpAllowlist(req, res, next) { + if (ALLOWED_QR_IPS.size === 0) { + return next(); + } + + const clientIp = getClientIp(req); + if (ALLOWED_QR_IPS.has(clientIp)) { + return next(); + } + + return res.status(403).json({ error: "Access denied for this IP address" }); +} + function parseIntWithBounds(value, fallback, min, max) { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed)) { @@ -70,8 +118,8 @@ app.get("/health", (_req, res) => { res.json({ status: "ok" }); }); -app.get("/api/qr", (req, res, next) => sendQrPng(req.query, res, next)); -app.post("/api/qr", (req, res, next) => sendQrPng(req.body, res, next)); +app.get("/api/qr", enforceQrIpAllowlist, (req, res, next) => sendQrPng(req.query, res, next)); +app.post("/api/qr", enforceQrIpAllowlist, (req, res, next) => sendQrPng(req.body, res, next)); app.use((err, _req, res, _next) => { console.error(err);