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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user