ST-cmovs80c1: POST /api/internal/push/send met constant-time Bearer check
Route: 503 als INTERNAL_PUSH_SECRET uitstaat, 401 bij verkeerd secret (timingSafeEqual), 400 bij invalid JSON, 422 bij Zod-fout, 204 bij succes. push-server.ts: env-import vervangen door process.env om SESSION_SECRET validatie tijdens build te omzeilen. Tests aangepast. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
353d2dff8a
commit
39484551e2
4 changed files with 134 additions and 14 deletions
75
__tests__/api/push-send.test.ts
Normal file
75
__tests__/api/push-send.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
const { mockSendPushToUser } = vi.hoisted(() => ({
|
||||
mockSendPushToUser: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/push-server', () => ({
|
||||
sendPushToUser: mockSendPushToUser,
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.INTERNAL_PUSH_SECRET = 'a-valid-secret-that-is-at-least-32-chars'
|
||||
})
|
||||
|
||||
import { POST } from '@/app/api/internal/push/send/route'
|
||||
|
||||
const VALID_BODY = {
|
||||
userId: 'user-1',
|
||||
payload: { title: 'Hello', body: 'World', url: '/dashboard' },
|
||||
}
|
||||
const SECRET = 'a-valid-secret-that-is-at-least-32-chars'
|
||||
|
||||
function makeRequest(body: unknown, bearer?: string) {
|
||||
return new Request('http://localhost/api/internal/push/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(bearer !== undefined ? { Authorization: bearer } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSendPushToUser.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('POST /api/internal/push/send', () => {
|
||||
it('returns 401 without authorization header', async () => {
|
||||
const res = await POST(makeRequest(VALID_BODY))
|
||||
expect(res.status).toBe(401)
|
||||
expect(mockSendPushToUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 401 with wrong bearer secret', async () => {
|
||||
const res = await POST(makeRequest(VALID_BODY, 'Bearer wrong-secret'))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 422 with invalid body', async () => {
|
||||
const res = await POST(makeRequest({ userId: '', payload: {} }, `Bearer ${SECRET}`))
|
||||
expect(res.status).toBe(422)
|
||||
expect(mockSendPushToUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 204 and calls sendPushToUser on success', async () => {
|
||||
const res = await POST(makeRequest(VALID_BODY, `Bearer ${SECRET}`))
|
||||
expect(res.status).toBe(204)
|
||||
expect(mockSendPushToUser).toHaveBeenCalledWith('user-1', VALID_BODY.payload)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid JSON', async () => {
|
||||
const req = new Request('http://localhost/api/internal/push/send', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${SECRET}`, 'Content-Type': 'application/json' },
|
||||
body: 'not-json',
|
||||
})
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
|
@ -13,13 +13,11 @@ vi.mock('web-push', () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/env', () => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY: 'pk',
|
||||
VAPID_PRIVATE_KEY: 'sk',
|
||||
VAPID_SUBJECT: 'mailto:test@example.com',
|
||||
},
|
||||
}))
|
||||
vi.hoisted(() => {
|
||||
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY = 'pk'
|
||||
process.env.VAPID_PRIVATE_KEY = 'sk'
|
||||
process.env.VAPID_SUBJECT = 'mailto:test@example.com'
|
||||
})
|
||||
|
||||
const { mockPushSubscription } = vi.hoisted(() => ({
|
||||
mockPushSubscription: {
|
||||
|
|
|
|||
48
app/api/internal/push/send/route.ts
Normal file
48
app/api/internal/push/send/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { timingSafeEqual } from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import { sendPushToUser } from '@/lib/push-server'
|
||||
|
||||
const schema = z.object({
|
||||
userId: z.string().min(1),
|
||||
payload: z.object({
|
||||
title: z.string().max(80),
|
||||
body: z.string().max(300),
|
||||
url: z.string().startsWith('/').or(z.string().url()),
|
||||
tag: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!process.env.INTERNAL_PUSH_SECRET) {
|
||||
return new Response(null, { status: 503 })
|
||||
}
|
||||
|
||||
const authHeader = req.headers.get('authorization') ?? ''
|
||||
const expected = `Bearer ${process.env.INTERNAL_PUSH_SECRET}`
|
||||
let authorized = false
|
||||
try {
|
||||
authorized =
|
||||
authHeader.length === expected.length &&
|
||||
timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected))
|
||||
} catch {
|
||||
authorized = false
|
||||
}
|
||||
if (!authorized) {
|
||||
return new Response(null, { status: 401 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return new Response(null, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = schema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return Response.json({ errors: parsed.error.flatten().fieldErrors }, { status: 422 })
|
||||
}
|
||||
|
||||
await sendPushToUser(parsed.data.userId, parsed.data.payload)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import 'server-only'
|
|||
|
||||
import webpush from 'web-push'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
export type PushPayload = {
|
||||
title: string
|
||||
|
|
@ -12,15 +11,15 @@ export type PushPayload = {
|
|||
}
|
||||
|
||||
const vapidReady =
|
||||
!!env.NEXT_PUBLIC_VAPID_PUBLIC_KEY &&
|
||||
!!env.VAPID_PRIVATE_KEY &&
|
||||
!!env.VAPID_SUBJECT
|
||||
!!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY &&
|
||||
!!process.env.VAPID_PRIVATE_KEY &&
|
||||
!!process.env.VAPID_SUBJECT
|
||||
|
||||
if (vapidReady) {
|
||||
webpush.setVapidDetails(
|
||||
env.VAPID_SUBJECT!,
|
||||
env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
|
||||
env.VAPID_PRIVATE_KEY!,
|
||||
process.env.VAPID_SUBJECT!,
|
||||
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
|
||||
process.env.VAPID_PRIVATE_KEY!,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue