fix(deploy): align /api routing, socket path, and proxy-aware auth limits

This commit is contained in:
2026-02-12 19:43:49 +01:00
parent e05edff84d
commit 6fe2ab3d28
7 changed files with 88 additions and 7 deletions
+36
View File
@@ -0,0 +1,36 @@
server {
listen 80;
server_name draw.louiscreates.com;
# Frontend static build
root /var/www/excalidash;
index index.html;
# SPA routing
location / {
try_files $uri $uri/ /index.html;
}
# API reverse proxy (Express mounted at /api)
location /api/ {
proxy_pass http://127.0.0.1:6768/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Socket.IO websocket/polling endpoint
location /api/socket.io/ {
proxy_pass http://127.0.0.1:6768/api/socket.io/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
}
}
+3 -2
View File
@@ -29,6 +29,7 @@ import {
setAccessTokenCookie,
setAuthCookies,
} from "./auth/cookies";
import { getClientIp } from "./utils/clientIp";
interface JwtPayload {
userId: string;
@@ -125,8 +126,7 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
return trimmed.length > 0 ? trimmed.slice(0, 255) : null;
};
const resolveRateLimitIp = (req: Request): string =>
(req.ip || req.connection.remoteAddress || "unknown").slice(0, 255);
const resolveRateLimitIp = (req: Request): string => getClientIp(req);
const trackIdentifierRateLimitKey = (identifier: string, key: string): void => {
if (!loginIdentifierKeyIndex.has(identifier) && loginIdentifierKeyIndex.size >= 5000) {
@@ -250,6 +250,7 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
validate: {
trustProxy: false,
},
keyGenerator: (req) => getClientIp(req as Request),
});
const generateTempPassword = (): string => {
+10
View File
@@ -167,6 +167,16 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
const summarizedIssues = parsed.error.issues.map((issue) => ({
code: issue.code,
path: issue.path.join("."),
message: issue.message,
}));
console.warn("[auth/register] validation failed", {
issues: summarizedIssues,
requestId: req.headers["x-request-id"],
ip: req.ip || req.connection.remoteAddress || "unknown",
});
return res.status(400).json({
error: "Validation error",
message: "Invalid registration data",
+2
View File
@@ -31,6 +31,7 @@ import { prisma } from "./db/prisma";
import { createDrawingsCacheStore } from "./server/drawingsCache";
import { registerCsrfProtection } from "./server/csrf";
import { registerSocketHandlers } from "./server/socket";
import { getClientIp } from "./utils/clientIp";
const backendRoot = path.resolve(__dirname, "../");
console.log("Resolved DATABASE_URL:", process.env.DATABASE_URL);
@@ -330,6 +331,7 @@ const generalRateLimiter = rateLimit({
validate: {
trustProxy: false,
},
keyGenerator: (req) => getClientIp(req),
});
app.use(generalRateLimiter);
+3 -2
View File
@@ -11,6 +11,7 @@ import {
getCsrfClientCookieValue,
getCsrfValidationClientIds,
} from "../security/csrfClient";
import { getClientIp } from "../utils/clientIp";
const CSRF_CLIENT_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days
const CSRF_RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
@@ -77,7 +78,7 @@ export const registerCsrfProtection = ({
if (enableDebugLogging) {
const validationCandidates = getCsrfValidationClientIds(req);
const ip = req.ip || req.connection.remoteAddress || "unknown";
const ip = getClientIp(req);
console.log("[CSRF DEBUG] getClientId", {
method: req.method,
path: req.path,
@@ -102,7 +103,7 @@ export const registerCsrfProtection = ({
let csrfCleanupCounter = 0;
app.get("/csrf-token", (req, res) => {
const ip = req.ip || req.connection.remoteAddress || "unknown";
const ip = getClientIp(req);
const now = Date.now();
const clientLimit = csrfRateLimit.get(ip);
+23
View File
@@ -0,0 +1,23 @@
import type { Request } from "express";
const parseForwardedFor = (forwardedFor: string | string[] | undefined): string | null => {
if (!forwardedFor) return null;
const raw = Array.isArray(forwardedFor) ? forwardedFor.join(",") : forwardedFor;
const first = raw
.split(",")
.map((part) => part.trim())
.find((part) => part.length > 0);
return first || null;
};
export const getClientIp = (req: Request): string => {
const fromForwardedFor = parseForwardedFor(req.headers["x-forwarded-for"]);
if (fromForwardedFor) return fromForwardedFor.slice(0, 255);
const fromRequestIp = typeof req.ip === "string" ? req.ip.trim() : "";
if (fromRequestIp.length > 0) return fromRequestIp.slice(0, 255);
const fromSocket = req.socket?.remoteAddress || req.connection?.remoteAddress || "unknown";
return String(fromSocket).slice(0, 255);
};
+11 -3
View File
@@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext';
import { Logo } from '../components/Logo';
export const Register: React.FC = () => {
const strongPasswordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{12,100}$/;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
@@ -43,7 +44,12 @@ export const Register: React.FC = () => {
e.preventDefault();
setError('');
if (password.length < 8) {
if (import.meta.env.PROD) {
if (!strongPasswordPattern.test(password)) {
setError('Password must be 12+ chars and include upper, lower, number, and symbol');
return;
}
} else if (password.length < 8) {
setError('Password must be at least 8 characters long');
return;
}
@@ -134,9 +140,11 @@ export const Register: React.FC = () => {
type="password"
autoComplete="new-password"
required
minLength={8}
minLength={import.meta.env.PROD ? 12 : 8}
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password (min 8 characters)"
placeholder={import.meta.env.PROD
? "Password (12+, upper/lower/number/symbol)"
: "Password (min 8 characters)"}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>