diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8fde119..6289b50 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -96,6 +96,9 @@ importers:
clerk-solidjs:
specifier: ^2.0.10
version: 2.0.10(@solidjs/router@0.15.4(solid-js@1.9.13))(@solidjs/start@2.0.0-alpha.2(crossws@0.3.5)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)))(react@19.2.6)(solid-js@1.9.13)
+ dompurify:
+ specifier: ^3.4.7
+ version: 3.4.7
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)
@@ -105,6 +108,9 @@ importers:
ioredis:
specifier: ^5.10.1
version: 5.10.1
+ isomorphic-dompurify:
+ specifier: ^3.15.0
+ version: 3.15.0
jose:
specifier: ^5
version: 5.10.0
@@ -2120,6 +2126,9 @@ packages:
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
+ '@types/trusted-types@2.0.7':
+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -2711,6 +2720,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
+ dompurify@3.4.7:
+ resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==}
+
dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
@@ -3368,6 +3380,10 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ isomorphic-dompurify@3.15.0:
+ resolution: {integrity: sha512-9ZtkbQ8+SgNf6LuDAdu9bq23dVXMIGNM8ZYnyl2MufyZiSD5dqAUJcyjtYZz7B80HuPpEn/f0NCS6zKvavHtfA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
+
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
@@ -6846,6 +6862,9 @@ snapshots:
'@types/tough-cookie@4.0.5':
optional: true
+ '@types/trusted-types@2.0.7':
+ optional: true
+
'@types/unist@3.0.3': {}
'@types/webxr@0.5.24': {}
@@ -7409,6 +7428,10 @@ snapshots:
diff@8.0.4: {}
+ dompurify@3.4.7:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
dot-case@3.0.4:
dependencies:
no-case: 3.0.4
@@ -8136,6 +8159,14 @@ snapshots:
isexe@2.0.0: {}
+ isomorphic-dompurify@3.15.0:
+ dependencies:
+ dompurify: 3.4.7
+ jsdom: 29.1.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+ - canvas
+
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
diff --git a/web/package.json b/web/package.json
index 8435532..fb1e10d 100644
--- a/web/package.json
+++ b/web/package.json
@@ -29,9 +29,11 @@
"bcryptjs": "^3.0.3",
"bullmq": "^5.77.3",
"clerk-solidjs": "^2.0.10",
+ "dompurify": "^3.4.7",
"drizzle-orm": "^0.45.2",
"firebase-admin": "^13.10.0",
"ioredis": "^5.10.1",
+ "isomorphic-dompurify": "^3.15.0",
"jose": "^5",
"node-cron": "^4.2.1",
"pino": "^10.3.1",
diff --git a/web/src/lib/html-utils.test.ts b/web/src/lib/html-utils.test.ts
new file mode 100644
index 0000000..3cccf0e
--- /dev/null
+++ b/web/src/lib/html-utils.test.ts
@@ -0,0 +1,90 @@
+import { describe, it, expect } from "vitest";
+import { sanitizeHtml } from "./html-utils";
+
+describe("sanitizeHtml", () => {
+ it("strips
World
';
+ const output = sanitizeHtml(input);
+ expect(output).not.toContain("">click';
+ const output = sanitizeHtml(input);
+ expect(output).not.toContain("data:text/html");
+ expect(output).not.toContain("")).toBe(false);
+ expect(validateReturnUrl("mailto:test@test.com")).toBe(false);
+ });
+
+ it("rejects empty and whitespace strings", () => {
+ expect(validateReturnUrl("")).toBe(false);
+ expect(validateReturnUrl(" ")).toBe(false);
+ expect(validateReturnUrl("\t")).toBe(false);
+ });
+
+ it("rejects malformed URLs", () => {
+ expect(validateReturnUrl("not a url")).toBe(false);
+ expect(validateReturnUrl("://missing-protocol")).toBe(false);
+ });
+ });
+
+ describe("environment configuration", () => {
+ it("respects custom ALLOWED_RETURN_DOMAINS", () => {
+ process.env.ALLOWED_RETURN_DOMAINS = "myapp.example.com";
+ expect(validateReturnUrl("https://myapp.example.com/return")).toBe(true);
+ expect(validateReturnUrl("https://app.kordant.com/return")).toBe(false);
+ });
+
+ it("supports multiple custom domains", () => {
+ process.env.ALLOWED_RETURN_DOMAINS = "app.example.com,admin.example.com";
+ expect(validateReturnUrl("https://app.example.com/")).toBe(true);
+ expect(validateReturnUrl("https://admin.example.com/")).toBe(true);
+ expect(validateReturnUrl("https://evil.com/")).toBe(false);
+ });
+ });
+});
diff --git a/web/src/lib/url-validation.ts b/web/src/lib/url-validation.ts
new file mode 100644
index 0000000..bdc801b
--- /dev/null
+++ b/web/src/lib/url-validation.ts
@@ -0,0 +1,69 @@
+import { object, string, minLength, custom } from "valibot";
+
+function getAllowlist(): string[] {
+ const raw = process.env.ALLOWED_RETURN_DOMAINS ?? "app.kordant.com,admin.kordant.com";
+ return raw
+ .split(",")
+ .map((d) => d.trim().toLowerCase())
+ .filter(Boolean);
+}
+
+const LOCALHOST_DOMAINS = ["localhost", "127.0.0.1"];
+
+/**
+ * Validates that a URL points to a trusted domain.
+ * Rejects protocol-relative URLs, subdomain spoofing, and URL-encoded redirects.
+ */
+export function validateReturnUrl(url: string): boolean {
+ // Reject empty or whitespace-only strings
+ if (!url || !url.trim()) return false;
+
+ // Decode URL-encoded characters to prevent encoding tricks
+ let decoded: string;
+ try {
+ decoded = decodeURIComponent(url);
+ } catch {
+ return false;
+ }
+
+ // Reject protocol-relative URLs (//evil.com)
+ if (/^\/\//.test(decoded)) return false;
+
+ // Parse the URL
+ let parsed: URL;
+ try {
+ parsed = new URL(decoded);
+ } catch {
+ return false;
+ }
+
+ // Must be http or https
+ if (!["http:", "https:"].includes(parsed.protocol)) return false;
+
+ // Extract hostname (lowercase)
+ const hostname = parsed.hostname.toLowerCase();
+
+ // Check against allowlist - exact match or subdomain of allowed domain
+ const allowlist = [...LOCALHOST_DOMAINS, ...getAllowlist()];
+ for (const allowed of allowlist) {
+ if (hostname === allowed) return true;
+ if (hostname.endsWith(`.${allowed}`)) return true;
+ }
+
+ return false;
+}
+
+/**
+ * Valibot custom schema for return URL validation.
+ */
+export const returnUrlSchema = custom(
+ (value) => {
+ if (typeof value !== "string" || !validateReturnUrl(value)) {
+ return {
+ message:
+ "Return URL must point to a trusted domain. Only app.kordant.com and admin.kordant.com are allowed.",
+ };
+ }
+ return value;
+ },
+);
diff --git a/web/src/middleware.test.ts b/web/src/middleware.test.ts
new file mode 100644
index 0000000..ec9fe56
--- /dev/null
+++ b/web/src/middleware.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect } from "vitest";
+
+/**
+ * Mirrors the isValidCorsOrigin function from middleware.ts
+ */
+function isValidCorsOrigin(origin: string): boolean {
+ if (!origin || !origin.trim()) return false;
+ if (origin === "*") return false;
+
+ try {
+ const parsed = new URL(origin);
+ if (!parsed.protocol.match(/^https?:$/)) return false;
+ if (!parsed.hostname) return false;
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+describe("isValidCorsOrigin", () => {
+ describe("accepted origins", () => {
+ it("accepts valid HTTPS origins", () => {
+ expect(isValidCorsOrigin("https://app.kordant.com")).toBe(true);
+ expect(isValidCorsOrigin("https://admin.kordant.com")).toBe(true);
+ expect(isValidCorsOrigin("https://localhost:3000")).toBe(true);
+ });
+
+ it("accepts valid HTTP origins", () => {
+ expect(isValidCorsOrigin("http://localhost:3000")).toBe(true);
+ expect(isValidCorsOrigin("http://localhost:3001")).toBe(true);
+ expect(isValidCorsOrigin("http://127.0.0.1:8080")).toBe(true);
+ });
+
+ it("accepts origins with ports", () => {
+ expect(isValidCorsOrigin("https://app.kordant.com:8443")).toBe(true);
+ expect(isValidCorsOrigin("http://localhost:5173")).toBe(true);
+ });
+
+ it("accepts origins with paths", () => {
+ expect(isValidCorsOrigin("https://app.kordant.com/api")).toBe(true);
+ });
+ });
+
+ describe("rejected origins", () => {
+ it("rejects wildcard", () => {
+ expect(isValidCorsOrigin("*")).toBe(false);
+ });
+
+ it("rejects missing scheme", () => {
+ expect(isValidCorsOrigin("evil.com")).toBe(false);
+ expect(isValidCorsOrigin("localhost")).toBe(false);
+ expect(isValidCorsOrigin("app.kordant.com")).toBe(false);
+ });
+
+ it("rejects non-HTTP schemes", () => {
+ expect(isValidCorsOrigin("ftp://evil.com")).toBe(false);
+ expect(isValidCorsOrigin("file:///etc/passwd")).toBe(false);
+ expect(isValidCorsOrigin("javascript:alert(1)")).toBe(false);
+ expect(isValidCorsOrigin("data:text/html,test")).toBe(false);
+ });
+
+ it("rejects empty and whitespace strings", () => {
+ expect(isValidCorsOrigin("")).toBe(false);
+ expect(isValidCorsOrigin(" ")).toBe(false);
+ expect(isValidCorsOrigin("\t")).toBe(false);
+ });
+
+ it("rejects malformed URLs", () => {
+ expect(isValidCorsOrigin("not a url")).toBe(false);
+ expect(isValidCorsOrigin("://missing-protocol")).toBe(false);
+ });
+ });
+});
diff --git a/web/src/middleware.ts b/web/src/middleware.ts
index 79cad22..ea771d9 100644
--- a/web/src/middleware.ts
+++ b/web/src/middleware.ts
@@ -18,13 +18,42 @@ const securityHeaders: RequestMiddleware = (event) => {
h.set("X-Permitted-Cross-Domain-Policies", "none");
};
+/**
+ * Validates that an origin string is a well-formed HTTP(S) origin.
+ * Rejects wildcards, empty strings, non-HTTP schemes, and malformed URLs.
+ */
+function isValidCorsOrigin(origin: string): boolean {
+ if (!origin || !origin.trim()) return false;
+ if (origin === "*") return false;
+
+ try {
+ const parsed = new URL(origin);
+ // Only allow http and https schemes
+ if (!parsed.protocol.match(/^https?:$/)) return false;
+ // Hostname must not be empty
+ if (!parsed.hostname) return false;
+ return true;
+ } catch {
+ return false;
+ }
+}
+
const corsHeaders: RequestMiddleware = (event) => {
const origin = event.request.headers.get("origin");
const allowedOrigins = [
"http://localhost:3000",
"http://localhost:3001",
- process.env.APP_URL,
- ].filter(Boolean);
+ ];
+
+ // Validate APP_URL before trusting it as a CORS origin
+ const appUrl = process.env.APP_URL;
+ if (appUrl) {
+ if (isValidCorsOrigin(appUrl)) {
+ allowedOrigins.push(appUrl);
+ } else {
+ console.warn(`[cors] APP_URL "${appUrl}" is not a valid HTTP(S) origin and will be excluded from CORS allowlist`);
+ }
+ }
if (origin && allowedOrigins.includes(origin)) {
event.response.headers.set("Access-Control-Allow-Origin", origin);
diff --git a/web/src/routes/api/stripe/webhook.test.ts b/web/src/routes/api/stripe/webhook.test.ts
new file mode 100644
index 0000000..6ae5efd
--- /dev/null
+++ b/web/src/routes/api/stripe/webhook.test.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// Mock the modules that have side effects
+vi.mock("~/server/stripe", () => ({
+ stripe: {
+ webhooks: {
+ constructEvent: vi.fn(),
+ },
+ subscriptions: {
+ retrieve: vi.fn(),
+ },
+ },
+}));
+
+vi.mock("~/server/services/billing.service", () => ({
+ handleWebhookEvent: vi.fn(),
+}));
+
+vi.mock("~/server/db", () => ({
+ db: {
+ select: vi.fn().mockReturnValue({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockReturnValue({
+ limit: vi.fn().mockResolvedValue([]),
+ }),
+ }),
+ }),
+ insert: vi.fn().mockReturnValue({
+ values: vi.fn().mockReturnValue({
+ onConflictDoNothing: vi.fn().mockResolvedValue(undefined),
+ }),
+ }),
+ delete: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue(undefined),
+ }),
+ },
+}));
+
+vi.mock("drizzle-orm", () => ({
+ eq: vi.fn((col: any, val: any) => ({ column: col, value: val })),
+ lt: vi.fn((col: any, val: any) => ({ column: col, value: val })),
+}));
+
+describe("Webhook deduplication", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should construct event from signed payload", async () => {
+ const { stripe } = await import("~/server/stripe");
+ const mockEvent = {
+ id: "evt_test123",
+ type: "checkout.session.completed",
+ data: { object: {} },
+ };
+ vi.mocked(stripe.webhooks.constructEvent).mockReturnValue(mockEvent as any);
+
+ const mockEvent2 = {
+ id: "evt_test123",
+ type: "checkout.session.completed",
+ data: { object: {} },
+ };
+ vi.mocked(stripe.webhooks.constructEvent).mockReturnValue(
+ mockEvent2 as any,
+ );
+
+ expect(stripe.webhooks.constructEvent).toBeDefined();
+ });
+
+ it("should return 400 for missing signature", async () => {
+ // This tests the webhook handler behavior
+ const { POST } = await import("./webhook");
+ expect(POST).toBeDefined();
+ });
+
+ it("should check for duplicate event ID before processing", async () => {
+ const { db } = await import("~/server/db");
+ const { stripeWebhookEvents } = await import(
+ "~/server/db/schema/webhook-events"
+ );
+ const { eq } = await import("drizzle-orm");
+
+ // Verify the table and query functions are available
+ expect(stripeWebhookEvents).toBeDefined();
+ expect(eq).toBeDefined();
+ expect(db.select).toBeDefined();
+ });
+
+ it("should clean up old webhook events", async () => {
+ const { db } = await import("~/server/db");
+ const { stripeWebhookEvents } = await import(
+ "~/server/db/schema/webhook-events"
+ );
+ const { lt } = await import("drizzle-orm");
+
+ // Verify cleanup function can be called
+ expect(stripeWebhookEvents).toBeDefined();
+ expect(lt).toBeDefined();
+ expect(db.delete).toBeDefined();
+ });
+});
diff --git a/web/src/routes/api/stripe/webhook.ts b/web/src/routes/api/stripe/webhook.ts
index 9773a65..ba29f02 100644
--- a/web/src/routes/api/stripe/webhook.ts
+++ b/web/src/routes/api/stripe/webhook.ts
@@ -1,27 +1,68 @@
import type { APIEvent } from "@solidjs/start/server";
+import { eq, lt } from "drizzle-orm";
+import { db } from "~/server/db";
import { stripe } from "~/server/stripe";
import { handleWebhookEvent } from "~/server/services/billing.service";
+import { stripeWebhookEvents } from "~/server/db/schema/webhook-events";
+
+/**
+ * Cleans up webhook event records older than 30 days to prevent unbounded table growth.
+ */
+export async function cleanupWebhookEvents(): Promise {
+ try {
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
+ await db
+ .delete(stripeWebhookEvents)
+ .where(lt(stripeWebhookEvents.processedAt, thirtyDaysAgo));
+ console.log("[webhook] Cleaned up old webhook event records (30+ days)");
+ } catch (err) {
+ console.error("[webhook] Failed to clean up old webhook events:", err);
+ }
+}
export async function POST(event: APIEvent) {
- const body = await event.request.text();
- const signature = event.request.headers.get("stripe-signature");
+ const body = await event.request.text();
+ const signature = event.request.headers.get("stripe-signature");
- if (!signature) {
- return new Response("Missing stripe-signature header", { status: 400 });
- }
+ if (!signature) {
+ return new Response("Missing stripe-signature header", { status: 400 });
+ }
- try {
- const webhookEvent = stripe.webhooks.constructEvent(
- body,
- signature,
- process.env.STRIPE_WEBHOOK_SECRET ?? "",
- );
+ try {
+ const webhookEvent = stripe.webhooks.constructEvent(
+ body,
+ signature,
+ process.env.STRIPE_WEBHOOK_SECRET ?? "",
+ );
- await handleWebhookEvent(webhookEvent);
+ // Check for duplicate event ID (webhook replay protection)
+ const existing = await db
+ .select()
+ .from(stripeWebhookEvents)
+ .where(eq(stripeWebhookEvents.id, webhookEvent.id))
+ .limit(1);
- return new Response("OK", { status: 200 });
- } catch (err) {
- const message = err instanceof Error ? err.message : "Webhook error";
- return new Response(message, { status: 400 });
- }
+ if (existing.length > 0) {
+ console.log(
+ `[webhook] Duplicate event ${webhookEvent.id} (${webhookEvent.type}) — skipping`,
+ );
+ return new Response("OK", { status: 200 });
+ }
+
+ // Record the event ID with unique constraint for race condition safety
+ await db
+ .insert(stripeWebhookEvents)
+ .values({
+ id: webhookEvent.id,
+ type: webhookEvent.type,
+ })
+ .onConflictDoNothing();
+
+ await handleWebhookEvent(webhookEvent);
+
+ return new Response("OK", { status: 200 });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Webhook error";
+ return new Response(message, { status: 400 });
+ }
}
diff --git a/web/src/routes/blog/[slug].tsx b/web/src/routes/blog/[slug].tsx
index 3f637d5..aa90e9a 100644
--- a/web/src/routes/blog/[slug].tsx
+++ b/web/src/routes/blog/[slug].tsx
@@ -2,6 +2,7 @@ import { For, Show, createMemo, createResource, Suspense } from "solid-js";
import { Title } from "@solidjs/meta";
import { A, useParams } from "@solidjs/router";
import { cn } from "~/lib/utils";
+import { sanitizeHtml } from "~/lib/html-utils";
import { Badge, Card, Button } from "~/components/ui";
import PageContainer from "~/components/layout/PageContainer";
import { api } from "~/lib/api";
@@ -118,7 +119,7 @@ export default function BlogPostPage() {
-
+