fix: critical security issues - webhook bypass, SSRF, XSS
This commit is contained in:
parent
bba19442f4
commit
6a38ba4adc
2 changed files with 49 additions and 13 deletions
|
|
@ -2,6 +2,10 @@ import { Router, Request, Response } from "express";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { createProKey, revokeByCustomer } from "../services/keys.js";
|
import { createProKey, revokeByCustomer } from "../services/keys.js";
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
let _stripe: Stripe | null = null;
|
let _stripe: Stripe | null = null;
|
||||||
function getStripe(): Stripe {
|
function getStripe(): Stripe {
|
||||||
if (!_stripe) {
|
if (!_stripe) {
|
||||||
|
|
@ -69,7 +73,7 @@ a { color: #4f9; }
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>🎉 Welcome to Pro!</h1>
|
<h1>🎉 Welcome to Pro!</h1>
|
||||||
<p>Your API key:</p>
|
<p>Your API key:</p>
|
||||||
<div class="key" onclick="navigator.clipboard.writeText('${keyInfo.key}')" title="Click to copy">${keyInfo.key}</div>
|
<div class="key" onclick="navigator.clipboard.writeText('${escapeHtml(keyInfo.key)}')" title="Click to copy">${escapeHtml(keyInfo.key)}</div>
|
||||||
<p><strong>Save this key!</strong> It won't be shown again.</p>
|
<p><strong>Save this key!</strong> It won't be shown again.</p>
|
||||||
<p>10,000 PDFs/month • All endpoints • Priority support</p>
|
<p>10,000 PDFs/month • All endpoints • Priority support</p>
|
||||||
<p><a href="/docs">View API docs →</a></p>
|
<p><a href="/docs">View API docs →</a></p>
|
||||||
|
|
@ -87,16 +91,17 @@ router.post("/webhook", async (req: Request, res: Response) => {
|
||||||
|
|
||||||
let event: Stripe.Event;
|
let event: Stripe.Event;
|
||||||
|
|
||||||
if (webhookSecret && sig) {
|
if (!webhookSecret || !sig) {
|
||||||
try {
|
res.status(400).json({ error: "Missing webhook secret or signature" });
|
||||||
event = getStripe().webhooks.constructEvent(req.body, sig, webhookSecret);
|
return;
|
||||||
} catch (err: any) {
|
}
|
||||||
console.error("Webhook signature verification failed:", err.message);
|
|
||||||
res.status(400).json({ error: "Invalid signature" });
|
try {
|
||||||
return;
|
event = getStripe().webhooks.constructEvent(req.body, sig, webhookSecret);
|
||||||
}
|
} catch (err: any) {
|
||||||
} else {
|
console.error("Webhook signature verification failed:", err.message);
|
||||||
event = req.body as Stripe.Event;
|
res.status(400).json({ error: "Invalid signature" });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,24 @@
|
||||||
import { Router, Request, Response } from "express";
|
import { Router, Request, Response } from "express";
|
||||||
import { renderPdf, renderUrlPdf } from "../services/browser.js";
|
import { renderPdf, renderUrlPdf } from "../services/browser.js";
|
||||||
import { markdownToHtml, wrapHtml } from "../services/markdown.js";
|
import { markdownToHtml, wrapHtml } from "../services/markdown.js";
|
||||||
|
import dns from "node:dns/promises";
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
|
function isPrivateIP(ip: string): boolean {
|
||||||
|
// IPv6 loopback/unspecified
|
||||||
|
if (ip === "::1" || ip === "::") return true;
|
||||||
|
// IPv4-mapped IPv6
|
||||||
|
if (ip.startsWith("::ffff:")) ip = ip.slice(7);
|
||||||
|
if (!net.isIPv4(ip)) return false;
|
||||||
|
const parts = ip.split(".").map(Number);
|
||||||
|
if (parts[0] === 0) return true; // 0.0.0.0/8
|
||||||
|
if (parts[0] === 10) return true; // 10.0.0.0/8
|
||||||
|
if (parts[0] === 127) return true; // 127.0.0.0/8
|
||||||
|
if (parts[0] === 169 && parts[1] === 254) return true; // 169.254.0.0/16
|
||||||
|
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 172.16.0.0/12
|
||||||
|
if (parts[0] === 192 && parts[1] === 168) return true; // 192.168.0.0/16
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export const convertRouter = Router();
|
export const convertRouter = Router();
|
||||||
|
|
||||||
|
|
@ -93,9 +111,10 @@ convertRouter.post("/url", async (req: Request, res: Response) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic URL validation
|
// URL validation + SSRF protection
|
||||||
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(body.url);
|
parsed = new URL(body.url);
|
||||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||||
res.status(400).json({ error: "Only http/https URLs are supported" });
|
res.status(400).json({ error: "Only http/https URLs are supported" });
|
||||||
return;
|
return;
|
||||||
|
|
@ -105,6 +124,18 @@ convertRouter.post("/url", async (req: Request, res: Response) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DNS lookup to block private/reserved IPs
|
||||||
|
try {
|
||||||
|
const { address } = await dns.lookup(parsed.hostname);
|
||||||
|
if (isPrivateIP(address)) {
|
||||||
|
res.status(400).json({ error: "URL resolves to private/reserved IP" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({ error: "DNS lookup failed for URL hostname" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pdf = await renderUrlPdf(body.url, {
|
const pdf = await renderUrlPdf(body.url, {
|
||||||
format: body.format,
|
format: body.format,
|
||||||
landscape: body.landscape,
|
landscape: body.landscape,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue