From 73bb041513dfd2e1ac45aeacf7e6b4ff68aaa252 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 14 Feb 2026 17:04:55 +0000 Subject: [PATCH] Security fixes: non-root user, signup rate limiting, differentiated CORS, persistent usage tracking --- Dockerfile | 14 +++++++++- Dockerfile.backup | 19 ++++++++++++++ src/index.ts | 17 +++++++++--- src/middleware/usage.ts | 58 ++++++++++++++++++++++++++++++++--------- src/routes/signup.ts | 17 +++++++++++- 5 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 Dockerfile.backup diff --git a/Dockerfile b/Dockerfile index bdc953a..4766263 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,31 @@ FROM node:22-bookworm-slim -# Install Chromium (works on ARM and x86) +# Install Chromium and dependencies as root RUN apt-get update && apt-get install -y --no-install-recommends \ chromium fonts-liberation \ && rm -rf /var/lib/apt/lists/* +# Create non-root user +RUN groupadd --gid 1001 docfast \ + && useradd --uid 1001 --gid docfast --shell /bin/bash --create-home docfast + +# Set environment variables ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev + COPY dist/ dist/ COPY public/ public/ +# Create data directory and set ownership to docfast user +RUN mkdir -p /app/data && chown -R docfast:docfast /app + +# Switch to non-root user +USER docfast + ENV PORT=3100 EXPOSE 3100 CMD ["node", "dist/index.js"] diff --git a/Dockerfile.backup b/Dockerfile.backup new file mode 100644 index 0000000..bdc953a --- /dev/null +++ b/Dockerfile.backup @@ -0,0 +1,19 @@ +FROM node:22-bookworm-slim + +# Install Chromium (works on ARM and x86) +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY dist/ dist/ +COPY public/ public/ + +ENV PORT=3100 +EXPOSE 3100 +CMD ["node", "dist/index.js"] diff --git a/src/index.ts b/src/index.ts index b519862..b7e8e91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,19 +22,30 @@ loadKeys(); app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); -// CORS — allow browser requests from the landing page +// Differentiated CORS middleware app.use((req, res, next) => { - // Allow all origins — public API - res.setHeader("Access-Control-Allow-Origin", "*"); + const isAuthBillingRoute = req.path.startsWith('/v1/signup') || + req.path.startsWith('/v1/billing'); + + if (isAuthBillingRoute) { + // Auth/billing routes: restrict to docfast.dev + res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev"); + } else { + // Conversion API routes: allow all origins + res.setHeader("Access-Control-Allow-Origin", "*"); + } + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key"); res.setHeader("Access-Control-Max-Age", "86400"); + if (req.method === "OPTIONS") { res.status(204).end(); return; } next(); }); + // Raw body for Stripe webhook signature verification app.use("/v1/billing/webhook", express.raw({ type: "application/json" })); app.use(express.json({ limit: "2mb" })); diff --git a/src/middleware/usage.ts b/src/middleware/usage.ts index 3a94827..15a1afe 100644 --- a/src/middleware/usage.ts +++ b/src/middleware/usage.ts @@ -1,12 +1,9 @@ -import { Request, Response, NextFunction } from "express"; import { isProKey } from "../services/keys.js"; +import fs from "fs/promises"; +import path from "path"; -interface UsageRecord { - count: number; - monthKey: string; -} - -const usage = new Map(); +const USAGE_FILE = "/app/data/usage.json"; +let usage = new Map(); const FREE_TIER_LIMIT = 100; function getMonthKey(): string { @@ -14,11 +11,45 @@ function getMonthKey(): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; } -export function usageMiddleware( - req: Request, - res: Response, - next: NextFunction -): void { +// Load usage data from file on startup +async function loadUsageData(): Promise { + try { + const data = await fs.readFile(USAGE_FILE, "utf8"); + const usageObj = JSON.parse(data); + + usage = new Map(); + for (const [key, record] of Object.entries(usageObj)) { + usage.set(key, record as { count: number; monthKey: string }); + } + + console.log(`Loaded usage data for ${usage.size} keys`); + } catch (error) { + // File doesn't exist or invalid JSON - start fresh + console.log("No existing usage data found, starting fresh"); + usage = new Map(); + } +} + +// Save usage data to file +async function saveUsageData(): Promise { + try { + const usageObj: Record = {}; + for (const [key, record] of usage) { + usageObj[key] = record; + } + + // Ensure directory exists + await fs.mkdir(path.dirname(USAGE_FILE), { recursive: true }); + await fs.writeFile(USAGE_FILE, JSON.stringify(usageObj, null, 2)); + } catch (error) { + console.error("Failed to save usage data:", error); + } +} + +// Initialize usage data loading +loadUsageData().catch(console.error); + +export function usageMiddleware(req: any, res: any, next: any): void { const key = req.headers.authorization?.slice(7) || "unknown"; const monthKey = getMonthKey(); @@ -52,6 +83,9 @@ function trackUsage(key: string, monthKey: string): void { } else { record.count++; } + + // Save to file after each update (simple approach) + saveUsageData().catch(console.error); } export function getUsageStats(): Record { diff --git a/src/routes/signup.ts b/src/routes/signup.ts index 02c7cca..201f547 100644 --- a/src/routes/signup.ts +++ b/src/routes/signup.ts @@ -1,10 +1,25 @@ import { Router, Request, Response } from "express"; +import rateLimit from "express-rate-limit"; import { createFreeKey } from "../services/keys.js"; const router = Router(); +// Rate limiting for signup - 5 signups per IP per hour +const signupLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 5, // 5 signups per IP per hour + message: { + error: "Too many signup attempts. Please try again in 1 hour.", + retryAfter: "1 hour" + }, + standardHeaders: true, + legacyHeaders: false, + skipSuccessfulRequests: false, + skipFailedRequests: false, +}); + // Self-service free API key signup -router.post("/free", (req: Request, res: Response) => { +router.post("/free", signupLimiter, (req: Request, res: Response) => { const { email } = req.body; if (!email || typeof email !== "string") {