Security fixes: non-root user, signup rate limiting, differentiated CORS, persistent usage tracking
This commit is contained in:
parent
6a38ba4adc
commit
73bb041513
5 changed files with 108 additions and 17 deletions
14
Dockerfile
14
Dockerfile
|
|
@ -1,19 +1,31 @@
|
||||||
FROM node:22-bookworm-slim
|
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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
chromium fonts-liberation \
|
chromium fonts-liberation \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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_SKIP_CHROMIUM_DOWNLOAD=true
|
||||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
COPY dist/ dist/
|
COPY dist/ dist/
|
||||||
COPY public/ public/
|
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
|
ENV PORT=3100
|
||||||
EXPOSE 3100
|
EXPOSE 3100
|
||||||
CMD ["node", "dist/index.js"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|
|
||||||
19
Dockerfile.backup
Normal file
19
Dockerfile.backup
Normal file
|
|
@ -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"]
|
||||||
15
src/index.ts
15
src/index.ts
|
|
@ -22,19 +22,30 @@ loadKeys();
|
||||||
|
|
||||||
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
|
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
|
||||||
|
|
||||||
// CORS — allow browser requests from the landing page
|
// Differentiated CORS middleware
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
// Allow all origins — public API
|
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-Origin", "*");
|
||||||
|
}
|
||||||
|
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
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-Allow-Headers", "Content-Type, Authorization, X-API-Key");
|
||||||
res.setHeader("Access-Control-Max-Age", "86400");
|
res.setHeader("Access-Control-Max-Age", "86400");
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Raw body for Stripe webhook signature verification
|
// Raw body for Stripe webhook signature verification
|
||||||
app.use("/v1/billing/webhook", express.raw({ type: "application/json" }));
|
app.use("/v1/billing/webhook", express.raw({ type: "application/json" }));
|
||||||
app.use(express.json({ limit: "2mb" }));
|
app.use(express.json({ limit: "2mb" }));
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { isProKey } from "../services/keys.js";
|
import { isProKey } from "../services/keys.js";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
interface UsageRecord {
|
const USAGE_FILE = "/app/data/usage.json";
|
||||||
count: number;
|
let usage = new Map<string, { count: number; monthKey: string }>();
|
||||||
monthKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage = new Map<string, UsageRecord>();
|
|
||||||
const FREE_TIER_LIMIT = 100;
|
const FREE_TIER_LIMIT = 100;
|
||||||
|
|
||||||
function getMonthKey(): string {
|
function getMonthKey(): string {
|
||||||
|
|
@ -14,11 +11,45 @@ function getMonthKey(): string {
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usageMiddleware(
|
// Load usage data from file on startup
|
||||||
req: Request,
|
async function loadUsageData(): Promise<void> {
|
||||||
res: Response,
|
try {
|
||||||
next: NextFunction
|
const data = await fs.readFile(USAGE_FILE, "utf8");
|
||||||
): void {
|
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<void> {
|
||||||
|
try {
|
||||||
|
const usageObj: Record<string, { count: number; monthKey: string }> = {};
|
||||||
|
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 key = req.headers.authorization?.slice(7) || "unknown";
|
||||||
const monthKey = getMonthKey();
|
const monthKey = getMonthKey();
|
||||||
|
|
||||||
|
|
@ -52,6 +83,9 @@ function trackUsage(key: string, monthKey: string): void {
|
||||||
} else {
|
} else {
|
||||||
record.count++;
|
record.count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save to file after each update (simple approach)
|
||||||
|
saveUsageData().catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUsageStats(): Record<string, { count: number; month: string }> {
|
export function getUsageStats(): Record<string, { count: number; month: string }> {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,25 @@
|
||||||
import { Router, Request, Response } from "express";
|
import { Router, Request, Response } from "express";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
import { createFreeKey } from "../services/keys.js";
|
import { createFreeKey } from "../services/keys.js";
|
||||||
|
|
||||||
const router = Router();
|
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
|
// 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;
|
const { email } = req.body;
|
||||||
|
|
||||||
if (!email || typeof email !== "string") {
|
if (!email || typeof email !== "string") {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue