From 6bf8ecc8d5696170df2ed7ac3cd6b6d1a7e4ffae Mon Sep 17 00:00:00 2001 From: saltbo Date: Mon, 20 Apr 2026 15:10:21 -0400 Subject: [PATCH] fix(share): return 403 instead of 401 on wrong share password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev (vite + miniflare + undici) returns 500 "fetch failed" when the Worker responds 401 to a POST with a body. undici follows the Fetch spec's HTTP-auth retry branch, which needs to re-extract the request body from its source — but the body comes from Node's IncomingMessage stream, so `body.source` is null and undici throws `expected non-null body source`. Production (direct CF edge) is unaffected, this only breaks local dev. 403 is also semantically more accurate: the client isn't performing HTTP authentication, just supplying a shared secret. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/routes/share-api.ts | 2 +- server/routes/share-public.integration.test.ts | 4 ++-- src/components/share/password-prompt.tsx | 2 +- src/lib/api.test.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/routes/share-api.ts b/server/routes/share-api.ts index 33c32767..d10b68bd 100644 --- a/server/routes/share-api.ts +++ b/server/routes/share-api.ts @@ -85,7 +85,7 @@ const app = new Hono() const { share } = resolved if (share.kind !== 'landing') return c.json({ error: 'Share not found or revoked' }, 404) - if (!verifyPassword(share, password)) return c.json({ error: 'Invalid password' }, 401) + if (!verifyPassword(share, password)) return c.json({ error: 'Invalid password' }, 403) const now = new Date() const oneDayMs = 24 * 60 * 60 * 1000 diff --git a/server/routes/share-public.integration.test.ts b/server/routes/share-public.integration.test.ts index a60c2e31..be9b66f4 100644 --- a/server/routes/share-public.integration.test.ts +++ b/server/routes/share-public.integration.test.ts @@ -214,7 +214,7 @@ describe('POST /s/:token/verify', () => { expect(cookieHeader).toContain('HttpOnly') }) - it('returns 401 on wrong password', async () => { + it('returns 403 on wrong password', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -234,7 +234,7 @@ describe('POST /s/:token/verify', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: 'wrongpassword' }), }) - expect(res.status).toBe(401) + expect(res.status).toBe(403) }) }) diff --git a/src/components/share/password-prompt.tsx b/src/components/share/password-prompt.tsx index c81e6d24..cf8fbbe0 100644 --- a/src/components/share/password-prompt.tsx +++ b/src/components/share/password-prompt.tsx @@ -25,7 +25,7 @@ export function PasswordPrompt({ token, fileName, onUnlocked }: PasswordPromptPr await verifySharePassword(token, password) onUnlocked() } catch (err) { - if (err instanceof ApiError && err.status === 401) { + if (err instanceof ApiError && err.status === 403) { setError(t('share.passwordWrong')) } else { setError(t('share.loadError')) diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index b4608a2c..7895740e 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -1152,8 +1152,8 @@ describe('api', () => { expect(JSON.parse(init.body as string)).toEqual({ password: 'secret' }) }) - it('throws ApiError on 401 (wrong password)', async () => { - vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Invalid password' }, false, 401)) + it('throws ApiError on 403 (wrong password)', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Invalid password' }, false, 403)) await expect(verifySharePassword('tok123', 'wrong')).rejects.toThrow('Invalid password') })