fix: close SSRF/rebinding, IDOR, and stale-session authz gaps found in audit

Bump to 0.5.1. Fixes: unfollowed-redirect SSRF + DNS-rebinding in AI
url-import and webhook dispatch (new safeFetch with IP-pinned undici
dispatcher); cross-user photo deletion via unvalidated recipe/review
storage keys; comment-moderation and tier-quota checks trusting a
stale cached session role/tier instead of the DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-12 19:05:20 +02:00
parent 4f36ce24b2
commit 5a9e306357
17 changed files with 307 additions and 54 deletions
+14
View File
@@ -10,6 +10,7 @@ const mockDb = vi.hoisted(() => ({
vi.mock("@epicure/db", () => ({
db: mockDb,
tierDefinitions: { tier: "tier" },
users: { id: "id", tier: "tier" },
userUsage: {
userId: "user_id",
month: "month",
@@ -65,6 +66,7 @@ describe("checkAndIncrementTierLimit", () => {
};
it("does not throw when the atomic upsert returns a row (under limit)", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
@@ -72,6 +74,7 @@ describe("checkAndIncrementTierLimit", () => {
});
it("throws TierLimitError when the upsert's WHERE clause excludes the row (limit reached)", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([]);
@@ -79,13 +82,24 @@ describe("checkAndIncrementTierLimit", () => {
});
it("throws TierLimitError for recipe key when limit reached", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([]);
await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
});
it("re-reads the current tier from the DB instead of trusting the caller's fallbackTier", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "pro" }]));
mockDb.select.mockReturnValueOnce(makeChain([{ ...tierDef, tier: "pro" }]));
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
// caller still thinks it's "free" (stale session), but DB says "pro"
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
});
it("does not throw when tier definition does not exist", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([]));
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
+2 -5
View File
@@ -1,7 +1,7 @@
import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
import { safeFetch } from "@/lib/validate-webhook-url";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const ImportedRecipeSchema = z.object({
@@ -19,10 +19,7 @@ const ImportedRecipeSchema = z.object({
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> {
const ssrfError = await validateWebhookUrl(url);
if (ssrfError) throw new Error(ssrfError);
const res = await fetch(url, {
const res = await safeFetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; Epicure/1.0; recipe importer)" },
signal: AbortSignal.timeout(10000),
});
+11 -1
View File
@@ -1,15 +1,25 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.5.0";
export const APP_VERSION = "0.5.1";
export type ChangelogEntry = {
version: string;
date: string;
added?: string[];
fixed?: string[];
security?: string[];
notes?: string;
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.5.1",
date: "2026-07-12",
security: [
"Recipe/review photo uploads: a stolen storage key from another user's public recipe or review could be submitted as your own, causing that user's photo to be deleted from storage when the recipe was later edited or deleted. Both routes now check the key was actually issued to you for that recipe.",
"AI URL-import could be redirected to internal/private network addresses via a crafted 3xx response, and was separately vulnerable to DNS-rebinding between validation and fetch. Both the import and outgoing webhook delivery now resolve the host once and pin the connection to that address, re-validating on every redirect hop.",
"Comment moderation and monthly AI/recipe/storage quota checks trusted a session field that can lag up to 5 minutes behind a role or tier change — both now re-check the current value in the database.",
],
},
{
version: "0.5.0",
date: "2026-07-12",
+16
View File
@@ -44,3 +44,19 @@ export async function deleteObject(key: string): Promise<void> {
export function getPublicUrl(key: string): string {
return `${clientPublicUrl}/${bucket}/${key}`;
}
/**
* A recipe/review photo's storage key is only trustworthy if it matches the
* exact prefix issued by /api/v1/upload/presign for this recipe + user +
* purpose — otherwise a client could submit another user's key (discoverable
* from a public recipe's rendered <Image> src, or a review's photoKey in API
* responses) and have it adopted, then later evicted, triggering
* deleteObject() against an object it never owned.
*/
export function isOwnedRecipePhotoKey(key: string, recipeId: string, userId: string): boolean {
return key.startsWith(`recipes/${recipeId}/photos/${userId}-`);
}
export function isOwnedReviewPhotoKey(key: string, recipeId: string, userId: string): boolean {
return key.startsWith(`recipes/${recipeId}/reviews/${userId}-`);
}
+10 -2
View File
@@ -1,5 +1,5 @@
import { db } from "@epicure/db";
import { tierDefinitions, userUsage } from "@epicure/db";
import { tierDefinitions, userUsage, users } from "@epicure/db";
import { eq, sql } from "@epicure/db";
function currentMonth() {
@@ -24,16 +24,24 @@ export class TierLimitError extends Error {
* single SQL statement, eliminating the TOCTOU race that existed when
* checkTierLimit and incrementUsage were called separately.
*
* The caller's `userTier` is never trusted directly — it comes from the
* session's 5-minute cookieCache (see lib/auth/server.ts), so a just-downgraded
* user would otherwise keep the old tier's caps for up to 5 minutes. The
* current tier is always re-read from the DB here.
*
* Throws TierLimitError if the limit has already been reached.
* Use this instead of the separate checkTierLimit + incrementUsage pair
* for "recipe" and "aiCall" keys.
*/
export async function checkAndIncrementTierLimit(
userId: string,
userTier: "free" | "pro",
fallbackTier: "free" | "pro",
key: "recipe" | "aiCall" | "storage",
amount = 1
): Promise<void> {
const [dbUser] = await db.select({ tier: users.tier }).from(users).where(eq(users.id, userId));
const userTier = (dbUser?.tier as "free" | "pro" | undefined) ?? fallbackTier;
const [tierDef] = await db
.select()
.from(tierDefinitions)
+72
View File
@@ -1,5 +1,6 @@
import dns from "node:dns/promises";
import net from "node:net";
import { Agent } from "undici";
function isPrivateV4(a: number, b: number): boolean {
if (a === 127) return true;
@@ -121,3 +122,74 @@ export async function validateWebhookUrl(rawUrl: string): Promise<string | null>
return null;
}
/** Resolves hostname once and returns the first non-private address, or throws. */
async function resolvePinnedAddress(hostname: string): Promise<{ address: string; family: number }> {
let results: { address: string; family: number }[];
try {
results = await dns.lookup(hostname, { all: true, family: 0 });
} catch {
throw new Error("Unable to resolve hostname");
}
const safe = results.find((r) => !isPrivateAddress(r.address));
if (!safe) throw new Error("URL must not point to a private or reserved address");
return safe;
}
/**
* SSRF-safe fetch: resolves the hostname once, validates the resolved IP, then
* pins the actual connection to that exact IP (via a custom undici Agent lookup)
* so a subsequent DNS re-resolution by the HTTP client can't be rebound to an
* internal address. Redirects are followed manually, with each hop re-validated
* and re-pinned the same way — so a 3xx response can't be used to reach an
* internal service either.
*/
export async function safeFetch(
rawUrl: string,
init: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal } = {},
maxRedirects = 5
): Promise<Response> {
let currentUrl = rawUrl;
for (let hop = 0; ; hop++) {
let url: URL;
try {
url = new URL(currentUrl);
} catch {
throw new Error("Invalid URL");
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
throw new Error("URL must use http or https");
}
const { address, family } = await resolvePinnedAddress(url.hostname);
const agent = new Agent({
connect: {
lookup: (_hostname, _opts, callback) => {
callback(null, [{ address, family: family as 4 | 6 }]);
},
},
});
// Node's global fetch is undici under the hood and accepts the same
// `dispatcher` extension, so this pins the connection without needing a
// separate undici-specific fetch call.
const res = await fetch(currentUrl, {
...init,
redirect: "manual",
// @ts-expect-error -- `dispatcher` is a Node/undici fetch extension, not in the lib.dom.d.ts RequestInit type
dispatcher: agent,
});
if (res.status >= 300 && res.status < 400) {
if (hop >= maxRedirects) throw new Error("Too many redirects");
const location = res.headers.get("location");
if (!location) throw new Error("Redirect with no Location header");
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return res;
}
}
+5 -9
View File
@@ -2,7 +2,7 @@ import crypto from "crypto";
import { db } from "@epicure/db";
import { webhooks, webhookDeliveries } from "@epicure/db";
import { eq, and } from "@epicure/db";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
import { safeFetch } from "@/lib/validate-webhook-url";
export const WEBHOOK_EVENTS = [
"recipe.created",
@@ -31,12 +31,10 @@ export async function dispatchWebhook(userId: string, event: WebhookEvent, paylo
let statusCode = 0;
let success = false;
try {
// Re-validate at dispatch time to defeat DNS rebinding between create and
// dispatch. A small window remains between this lookup and fetch's own
// resolution of the hostname — accepted for now.
const ssrfError = await validateWebhookUrl(hook.url);
if (ssrfError) throw new Error(ssrfError);
const res = await fetch(hook.url, {
// safeFetch resolves and pins the connection to a single validated IP
// (and re-validates + re-pins every redirect hop), so there's no
// separate re-resolution for a rebinding attack to exploit.
const res = await safeFetch(hook.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -44,8 +42,6 @@ export async function dispatchWebhook(userId: string, event: WebhookEvent, paylo
"X-Epicure-Event": event,
},
body,
// Redirects could point at internal services, so treat 3xx as failure.
redirect: "manual",
signal: AbortSignal.timeout(10000),
});
statusCode = res.status;