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:
@@ -2,6 +2,13 @@
|
||||
|
||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||
|
||||
## 0.5.1 — 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.
|
||||
|
||||
## 0.5.0 — 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Security Audit — Epicure
|
||||
|
||||
Date: 2026-07-12
|
||||
Scope: full app (`apps/web`, `packages/db`), 3 independent rounds, each own lens.
|
||||
Bar: confidence ≥8/10 only.
|
||||
|
||||
---
|
||||
|
||||
## Round 1 — Auth & Authorization
|
||||
|
||||
### Finding 1: Stale cached role trusted for comment moderation — MEDIUM
|
||||
- File: `apps/web/app/api/v1/comments/[id]/route.ts:33`
|
||||
- Confidence: 8
|
||||
- Description: Route checks `session.user.role === "user"` directly. Role comes from Better Auth `cookieCache`, stale up to 5 min (`apps/web/lib/auth/server.ts:79-82`). `requireAdmin()` (`apps/web/lib/api-auth.ts:17-30`) already works around this by re-querying DB — this route doesn't.
|
||||
- Exploit: Admin demotes abusive moderator/admin to `user`. Demoted user's existing session still passes stale-role check for up to 5 min → can keep deleting other users' comments via `DELETE /api/v1/comments/[id]`.
|
||||
- Fix: Reuse fresh-DB-role pattern from `requireAdmin()` (or shared `requireRole()` helper) instead of trusting `session.user.role` directly.
|
||||
|
||||
### Finding 2: Tier entitlement not re-verified against DB — LOW/MEDIUM
|
||||
- Files: `apps/web/lib/auth/server.ts:79-82` (cookieCache) + all quota call sites (`api/v1/ai/generate/route.ts`, `ai/adapt/[id]/route.ts`, `ai/meal-plan/generate/route.ts`, `ai/batch-cook/generate/route.ts`, `apps/web/lib/tiers.ts:31`)
|
||||
- Confidence: 8
|
||||
- Description: Same cookieCache staleness as Finding 1 applies to `session.user.tier`, but `checkAndIncrementTierLimit` never re-checks DB tier.
|
||||
- Exploit: User downgraded pro→free (cancellation/chargeback). Stale session keeps pro-tier quota (AI calls, recipe count, storage) applying for up to 5 min. Bounded quota-bypass, not data breach.
|
||||
- Fix: Fetch `users.tier` fresh in `checkAndIncrementTierLimit`, mirroring `requireAdmin`.
|
||||
|
||||
**No other findings ≥8** — recipes/comments/ratings/meal-plans/shopping-lists/collections/admin routes/api-keys/webhooks/Better Auth config/proxy.ts/public share pages all reviewed clean.
|
||||
|
||||
---
|
||||
|
||||
## Round 2 — Injection & Input Validation
|
||||
|
||||
### Finding 1: SSRF via unfollowed redirect in AI URL-import — HIGH
|
||||
- File: `apps/web/lib/ai/features/import-url.ts:22-28`
|
||||
- Confidence: 9
|
||||
- Description: `POST /api/v1/ai/import-url` validates host via `validateWebhookUrl()` (blocks private/loopback/metadata ranges) but then `fetch(url, {...})` uses default `redirect: "follow"` — no restriction on where a 3xx response redirects to. `apps/web/lib/webhooks.ts:39-49` already hardens this with `redirect: "manual"` + comment explaining why; `import-url.ts` never got the same fix.
|
||||
- Exploit: Attacker hosts public page returning `302 Location: http://169.254.169.254/latest/meta-data/...` (or internal service). Initial host passes validation; fetch follows redirect straight to internal/metadata address, response fed into AI prompt.
|
||||
- Fix: Set `redirect: "manual"` in `import-url.ts`, treat 3xx as failure (mirror `webhooks.ts`).
|
||||
|
||||
### Finding 2: DNS-rebinding TOCTOU in `validateWebhookUrl` — MEDIUM
|
||||
- Files: `apps/web/lib/validate-webhook-url.ts:96-123`, consumed by `import-url.ts:22-25`
|
||||
- Confidence: 8
|
||||
- Description: Validation does its own `dns.lookup`; the real `fetch()` re-resolves independently moments later. Attacker controlling authoritative DNS (low TTL) can return public IP for validation, private IP for fetch. `webhooks.ts` has a comment accepting this as residual risk; `import-url.ts` has no such mitigation and also lacks redirect protection (compounds with Finding 1).
|
||||
- Fix: Resolve host once, validate resolved IP, connect directly to that IP (pin via custom `dns.lookup`/agent) instead of re-resolving in `fetch`. Apply to both `webhooks.ts` and `import-url.ts`.
|
||||
|
||||
### Finding 3: IDOR into S3 `deleteObject` via unvalidated photo storage keys — HIGH
|
||||
- Files: `apps/web/app/api/v1/recipes/route.ts:48-51`, `apps/web/app/api/v1/recipes/[id]/route.ts:45-48,211-231`, sink `apps/web/lib/storage.ts:40-42`
|
||||
- Confidence: 8
|
||||
- Description: `photos[].key` validated only as non-empty string — no check that key was actually issued to this user/recipe by `/api/v1/upload/presign` (no prefix check against `recipes/{recipeId}/photos/` + `session.user.id`). Storage keys of public recipes are visible in rendered `<Image>` src on `apps/web/app/r/[id]/page.tsx:52-58`. On `PUT`, any photo key present in old list but absent from new list gets passed to `deleteObject()` with no ownership check.
|
||||
- Exploit: Attacker views victim's public recipe, copies photo storage key from image URL. Calls `PUT /api/v1/recipes/{ownRecipeId}` with `photos: [{key: <victim's key>}]` (accepted, no ownership check), then `PUT` again without that key → diff logic treats it as "removed" → `deleteObject` permanently deletes victim's S3 object. No privilege required.
|
||||
- Fix: Track storage keys server-side as belonging to a specific recipe/user (row created at presign time, or HMAC-signed key). Validate every submitted photo key was issued for this recipe/user before accepting into `recipePhotos` or before deletion eligibility. At minimum prefix-match against `recipes/{recipeId}/` + `session.user.id`, and never `deleteObject` a key not already present in that recipe's existing photo set.
|
||||
|
||||
**No other findings ≥8** — all `sql\`...\`` usages parameterized correctly, no eval/exec usage, no raw filesystem path construction from user filenames elsewhere, Zod schemas otherwise bound ranges/enums/lengths properly.
|
||||
|
||||
---
|
||||
|
||||
## Round 3 — Crypto, Secrets Management & Data Exposure
|
||||
|
||||
**No findings ≥8.** Reviewed: `lib/auth/server.ts`, `lib/encrypt.ts`, `lib/gravatar.ts`, `lib/invites.ts`, `lib/api-auth.ts`, shopping-list public-share feature (`app/s/[id]/page.tsx`, `api/v1/shopping-lists/**`), profile-picture upload, `users/me/export`, api-keys/ai-keys, webhooks, Stripe/cron signature verification, broad sweep of routes joining/returning `users` rows.
|
||||
|
||||
Notable good patterns confirmed:
|
||||
- Share-link tokens use `crypto.randomUUID()` (122 bits), scoped queries never leak owner PII.
|
||||
- User-row queries always project to narrow DTO before serializing (password/email never leak).
|
||||
- All security-sensitive tokens use `crypto.randomBytes`/`randomUUID`; `Math.random()` only in non-security contexts.
|
||||
- `lib/encrypt.ts`: AES-256-GCM + HKDF-SHA256, random IV per encryption, auth-tag verified.
|
||||
- Webhook/cron secret checks use `crypto.timingSafeEqual` with length pre-check.
|
||||
- No TLS-verification bypasses, no hardcoded secrets.
|
||||
- `requireAdmin()` re-queries DB role rather than trusting session cache (see Round 1 Finding 1 — inconsistently applied elsewhere).
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Severity | Category | Location | Confidence |
|
||||
|---|----------|----------|----------|------------|
|
||||
| R2-F1 | HIGH | SSRF (redirect) | `lib/ai/features/import-url.ts:22-28` | 9 |
|
||||
| R2-F3 | HIGH | IDOR / arbitrary delete | `api/v1/recipes/[id]/route.ts:211-231` | 8 |
|
||||
| R1-F1 | MEDIUM | Stale-role authz bypass | `api/v1/comments/[id]/route.ts:33` | 8 |
|
||||
| R2-F2 | MEDIUM | SSRF (DNS rebinding) | `lib/validate-webhook-url.ts:96-123` | 8 |
|
||||
| R1-F2 | LOW/MEDIUM | Stale-tier quota bypass | `lib/tiers.ts:31` + call sites | 8 |
|
||||
|
||||
**Priority fix order**: R2-F1 (SSRF redirect) and R2-F3 (IDOR delete) first — both HIGH, both concrete exploit paths with no special privilege needed. Then R1-F1 (moderation bypass), R2-F2 (rebinding, compounds with F1), R1-F2 (quota bypass, low impact).
|
||||
|
||||
**Status: all 5 fixed** (`safeFetch` w/ IP-pinning + manual-redirect in `lib/validate-webhook-url.ts`; `isOwnedRecipePhotoKey` in `lib/storage.ts` enforced on recipe create/update; fresh-DB role check in `comments/[id]/route.ts`; fresh-DB tier check in `lib/tiers.ts`).
|
||||
|
||||
---
|
||||
|
||||
## Round 4 — Verification + fresh sweep
|
||||
|
||||
Re-verified all 5 fixes above: all PASS, no gaps, no regressions (full `pnpm typecheck` + `pnpm test` clean, same 2 pre-existing unrelated test failures as baseline `main`).
|
||||
|
||||
### Finding: IDOR on rating/review `photoKey` — same class as R2-F3, different endpoint — HIGH (fixed)
|
||||
- File: `apps/web/app/api/v1/recipes/[id]/rate/route.ts:10,41,54`
|
||||
- Confidence: 9
|
||||
- Description: `POST /api/v1/recipes/[id]/rate` accepted a client-supplied `photoKey` with no ownership check — the R2-F3 fix only covered `recipePhotos` (folder `photos/`), not review photos (folder `reviews/`), which use a different prefix format and weren't validated at all.
|
||||
- Exploit: Attacker reads a victim's review-photo storage key off any public recipe/review response, submits it as `photoKey` when rating an unrelated recipe. When that recipe's author later deletes their recipe, `recipes/[id]/route.ts` DELETE collects all `ratings.photoKey` for the recipe and calls `deleteObject()` unconditionally — destroying the victim's photo in a completely different recipe.
|
||||
- Fix applied: added `isOwnedReviewPhotoKey(key, recipeId, userId)` to `lib/storage.ts` (checks `recipes/{recipeId}/reviews/{userId}-` prefix), enforced in `rate/route.ts` before insert/update — rejects with 400 if the submitted key wasn't issued to this user for this recipe's reviews.
|
||||
|
||||
No other new HIGH/MEDIUM findings survived this round. Checked clean: admin role checks, internal cron auth, public shopping-list share endpoints, all raw `sql` usage, other `deleteObject`/presign call sites.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, comments, eq, and } from "@epicure/db";
|
||||
import { db, comments, users, eq } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
@@ -30,8 +30,17 @@ export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
|
||||
const comment = await db.query.comments.findFirst({ where: eq(comments.id, id) });
|
||||
if (!comment) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (comment.userId !== session!.user.id && session!.user.role === "user") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
if (comment.userId !== session!.user.id) {
|
||||
// session.user.role comes from a 5-minute cookieCache — a just-demoted
|
||||
// moderator/admin would keep access for up to 5 minutes. Re-query fresh.
|
||||
const [dbUser] = await db
|
||||
.select({ role: users.role })
|
||||
.from(users)
|
||||
.where(eq(users.id, session!.user.id))
|
||||
.limit(1);
|
||||
if (dbUser?.role !== "admin" && dbUser?.role !== "moderator") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
await db.delete(comments).where(eq(comments.id, id));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { db, recipes, ratings, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { createNotification } from "@/lib/notifications";
|
||||
import { isOwnedReviewPhotoKey } from "@/lib/storage";
|
||||
|
||||
const Schema = z.object({
|
||||
score: z.number().int().min(1).max(5),
|
||||
@@ -29,6 +30,10 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
if (parsed.data.photoKey && !isOwnedReviewPhotoKey(parsed.data.photoKey, id, session!.user.id)) {
|
||||
return NextResponse.json({ error: "Validation error", issues: [{ path: ["photoKey"], message: "Photo key not issued for this review" }] }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await db.query.ratings.findFirst({
|
||||
where: and(eq(ratings.recipeId, id), eq(ratings.userId, session!.user.id)),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeBatchD
|
||||
import { eq, and, max, isNotNull } from "@epicure/db";
|
||||
import { z } from "zod";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { deleteObject } from "@/lib/storage";
|
||||
import { deleteObject, isOwnedRecipePhotoKey } from "@/lib/storage";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
import { parseQuantity } from "@/lib/parse-quantity";
|
||||
import { extractIngredientQuantity } from "@/lib/extract-ingredient-quantity";
|
||||
@@ -106,6 +106,10 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
|
||||
const data = parsed.data;
|
||||
|
||||
if (data.photos?.some((p) => !isOwnedRecipePhotoKey(p.key, id, session!.user.id))) {
|
||||
return NextResponse.json({ error: "Validation error", issues: [{ path: ["photos"], message: "Photo key not issued for this recipe" }] }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Create a snapshot of the current state before updating
|
||||
const [maxVersionRow] = await tx
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeBatchD
|
||||
import { eq, desc, and } from "@epicure/db";
|
||||
import { z } from "zod";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { isOwnedRecipePhotoKey } from "@/lib/storage";
|
||||
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
import { parseQuantity } from "@/lib/parse-quantity";
|
||||
@@ -93,6 +94,14 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
const data = parsed.data;
|
||||
|
||||
if (data.photos.some((p) => !isOwnedRecipePhotoKey(p.key, id, session!.user.id))) {
|
||||
return NextResponse.json({ error: "Validation error", issues: [{ path: ["photos"], message: "Photo key not issued for this recipe" }] }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
|
||||
} catch (err) {
|
||||
@@ -102,10 +111,6 @@ export async function POST(req: NextRequest) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
const data = parsed.data;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(recipes).values({
|
||||
id,
|
||||
|
||||
@@ -32,6 +32,15 @@ export function ChangelogList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.security && entry.security.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Security</p>
|
||||
<ul className="space-y-1 text-sm list-disc pl-5">
|
||||
{entry.security.map((line, j) => <li key={j}>{line}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{i < CHANGELOG.length - 1 && <Separator className="mt-6" />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -15,7 +15,7 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.86",
|
||||
"@ai-sdk/openai": "^3.0.74",
|
||||
"@asteasolutions/zod-to-openapi": "^8.5.0",
|
||||
"@asteasolutions/zod-to-openapi": "^7.3.4",
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1075.0",
|
||||
"@base-ui/react": "^1.6.0",
|
||||
@@ -46,6 +46,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"undici": "^7.28.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Generated
+29
-26
@@ -21,8 +21,8 @@ importers:
|
||||
specifier: ^3.0.74
|
||||
version: 3.0.74(zod@3.25.76)
|
||||
'@asteasolutions/zod-to-openapi':
|
||||
specifier: ^8.5.0
|
||||
version: 8.5.0(zod@3.25.76)
|
||||
specifier: ^7.3.4
|
||||
version: 7.3.4(zod@3.25.76)
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.1075.0
|
||||
version: 3.1075.0
|
||||
@@ -113,6 +113,9 @@ importers:
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
undici:
|
||||
specifier: ^7.28.0
|
||||
version: 7.28.0
|
||||
web-push:
|
||||
specifier: ^3.6.7
|
||||
version: 3.6.7
|
||||
@@ -243,10 +246,10 @@ packages:
|
||||
'@asamuzakjp/nwsapi@2.3.9':
|
||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
||||
|
||||
'@asteasolutions/zod-to-openapi@8.5.0':
|
||||
resolution: {integrity: sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==}
|
||||
'@asteasolutions/zod-to-openapi@7.3.4':
|
||||
resolution: {integrity: sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA==}
|
||||
peerDependencies:
|
||||
zod: ^4.0.0
|
||||
zod: ^3.20.2
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||
@@ -5502,7 +5505,7 @@ snapshots:
|
||||
|
||||
'@asamuzakjp/nwsapi@2.3.9': {}
|
||||
|
||||
'@asteasolutions/zod-to-openapi@8.5.0(zod@3.25.76)':
|
||||
'@asteasolutions/zod-to-openapi@7.3.4(zod@3.25.76)':
|
||||
dependencies:
|
||||
openapi3-ts: 4.6.0
|
||||
zod: 3.25.76
|
||||
@@ -5958,7 +5961,7 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
|
||||
'@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.4.2
|
||||
'@better-fetch/fetch': 1.3.1
|
||||
@@ -5972,38 +5975,38 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@better-auth/drizzle-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))':
|
||||
'@better-auth/drizzle-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.2
|
||||
optionalDependencies:
|
||||
drizzle-orm: 0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9)
|
||||
|
||||
'@better-auth/kysely-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)':
|
||||
'@better-auth/kysely-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.2
|
||||
optionalDependencies:
|
||||
kysely: 0.29.2
|
||||
|
||||
'@better-auth/memory-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
|
||||
'@better-auth/memory-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.2
|
||||
|
||||
'@better-auth/mongo-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
|
||||
'@better-auth/mongo-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.2
|
||||
|
||||
'@better-auth/prisma-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
|
||||
'@better-auth/prisma-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.2
|
||||
|
||||
'@better-auth/telemetry@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)':
|
||||
'@better-auth/telemetry@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.2
|
||||
'@better-fetch/fetch': 1.3.1
|
||||
|
||||
@@ -7628,13 +7631,13 @@ snapshots:
|
||||
|
||||
better-auth@1.6.20(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.9):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))
|
||||
'@better-auth/kysely-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)
|
||||
'@better-auth/memory-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
|
||||
'@better-auth/mongo-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
|
||||
'@better-auth/prisma-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
|
||||
'@better-auth/telemetry': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)
|
||||
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))
|
||||
'@better-auth/kysely-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)
|
||||
'@better-auth/memory-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
|
||||
'@better-auth/mongo-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
|
||||
'@better-auth/prisma-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
|
||||
'@better-auth/telemetry': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)
|
||||
'@better-auth/utils': 0.4.2
|
||||
'@better-fetch/fetch': 1.3.1
|
||||
'@noble/ciphers': 2.2.0
|
||||
|
||||
Reference in New Issue
Block a user