security hardening
This commit is contained in:
@@ -15,6 +15,7 @@ import { createRequestHandler } from "@react-router/express";
|
||||
import compression from "compression";
|
||||
import express from "express";
|
||||
import morgan from "morgan";
|
||||
import rateLimit from "express-rate-limit";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console timestamps — patch BEFORE anything else logs.
|
||||
@@ -33,6 +34,11 @@ const build = buildModule.default ?? buildModule;
|
||||
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
// The app runs behind a single reverse proxy (Caddy) that sets
|
||||
// X-Forwarded-For. Trust ONLY the first proxy hop so req.ip reflects the real
|
||||
// client IP for rate limiting, without trusting arbitrary client-supplied
|
||||
// forwarding headers (which `trust proxy: true` would).
|
||||
app.set("trust proxy", 1);
|
||||
app.use(compression());
|
||||
|
||||
// Static assets emitted by the React Router build.
|
||||
@@ -46,15 +52,43 @@ app.use(express.static("public", { maxAge: "1h" }));
|
||||
// Access log: ISO timestamp + standard request info; suppress healthy
|
||||
// /healthz polls so real traffic stays visible.
|
||||
morgan.token("isotime", () => new Date().toISOString());
|
||||
// Redacted URL: strip sensitive query parameters (the GiroCode HMAC `sig`
|
||||
// and any `token`) from the logged URL so signed-URL secrets / bearer tokens
|
||||
// never land in access logs. Other query params are preserved.
|
||||
morgan.token("safeurl", (req) => {
|
||||
const raw = req.originalUrl || req.url || "";
|
||||
const qIdx = raw.indexOf("?");
|
||||
if (qIdx === -1) return raw;
|
||||
const path = raw.slice(0, qIdx);
|
||||
const params = new URLSearchParams(raw.slice(qIdx + 1));
|
||||
for (const key of ["sig", "token"]) {
|
||||
if (params.has(key)) params.set(key, "REDACTED");
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
});
|
||||
app.use(
|
||||
morgan(
|
||||
":isotime :method :url :status :res[content-length] - :response-time ms",
|
||||
":isotime :method :safeurl :status :res[content-length] - :response-time ms",
|
||||
{
|
||||
skip: (req, res) => req.url === "/healthz" && res.statusCode < 400,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Per-IP rate limiting for the public, unauthenticated-at-the-edge API
|
||||
// surface only (/api/public/*). Shopify webhooks (/webhooks/*) and the
|
||||
// embedded admin are intentionally NOT rate limited here — webhooks can burst
|
||||
// legitimately and are already HMAC-verified.
|
||||
const publicApiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
limit: 60, // 60 requests / minute / IP
|
||||
standardHeaders: true, // RateLimit-* headers
|
||||
legacyHeaders: false,
|
||||
message: { error: "rate-limited" },
|
||||
});
|
||||
app.use("/api/public", publicApiLimiter);
|
||||
|
||||
app.all(
|
||||
"*",
|
||||
createRequestHandler({ build, mode: process.env.NODE_ENV }),
|
||||
@@ -71,11 +105,22 @@ const server = HOST
|
||||
: app.listen(PORT, onListen);
|
||||
|
||||
for (const signal of ["SIGTERM", "SIGINT"]) {
|
||||
process.once(signal, () => {
|
||||
process.once(signal, async () => {
|
||||
console.log(`[server] received ${signal}, shutting down`);
|
||||
// Stop accepting new connections.
|
||||
server.close((err) => {
|
||||
if (err) console.error("[server] close error:", err);
|
||||
process.exit(0);
|
||||
});
|
||||
// Drain in-flight background webhook work (PDF render / SMTP send) before
|
||||
// exiting so a container stop doesn't lose invoice work mid-send. The
|
||||
// background queue exposes this bridge because server.js loads only the
|
||||
// bundled build and can't import the module directly.
|
||||
try {
|
||||
const drain = globalThis.__linumiqWebhookDrain;
|
||||
if (typeof drain === "function") await drain();
|
||||
} catch (err) {
|
||||
console.error("[server] webhook drain error:", err);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user