Files
Gerhard Scheikl 01b4734477 security hardening
2026-05-31 09:35:31 +02:00

127 lines
4.6 KiB
JavaScript

// Custom production server for the React Router build.
//
// Replaces `react-router-serve` so we can:
// - prefix every console line with an ISO timestamp,
// - use a richer morgan format (with timestamp + content-length),
// - skip access logs for successful /healthz probes (they would otherwise
// drown out everything useful — Docker/Caddy poll them every couple of
// seconds).
//
// Behaviour is otherwise intentionally identical to `@react-router/serve`'s
// CLI (compression, /assets immutable cache, public/ static, SIGTERM/SIGINT
// handling).
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.
// ---------------------------------------------------------------------------
const ts = () => `[${new Date().toISOString()}]`;
for (const level of ["log", "info", "warn", "error", "debug"]) {
const original = console[level].bind(console);
console[level] = (...args) => original(ts(), ...args);
}
const PORT = Number(process.env.PORT || 3000);
const HOST = process.env.HOST;
const buildModule = await import("./build/server/index.js");
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.
app.use(
"/assets",
express.static("build/client/assets", { immutable: true, maxAge: "1y" }),
);
app.use(express.static("build/client", { maxAge: "1h" }));
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 :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 }),
);
const onListen = () => {
console.log(
`[server] listening on http://${HOST ?? "localhost"}:${PORT}`,
);
};
const server = HOST
? app.listen(PORT, HOST, onListen)
: app.listen(PORT, onListen);
for (const signal of ["SIGTERM", "SIGINT"]) {
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);
});
// 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);
});
}