fix(deploy): align /api routing, socket path, and proxy-aware auth limits
This commit is contained in:
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user