Implement IP allowlisting for QR generation and enhance .env file handling
All checks were successful
Deploy Production (qr.crewsportswear.app) / deploy (push) Successful in 31s
All checks were successful
Deploy Production (qr.crewsportswear.app) / deploy (push) Successful in 31s
This commit is contained in:
@@ -75,6 +75,19 @@ jobs:
|
|||||||
|
|
||||||
cd "$DEPLOY_DIR"
|
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"
|
echo "Ensure networks"
|
||||||
docker network inspect traefik-public >/dev/null 2>&1 || \
|
docker network inspect traefik-public >/dev/null 2>&1 || \
|
||||||
docker network create traefik-public
|
docker network create traefik-public
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -70,3 +70,19 @@ Notes:
|
|||||||
- Internal service port is `3000`
|
- Internal service port is `3000`
|
||||||
- TLS uses Traefik Let's Encrypt via `tls.certresolver=le`
|
- TLS uses Traefik Let's Encrypt via `tls.certresolver=le`
|
||||||
- Includes HTTP -> HTTPS redirect via Traefik labels
|
- 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.
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
- TRUST_PROXY=${TRUST_PROXY:-true}
|
||||||
|
- ALLOWED_QR_IPS=${ALLOWED_QR_IPS:-}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
52
server.js
52
server.js
@@ -3,9 +3,57 @@ const QRCode = require("qrcode");
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
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" }));
|
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) {
|
function parseIntWithBounds(value, fallback, min, max) {
|
||||||
const parsed = Number.parseInt(value, 10);
|
const parsed = Number.parseInt(value, 10);
|
||||||
if (!Number.isFinite(parsed)) {
|
if (!Number.isFinite(parsed)) {
|
||||||
@@ -70,8 +118,8 @@ app.get("/health", (_req, res) => {
|
|||||||
res.json({ status: "ok" });
|
res.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/qr", (req, res, next) => sendQrPng(req.query, res, next));
|
app.get("/api/qr", enforceQrIpAllowlist, (req, res, next) => sendQrPng(req.query, res, next));
|
||||||
app.post("/api/qr", (req, res, next) => sendQrPng(req.body, res, next));
|
app.post("/api/qr", enforceQrIpAllowlist, (req, res, next) => sendQrPng(req.body, res, next));
|
||||||
|
|
||||||
app.use((err, _req, res, _next) => {
|
app.use((err, _req, res, _next) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
Reference in New Issue
Block a user