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

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