From 59cc8f3d0e2e87e571edf6926004466305e1092d Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Mon, 16 Feb 2026 19:30:21 +0000 Subject: [PATCH] Session 45: support email, audit fixes (template validation, content-type, admin auth, waitUntil) - Added support@docfast.dev to footer, impressum, terms, landing page, openapi.json - Fixed audit #6: Template render validates required fields (400 on missing) - Fixed audit #7: Content-Type check on markdown/URL routes (415) - Fixed audit #11: /v1/usage and /v1/concurrency now require ADMIN_API_KEY - Fixed audit Critical #3: URL convert uses domcontentloaded instead of networkidle0 --- dist/index.js | 41 +++++++++++++--------------------- dist/routes/billing.js | 17 +++++--------- dist/routes/convert.js | 31 +++++++++++++++++++++---- dist/routes/templates.js | 17 +++++++++++++- dist/services/browser.js | 37 +++++++++++++++++++++++++++++- dist/services/verification.js | 7 ++++-- public/impressum.html | 4 +++- public/index.html | 3 ++- public/openapi.json | 2 +- public/privacy.html | 1 + public/src/impressum.html | 3 ++- public/src/index.html | 2 +- public/src/terms.html | 2 +- public/terms.html | 3 ++- src/index.ts | 14 ++++++++---- src/routes/convert.ts | 12 ++++++++++ src/routes/templates.ts | 21 ++++++++++++++++- src/services/browser.ts | 2 +- templates/pages/impressum.html | 3 ++- templates/pages/index.html | 2 +- templates/pages/terms.html | 2 +- templates/partials/footer.html | 1 + 22 files changed, 166 insertions(+), 61 deletions(-) diff --git a/dist/index.js b/dist/index.js index c8ee9a7..90437e5 100644 --- a/dist/index.js +++ b/dist/index.js @@ -89,12 +89,24 @@ app.use("/v1/email-change", emailChangeRouter); // Authenticated routes app.use("/v1/convert", authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter); app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter); -// Admin: usage stats -app.get("/v1/usage", authMiddleware, (req, res) => { +// Admin: usage stats (admin key required) +const adminAuth = (req, res, next) => { + const adminKey = process.env.ADMIN_API_KEY; + if (!adminKey) { + res.status(503).json({ error: "Admin access not configured" }); + return; + } + if (req.apiKeyInfo?.key !== adminKey) { + res.status(403).json({ error: "Admin access required" }); + return; + } + next(); +}; +app.get("/v1/usage", authMiddleware, adminAuth, (req, res) => { res.json(getUsageStats(req.apiKeyInfo?.key)); }); -// Admin: concurrency stats -app.get("/v1/concurrency", authMiddleware, (_req, res) => { +// Admin: concurrency stats (admin key required) +app.get("/v1/concurrency", authMiddleware, adminAuth, (_req, res) => { res.json(getConcurrencyStats()); }); // Email verification endpoint @@ -246,27 +258,6 @@ app.use((req, res) => { `); } }); -// 404 handler — must be after all routes -app.use((req, res) => { - if (req.path.startsWith("/v1/")) { - res.status(404).json({ error: "Not found" }); - } - else { - const accepts = req.headers.accept || ""; - if (accepts.includes("text/html")) { - res.status(404).send(` - -404 — DocFast - - -

404

Page not found.

← Back to DocFast · API Docs

`); - } - else { - res.status(404).json({ error: "Not found" }); - } - } -}); async function start() { // Initialize PostgreSQL await initDatabase(); diff --git a/dist/routes/billing.js b/dist/routes/billing.js index e99f834..9fe5a24 100644 --- a/dist/routes/billing.js +++ b/dist/routes/billing.js @@ -65,7 +65,7 @@ a { color: #4f9; }

🎉 Welcome to Pro!

Your API key:

-
${escapeHtml(keyInfo.key)}
+
${escapeHtml(keyInfo.key)}

Save this key! It won't be shown again.

5,000 PDFs/month • All endpoints • Priority support

View API docs →

@@ -82,16 +82,9 @@ router.post("/webhook", async (req, res) => { const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; let event; if (!webhookSecret) { - console.warn("⚠️ STRIPE_WEBHOOK_SECRET is not configured — webhook signature verification skipped. Set this in production!"); - // Parse the body as a raw event without verification - try { - event = JSON.parse(typeof req.body === "string" ? req.body : req.body.toString()); - } - catch (err) { - logger.error({ err }, "Failed to parse webhook body"); - res.status(400).json({ error: "Invalid payload" }); - return; - } + logger.error("STRIPE_WEBHOOK_SECRET is not configured — refusing to process unverified webhooks"); + res.status(500).json({ error: "Webhook signature verification is not configured" }); + return; } else if (!sig) { res.status(400).json({ error: "Missing stripe-signature header" }); @@ -134,7 +127,7 @@ router.post("/webhook", async (req, res) => { break; } if (!customerId || !email) { - console.warn("checkout.session.completed: missing customerId or email, skipping key provisioning"); + logger.warn("checkout.session.completed: missing customerId or email, skipping key provisioning"); break; } const keyInfo = await createProKey(email, customerId); diff --git a/dist/routes/convert.js b/dist/routes/convert.js index 55a0fa1..e7d5c2d 100644 --- a/dist/routes/convert.js +++ b/dist/routes/convert.js @@ -12,6 +12,10 @@ function isPrivateIP(ip) { if (ip.toLowerCase().startsWith("fe8") || ip.toLowerCase().startsWith("fe9") || ip.toLowerCase().startsWith("fea") || ip.toLowerCase().startsWith("feb")) return true; + // IPv6 unique local (fc00::/7) + const lower = ip.toLowerCase(); + if (lower.startsWith("fc") || lower.startsWith("fd")) + return true; // IPv4-mapped IPv6 if (ip.startsWith("::ffff:")) ip = ip.slice(7); @@ -32,6 +36,10 @@ function isPrivateIP(ip) { return true; // 192.168.0.0/16 return false; } +function sanitizeFilename(name) { + // Strip characters dangerous in Content-Disposition headers + return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf"; +} export const convertRouter = Router(); // POST /v1/convert/html convertRouter.post("/html", async (req, res) => { @@ -63,7 +71,7 @@ convertRouter.post("/html", async (req, res) => { margin: body.margin, printBackground: body.printBackground, }); - const filename = body.filename || "document.pdf"; + const filename = sanitizeFilename(body.filename || "document.pdf"); res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.send(pdf); @@ -86,6 +94,12 @@ convertRouter.post("/html", async (req, res) => { convertRouter.post("/markdown", async (req, res) => { let slotAcquired = false; try { + // Reject non-JSON content types + const ct = req.headers["content-type"] || ""; + if (!ct.includes("application/json")) { + res.status(415).json({ error: "Unsupported Content-Type. Use application/json." }); + return; + } const body = typeof req.body === "string" ? { markdown: req.body } : req.body; if (!body.markdown) { res.status(400).json({ error: "Missing 'markdown' field" }); @@ -103,7 +117,7 @@ convertRouter.post("/markdown", async (req, res) => { margin: body.margin, printBackground: body.printBackground, }); - const filename = body.filename || "document.pdf"; + const filename = sanitizeFilename(body.filename || "document.pdf"); res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.send(pdf); @@ -126,6 +140,12 @@ convertRouter.post("/markdown", async (req, res) => { convertRouter.post("/url", async (req, res) => { let slotAcquired = false; try { + // Reject non-JSON content types + const ct = req.headers["content-type"] || ""; + if (!ct.includes("application/json")) { + res.status(415).json({ error: "Unsupported Content-Type. Use application/json." }); + return; + } const body = req.body; if (!body.url) { res.status(400).json({ error: "Missing 'url' field" }); @@ -144,13 +164,15 @@ convertRouter.post("/url", async (req, res) => { res.status(400).json({ error: "Invalid URL" }); return; } - // DNS lookup to block private/reserved IPs + // DNS lookup to block private/reserved IPs + pin resolution to prevent DNS rebinding + let resolvedAddress; try { const { address } = await dns.lookup(parsed.hostname); if (isPrivateIP(address)) { res.status(400).json({ error: "URL resolves to a private/internal IP address" }); return; } + resolvedAddress = address; } catch { res.status(400).json({ error: "DNS lookup failed for URL hostname" }); @@ -167,8 +189,9 @@ convertRouter.post("/url", async (req, res) => { margin: body.margin, printBackground: body.printBackground, waitUntil: body.waitUntil, + hostResolverRules: `MAP ${parsed.hostname} ${resolvedAddress}`, }); - const filename = body.filename || "page.pdf"; + const filename = sanitizeFilename(body.filename || "page.pdf"); res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.send(pdf); diff --git a/dist/routes/templates.js b/dist/routes/templates.js index 720cac0..5957e83 100644 --- a/dist/routes/templates.js +++ b/dist/routes/templates.js @@ -2,6 +2,9 @@ import { Router } from "express"; import { renderPdf } from "../services/browser.js"; import logger from "../services/logger.js"; import { templates, renderTemplate } from "../services/templates.js"; +function sanitizeFilename(name) { + return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200); +} export const templatesRouter = Router(); // GET /v1/templates — list available templates templatesRouter.get("/", (_req, res) => { @@ -23,12 +26,24 @@ templatesRouter.post("/:id/render", async (req, res) => { return; } const data = req.body.data || req.body; + // Validate required fields + const missingFields = template.fields + .filter((f) => f.required && (data[f.name] === undefined || data[f.name] === null || data[f.name] === "")) + .map((f) => f.name); + if (missingFields.length > 0) { + res.status(400).json({ + error: "Missing required fields", + missing: missingFields, + hint: `Required fields for '${id}': ${template.fields.filter((f) => f.required).map((f) => f.name).join(", ")}`, + }); + return; + } const html = renderTemplate(id, data); const pdf = await renderPdf(html, { format: data._format || "A4", margin: data._margin, }); - const filename = data._filename || `${id}.pdf`; + const filename = sanitizeFilename(data._filename || `${id}.pdf`); res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.send(pdf); diff --git a/dist/services/browser.js b/dist/services/browser.js index 38e99c3..2ec7521 100644 --- a/dist/services/browser.js +++ b/dist/services/browser.js @@ -224,10 +224,45 @@ export async function renderUrlPdf(url, options = {}) { const { page, instance } = await acquirePage(); try { await page.setJavaScriptEnabled(false); + // Pin DNS resolution to prevent DNS rebinding SSRF attacks + if (options.hostResolverRules) { + const client = await page.createCDPSession(); + // Use Chrome DevTools Protocol to set host resolver rules per-page + await client.send("Network.enable"); + // Extract hostname and IP from rules like "MAP hostname ip" + const match = options.hostResolverRules.match(/^MAP\s+(\S+)\s+(\S+)$/); + if (match) { + const [, hostname, ip] = match; + await page.setRequestInterception(true); + page.on("request", (request) => { + const reqUrl = new URL(request.url()); + if (reqUrl.hostname === hostname) { + // For HTTP, rewrite to IP with Host header + if (reqUrl.protocol === "http:") { + reqUrl.hostname = ip; + request.continue({ + url: reqUrl.toString(), + headers: { ...request.headers(), host: hostname }, + }); + } + else { + // For HTTPS, we can't easily swap the IP without cert issues + // But we've already validated the IP, and the short window makes rebinding unlikely + // Combined with JS disabled, this is sufficient mitigation + request.continue(); + } + } + else { + // Block any requests to other hosts (prevent redirects to internal IPs) + request.abort("blockedbyclient"); + } + }); + } + } const result = await Promise.race([ (async () => { await page.goto(url, { - waitUntil: options.waitUntil || "networkidle0", + waitUntil: options.waitUntil || "domcontentloaded", timeout: 30_000, }); const pdf = await page.pdf({ diff --git a/dist/services/verification.js b/dist/services/verification.js index de1e61e..92e36d8 100644 --- a/dist/services/verification.js +++ b/dist/services/verification.js @@ -1,4 +1,4 @@ -import { randomBytes, randomInt } from "crypto"; +import { randomBytes, randomInt, timingSafeEqual } from "crypto"; import logger from "./logger.js"; import pool from "./db.js"; const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; @@ -87,7 +87,10 @@ export async function verifyCode(email, code) { return { status: "max_attempts" }; } await pool.query("UPDATE pending_verifications SET attempts = attempts + 1 WHERE email = $1", [cleanEmail]); - if (pending.code !== code) { + const a = Buffer.from(pending.code, "utf8"); + const b = Buffer.from(code, "utf8"); + const codeMatch = a.length === b.length && timingSafeEqual(a, b); + if (!codeMatch) { return { status: "invalid" }; } await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]); diff --git a/public/impressum.html b/public/impressum.html index 97968ad..7c13fc2 100644 --- a/public/impressum.html +++ b/public/impressum.html @@ -77,7 +77,8 @@ footer .container { display: flex; justify-content: space-between; align-items:

Responsible for Content

Cloonar Technologies GmbH
- Legal contact: legal@docfast.dev

+ Legal contact: legal@docfast.dev
+ Support: support@docfast.dev

Disclaimer

Despite careful content control, we assume no liability for the content of external links. The operators of the linked pages are solely responsible for their content.

@@ -97,6 +98,7 @@ footer .container { display: flex; justify-content: space-between; align-items: Docs API Status Change Email + Support Impressum Privacy Policy Terms of Service diff --git a/public/index.html b/public/index.html index 70f1ac6..fd15056 100644 --- a/public/index.html +++ b/public/index.html @@ -407,7 +407,7 @@ html, body {
  • 5,000 PDFs per month
  • All conversion endpoints
  • All templates included
  • -
  • Priority support
  • +
  • Priority support (support@docfast.dev)
  • @@ -423,6 +423,7 @@ html, body { Docs API Status Change Email + Support Impressum Privacy Policy Terms of Service diff --git a/public/openapi.json b/public/openapi.json index f3e5023..56ed829 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -4,7 +4,7 @@ "title": "DocFast API", "version": "1.0.0", "description": "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header.\n\n## Rate Limits\n- Free tier: 100 PDFs/month, 10 req/min\n- Pro tier: 10,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Sign up at [docfast.dev](https://docfast.dev) or via `POST /v1/signup/free`\n2. Verify your email with the 6-digit code\n3. Use your API key to convert documents", - "contact": { "name": "DocFast", "url": "https://docfast.dev" } + "contact": { "name": "DocFast", "url": "https://docfast.dev", "email": "support@docfast.dev" } }, "servers": [{ "url": "https://docfast.dev", "description": "Production" }], "tags": [ diff --git a/public/privacy.html b/public/privacy.html index bdb66e0..9a30228 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -180,6 +180,7 @@ footer .container { display: flex; justify-content: space-between; align-items: Docs API Status Change Email + Support Impressum Privacy Policy Terms of Service diff --git a/public/src/impressum.html b/public/src/impressum.html index a9a3524..e7548ff 100644 --- a/public/src/impressum.html +++ b/public/src/impressum.html @@ -32,7 +32,8 @@

    Responsible for Content

    Cloonar Technologies GmbH
    - Legal contact: legal@docfast.dev

    + Legal contact: legal@docfast.dev
    + Support: support@docfast.dev

    Disclaimer

    Despite careful content control, we assume no liability for the content of external links. The operators of the linked pages are solely responsible for their content.

    diff --git a/public/src/index.html b/public/src/index.html index f40dc96..fab6689 100644 --- a/public/src/index.html +++ b/public/src/index.html @@ -156,7 +156,7 @@
  • 5,000 PDFs per month
  • All conversion endpoints
  • All templates included
  • -
  • Priority support
  • +
  • Priority support (support@docfast.dev)
  • diff --git a/public/src/terms.html b/public/src/terms.html index d9aeb6e..eed630b 100644 --- a/public/src/terms.html +++ b/public/src/terms.html @@ -48,7 +48,7 @@
  • Price: €9 per month
  • Monthly limit: 10,000 PDF conversions
  • Rate limit: Higher limits based on fair use
  • -
  • Support: Priority email support
  • +
  • Support: Priority email support (support@docfast.dev)
  • Billing: Monthly subscription via Stripe
  • diff --git a/public/terms.html b/public/terms.html index 3ade338..7a0ad71 100644 --- a/public/terms.html +++ b/public/terms.html @@ -93,7 +93,7 @@ footer .container { display: flex; justify-content: space-between; align-items:
  • Price: €9 per month
  • Monthly limit: 10,000 PDF conversions
  • Rate limit: Higher limits based on fair use
  • -
  • Support: Priority email support
  • +
  • Support: Priority email support (support@docfast.dev)
  • Billing: Monthly subscription via Stripe
  • @@ -252,6 +252,7 @@ footer .container { display: flex; justify-content: space-between; align-items: Docs API Status Change Email + Support Impressum Privacy Policy Terms of Service diff --git a/src/index.ts b/src/index.ts index f63a0db..4fac345 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,13 +103,19 @@ app.use("/v1/email-change", emailChangeRouter); app.use("/v1/convert", authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter); app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter); -// Admin: usage stats -app.get("/v1/usage", authMiddleware, (req: any, res) => { +// Admin: usage stats (admin key required) +const adminAuth = (req: any, res: any, next: any) => { + const adminKey = process.env.ADMIN_API_KEY; + if (!adminKey) { res.status(503).json({ error: "Admin access not configured" }); return; } + if (req.apiKeyInfo?.key !== adminKey) { res.status(403).json({ error: "Admin access required" }); return; } + next(); +}; +app.get("/v1/usage", authMiddleware, adminAuth, (req: any, res: any) => { res.json(getUsageStats(req.apiKeyInfo?.key)); }); -// Admin: concurrency stats -app.get("/v1/concurrency", authMiddleware, (_req, res) => { +// Admin: concurrency stats (admin key required) +app.get("/v1/concurrency", authMiddleware, adminAuth, (_req: any, res: any) => { res.json(getConcurrencyStats()); }); diff --git a/src/routes/convert.ts b/src/routes/convert.ts index 4543cf9..46e5447 100644 --- a/src/routes/convert.ts +++ b/src/routes/convert.ts @@ -107,6 +107,12 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { let slotAcquired = false; try { + // Reject non-JSON content types + const ct = req.headers["content-type"] || ""; + if (!ct.includes("application/json")) { + res.status(415).json({ error: "Unsupported Content-Type. Use application/json." }); + return; + } const body: ConvertBody = typeof req.body === "string" ? { markdown: req.body } : req.body; @@ -151,6 +157,12 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { let slotAcquired = false; try { + // Reject non-JSON content types + const ct = req.headers["content-type"] || ""; + if (!ct.includes("application/json")) { + res.status(415).json({ error: "Unsupported Content-Type. Use application/json." }); + return; + } const body = req.body as { url?: string; format?: string; landscape?: boolean; margin?: any; printBackground?: boolean; waitUntil?: string; filename?: string }; if (!body.url) { diff --git a/src/routes/templates.ts b/src/routes/templates.ts index 0ddf66d..944bbd8 100644 --- a/src/routes/templates.ts +++ b/src/routes/templates.ts @@ -3,6 +3,10 @@ import { renderPdf } from "../services/browser.js"; import logger from "../services/logger.js"; import { templates, renderTemplate } from "../services/templates.js"; +function sanitizeFilename(name: string): string { + return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200); +} + export const templatesRouter = Router(); // GET /v1/templates — list available templates @@ -27,13 +31,28 @@ templatesRouter.post("/:id/render", async (req: Request, res: Response) => { } const data = req.body.data || req.body; + + // Validate required fields + const missingFields = template.fields + .filter((f) => f.required && (data[f.name] === undefined || data[f.name] === null || data[f.name] === "")) + .map((f) => f.name); + + if (missingFields.length > 0) { + res.status(400).json({ + error: "Missing required fields", + missing: missingFields, + hint: `Required fields for '${id}': ${template.fields.filter((f) => f.required).map((f) => f.name).join(", ")}`, + }); + return; + } + const html = renderTemplate(id, data); const pdf = await renderPdf(html, { format: data._format || "A4", margin: data._margin, }); - const filename = data._filename || `${id}.pdf`; + const filename = sanitizeFilename(data._filename || `${id}.pdf`); res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.send(pdf); diff --git a/src/services/browser.ts b/src/services/browser.ts index 51d37c1..9ff97a7 100644 --- a/src/services/browser.ts +++ b/src/services/browser.ts @@ -308,7 +308,7 @@ export async function renderUrlPdf( const result = await Promise.race([ (async () => { await page.goto(url, { - waitUntil: (options.waitUntil as any) || "networkidle0", + waitUntil: (options.waitUntil as any) || "domcontentloaded", timeout: 30_000, }); const pdf = await page.pdf({ diff --git a/templates/pages/impressum.html b/templates/pages/impressum.html index e376d03..43edaba 100644 --- a/templates/pages/impressum.html +++ b/templates/pages/impressum.html @@ -68,7 +68,8 @@ footer .container { display: flex; justify-content: space-between; align-items:

    Responsible for Content

    Cloonar Technologies GmbH
    - Legal contact: legal@docfast.dev

    + Legal contact: legal@docfast.dev
    + Support: support@docfast.dev

    Disclaimer

    Despite careful content control, we assume no liability for the content of external links. The operators of the linked pages are solely responsible for their content.

    diff --git a/templates/pages/index.html b/templates/pages/index.html index 045f3db..2485931 100644 --- a/templates/pages/index.html +++ b/templates/pages/index.html @@ -407,7 +407,7 @@ html, body {
  • 5,000 PDFs per month
  • All conversion endpoints
  • All templates included
  • -
  • Priority support
  • +
  • Priority support (support@docfast.dev)
  • diff --git a/templates/pages/terms.html b/templates/pages/terms.html index 2058153..a9ef8ba 100644 --- a/templates/pages/terms.html +++ b/templates/pages/terms.html @@ -84,7 +84,7 @@ footer .container { display: flex; justify-content: space-between; align-items:
  • Price: €9 per month
  • Monthly limit: 10,000 PDF conversions
  • Rate limit: Higher limits based on fair use
  • -
  • Support: Priority email support
  • +
  • Support: Priority email support (support@docfast.dev)
  • Billing: Monthly subscription via Stripe
  • diff --git a/templates/partials/footer.html b/templates/partials/footer.html index 9c41181..6c642fe 100644 --- a/templates/partials/footer.html +++ b/templates/partials/footer.html @@ -6,6 +6,7 @@ Docs API Status Change Email + Support Impressum Privacy Policy Terms of Service