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,
|
setAccessTokenCookie,
|
||||||
setAuthCookies,
|
setAuthCookies,
|
||||||
} from "./auth/cookies";
|
} from "./auth/cookies";
|
||||||
|
import { getClientIp } from "./utils/clientIp";
|
||||||
|
|
||||||
interface JwtPayload {
|
interface JwtPayload {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -125,8 +126,7 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
|
|||||||
return trimmed.length > 0 ? trimmed.slice(0, 255) : null;
|
return trimmed.length > 0 ? trimmed.slice(0, 255) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveRateLimitIp = (req: Request): string =>
|
const resolveRateLimitIp = (req: Request): string => getClientIp(req);
|
||||||
(req.ip || req.connection.remoteAddress || "unknown").slice(0, 255);
|
|
||||||
|
|
||||||
const trackIdentifierRateLimitKey = (identifier: string, key: string): void => {
|
const trackIdentifierRateLimitKey = (identifier: string, key: string): void => {
|
||||||
if (!loginIdentifierKeyIndex.has(identifier) && loginIdentifierKeyIndex.size >= 5000) {
|
if (!loginIdentifierKeyIndex.has(identifier) && loginIdentifierKeyIndex.size >= 5000) {
|
||||||
@@ -250,6 +250,7 @@ export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router =>
|
|||||||
validate: {
|
validate: {
|
||||||
trustProxy: false,
|
trustProxy: false,
|
||||||
},
|
},
|
||||||
|
keyGenerator: (req) => getClientIp(req as Request),
|
||||||
});
|
});
|
||||||
|
|
||||||
const generateTempPassword = (): string => {
|
const generateTempPassword = (): string => {
|
||||||
|
|||||||
@@ -167,6 +167,16 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
const parsed = registerSchema.safeParse(req.body);
|
const parsed = registerSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
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({
|
return res.status(400).json({
|
||||||
error: "Validation error",
|
error: "Validation error",
|
||||||
message: "Invalid registration data",
|
message: "Invalid registration data",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { prisma } from "./db/prisma";
|
|||||||
import { createDrawingsCacheStore } from "./server/drawingsCache";
|
import { createDrawingsCacheStore } from "./server/drawingsCache";
|
||||||
import { registerCsrfProtection } from "./server/csrf";
|
import { registerCsrfProtection } from "./server/csrf";
|
||||||
import { registerSocketHandlers } from "./server/socket";
|
import { registerSocketHandlers } from "./server/socket";
|
||||||
|
import { getClientIp } from "./utils/clientIp";
|
||||||
|
|
||||||
const backendRoot = path.resolve(__dirname, "../");
|
const backendRoot = path.resolve(__dirname, "../");
|
||||||
console.log("Resolved DATABASE_URL:", process.env.DATABASE_URL);
|
console.log("Resolved DATABASE_URL:", process.env.DATABASE_URL);
|
||||||
@@ -330,6 +331,7 @@ const generalRateLimiter = rateLimit({
|
|||||||
validate: {
|
validate: {
|
||||||
trustProxy: false,
|
trustProxy: false,
|
||||||
},
|
},
|
||||||
|
keyGenerator: (req) => getClientIp(req),
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(generalRateLimiter);
|
app.use(generalRateLimiter);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getCsrfClientCookieValue,
|
getCsrfClientCookieValue,
|
||||||
getCsrfValidationClientIds,
|
getCsrfValidationClientIds,
|
||||||
} from "../security/csrfClient";
|
} from "../security/csrfClient";
|
||||||
|
import { getClientIp } from "../utils/clientIp";
|
||||||
|
|
||||||
const CSRF_CLIENT_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days
|
const CSRF_CLIENT_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days
|
||||||
const CSRF_RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
const CSRF_RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
||||||
@@ -77,7 +78,7 @@ export const registerCsrfProtection = ({
|
|||||||
|
|
||||||
if (enableDebugLogging) {
|
if (enableDebugLogging) {
|
||||||
const validationCandidates = getCsrfValidationClientIds(req);
|
const validationCandidates = getCsrfValidationClientIds(req);
|
||||||
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
const ip = getClientIp(req);
|
||||||
console.log("[CSRF DEBUG] getClientId", {
|
console.log("[CSRF DEBUG] getClientId", {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
path: req.path,
|
path: req.path,
|
||||||
@@ -102,7 +103,7 @@ export const registerCsrfProtection = ({
|
|||||||
let csrfCleanupCounter = 0;
|
let csrfCleanupCounter = 0;
|
||||||
|
|
||||||
app.get("/csrf-token", (req, res) => {
|
app.get("/csrf-token", (req, res) => {
|
||||||
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
const ip = getClientIp(req);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const clientLimit = csrfRateLimit.get(ip);
|
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';
|
import { Logo } from '../components/Logo';
|
||||||
|
|
||||||
export const Register: React.FC = () => {
|
export const Register: React.FC = () => {
|
||||||
|
const strongPasswordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{12,100}$/;
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -43,7 +44,12 @@ export const Register: React.FC = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
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');
|
setError('Password must be at least 8 characters long');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -134,9 +140,11 @@ export const Register: React.FC = () => {
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
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"
|
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}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user