diff --git a/backend/nginx.draw.louiscreates.com.conf b/backend/nginx.draw.louiscreates.com.conf new file mode 100644 index 0000000..8b3aa64 --- /dev/null +++ b/backend/nginx.draw.louiscreates.com.conf @@ -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; + } +} diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 0dd348d..0ca7fde 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -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 => { diff --git a/backend/src/auth/coreRoutes.ts b/backend/src/auth/coreRoutes.ts index f7caaf7..d50ac1a 100644 --- a/backend/src/auth/coreRoutes.ts +++ b/backend/src/auth/coreRoutes.ts @@ -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", diff --git a/backend/src/index.ts b/backend/src/index.ts index b0aabfc..795d25b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/server/csrf.ts b/backend/src/server/csrf.ts index 03f9640..d206c48 100644 --- a/backend/src/server/csrf.ts +++ b/backend/src/server/csrf.ts @@ -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); diff --git a/backend/src/utils/clientIp.ts b/backend/src/utils/clientIp.ts new file mode 100644 index 0000000..8f887b3 --- /dev/null +++ b/backend/src/utils/clientIp.ts @@ -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); +}; + diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index dfa7a6e..706c9db 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -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)} />