Session 45: support email, audit fixes (template validation, content-type, admin auth, waitUntil)
All checks were successful
Deploy to Production / Deploy to Server (push) Successful in 2m20s

- 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
This commit is contained in:
OpenClaw 2026-02-16 19:30:21 +00:00
parent 8a86e34f91
commit 59cc8f3d0e
22 changed files with 166 additions and 61 deletions

View file

@ -65,7 +65,7 @@ a { color: #4f9; }
<div class="card">
<h1>🎉 Welcome to Pro!</h1>
<p>Your API key:</p>
<div class="key" style="position:relative">${escapeHtml(keyInfo.key)}<button onclick="navigator.clipboard.writeText('${escapeHtml(keyInfo.key)}');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)" style="position:absolute;top:8px;right:8px;background:#4f9;color:#0a0a0a;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-size:0.8rem;font-family:system-ui">Copy</button></div>
<div class="key" style="position:relative" data-key="${escapeHtml(keyInfo.key)}">${escapeHtml(keyInfo.key)}<button onclick="navigator.clipboard.writeText(this.parentElement.dataset.key);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)" style="position:absolute;top:8px;right:8px;background:#4f9;color:#0a0a0a;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-size:0.8rem;font-family:system-ui">Copy</button></div>
<p><strong>Save this key!</strong> It won't be shown again.</p>
<p>5,000 PDFs/month All endpoints Priority support</p>
<p><a href="/docs">View API docs </a></p>
@ -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);

View file

@ -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);

View file

@ -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);