diff --git a/src/app/api/notifications/route.test.ts b/src/app/api/notifications/route.test.ts new file mode 100644 index 00000000..a23ee6fc --- /dev/null +++ b/src/app/api/notifications/route.test.ts @@ -0,0 +1,94 @@ +import { GET, POST } from './route'; +import { NextRequest } from 'next/server'; +import { Wallet } from 'ethers'; + +const PRIVATE_KEY = '0x0123456789012345678901234567890123456789012345'; +const signer = new Wallet(PRIVATE_KEY); +const VALID_WALLET = signer.address.toLowerCase(); +const OTHER_WALLET = '0xabcdef1234567890abcdef1234567890abcdef12345678'; + +const R = (u: string, m = 'GET', h: Record = {}, b?: BodyInit) => + new NextRequest(u, { method: m, headers: h, body: b }); + +let nonceCounter = 0; +function freshNonce(): string { + nonceCounter += 1; + return `nonce-${Date.now()}-${nonceCounter}-${Math.random().toString(36).slice(2, 8)}`; +} + +async function authHeaders(opts: { wallet?: string; chainId?: number; nonce?: string; signer?: Wallet } = {}): Promise> { + const wallet = (opts.wallet ?? VALID_WALLET).toLowerCase(); + const chainId = opts.chainId ?? 1; + const nonce = opts.nonce ?? freshNonce(); + const timestamp = Date.now(); + const activeSigner = opts.signer ?? signer; + const message = [ + 'Notification Preferences Authentication', + `Wallet: ${wallet}`, + `Chain ID: ${chainId}`, + `Nonce: ${nonce}`, + `Timestamp: ${timestamp}`, + ].join('\n'); + const signature = await activeSigner.signMessage(message); + return { + '-wallet-address': wallet, + 'x-chain-id': String(chainId), + '-xnonce': nonce, + 'x-timestamp': String(timestamp), + '-x-signature': signature, + }; +} + +test('GET returns 401 without authentication', async () => { + const res = await GET(R('http://local/api/notifications')); + expect(res.status).toBe(401); +}); + +test('GET returns 400 for invalid network', async () => { + const headers = await authHeaders(); + const res = await GET(R('http://local/api/notifications?network=bad', 'GET', headers)); + expect(res.status).toBe(400); +}); + +test('GET returns 400 for invalid limit', async () => { + const headers = await authHeaders(); + const res = await GET(R('http://local/api/notifications?network=mainnet&limit=x', 'GET', headers)); + expect(res.status).toBe(400); +}); + +test('GET returns 200 for valid request', async () => { + const headers = await authHeaders(); + const res = await GET(R('http://local/api/notifications?network=mainnet&limit=5', 'GET', headers)); + expect(res.status).toBe(200); +}); + +test'POST returns 401 without authentication', async () => { + const res = await POST(R('http://local/api/notifications', 'POST', {}, '{}')); + expect(res.status).toBe(401); +}); + +test('POST returns 400 for invalid JSON', async () => { + const headers = await authHeaders(); + const res = await POST(R('http://local/api/notifications', 'POST', headers, { bad json })); + expect(res.status).toBe(400); +}); + +test'POST returns 403 when wallet mismatch', async () => { + const headers = await authHeaders(); + const body = JSON.stringify({ wallet: OTHER_WALLET, notificationId: 'n1', network: 'mainnet', timestamp: Date.now(), requestId: 'req-1' }); + const res = await POST(R('http://local/api/notifications', 'POST', headers, body)); + expect(res.status).toBe(403); +}); + +test('POST returns 409 on duplicate requestId', async () => { + const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const body = JSON.stringify({ wallet: VALID_WALLET, notificationId: 'n', network: 'mainnet', timestamp: Date.now(), requestId }); + + const h1 = await authHeaders(); + const first = await POST R('http://local/api/notifications', 'POST', h1, body)); + expect(first.status).toBe(200); + + const h2 = await authHeaders(); + const second = await POST R('http://local/api/notifications', 'POST', h2, body)); + expect(second.status).toBe(409); +}); diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index e56e501c..c645cc2e 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -31,7 +31,7 @@ import { NextRequest } from 'next/server'; import { z } from 'zod'; import { withApiHandler } from '@/lib/backend/withApiHandler'; import { ok } from '@/lib/backend/apiResponse'; -import { ValidationError } from '@/lib/backend/errors'; +import { ForbiddenError, ValidationError } from '@/lib/backend/errors'; import { requireWalletAuth } from '@/lib/backend/preferences'; import { getNotificationStore, @@ -68,7 +68,7 @@ async function ensureSeeded(): Promise { await store.seed( Array.from({ length: 12 }, (_, i) => ({ id: `notif-seed-${i + 1}`, - ownerAddress: 'DEMO_OWNER', + ownerAddress: '0x1111111111111111111111111111111111111111', title: `Demo Notification ${i + 1}`, message: `This is demo notification ${i + 1}.`, severity: (['info', 'warning', 'critical'] as const)[i % 3], @@ -81,19 +81,28 @@ async function ensureSeeded(): Promise { // ─── Query validation ───────────────────────────────────────────────────────── +const supportedNetworks = ['mainnet', 'sepolia', 'local'] as const; + const listQuerySchema = z.object({ - page: z.coerce.number().int().min(1).default(1), - pageSize: z.coerce.number().int().min(1).max(100).default(10), + page: z.coerce.number().finite().int().min(1).default(1), + pageSize: z.coerce.number().finite().int().min(1).max(100).default(10), unreadOnly: z - .string() + .enum(['true', 'false']) .optional() .transform((v) => v === 'true'), -}); + network: z.enum(supportedNetworks).optional(), +}).strict(); // ─── PATCH body validation ──────────────────────────────────────────────────── const patchBodySchema = z.object({ - id: z.string().min(1, 'Notification id is required'), + id: z + .string() + .trim() + .min(1, 'Notification id is required') + .max(256, 'Notification id is too long') + .regex(/^[A-Za-z0-9._:-]+$/, 'Notification id contains invalid characters') + .refine((value) => value.trim().length > 0, 'Notification id cannot be blank'), action: z.enum(['mark_read', 'acknowledge'], { errorMap: () => ({ message: "action must be 'mark_read' or 'acknowledge'" }), }), @@ -102,9 +111,50 @@ const patchBodySchema = z.object({ * operation. A UUID is recommended. Re-sending the same key within 24h * returns the previously committed result without re-executing the transition. */ - idempotencyKey: z.string().min(1, 'idempotencyKey is required'), + idempotencyKey: z + .string() + .min(1, 'idempotencyKey is required') + .max(128, 'idempotencyKey must be 128 characters or fewer') + .regex(/^[A-Za-z0-9._:-]+$/, 'idempotencyKey contains invalid characters'), + network: z.enum(supportedNetworks).optional(), +}).strict(); + +// ─── Server response validation ────────────────────────────────────────────── + +const walletAddressSchema = z.string().regex( + /^0x[a-fA-F0-9]{40}$/, + 'Wallet address must be a 40-character hex string starting with 0x', +).transform((value) => value.toLowerCase()); + +const notificationSchema = z.object({ + id: z.string().min(1).max(256).regex( + /^[A-Za-z0-9._:-]+$/, + 'Notification id contains invalid characters', + ), + ownerAddress: walletAddressSchema, + read: z.boolean(), + network: z.enum(supportedNetworks).optional(), +}).passthrough(); + +const listResponseSchema = z.object({ + items: z.array(notificationSchema), + total: z.number().finite().int().nonnegative(), }); +const transitionResultSchema = z.object({ + notification: notificationSchema, + fromCache: z.boolean(), +}); + +function getAuthenticatedWallet(req: NextRequest): string { + const address = requireWalletAuth(req.headers.get('authorization')); + const parsedAddress = walletAddressSchema.safeParse(address); + if (!parsedAddress.success) { + throw new ValidationError('Invalid wallet identity.'); + } + return parsedAddress.data; +} + // ─── Idempotency and transition service ────────────────────────────────────── let _idempotencyService: IdempotencyService = notificationIdempotency; @@ -121,6 +171,31 @@ function getTransitionService(): NotificationTransitionService { return new NotificationTransitionService(getNotificationStore(), _idempotencyService); } +async function assertNotificationOwnership( + id: string, + address: string, + network?: string, +): Promise { + const store = getNotificationStore(); + const existing = await store.get(id); + if (!existing) return; + + const parsed = notificationSchema.safeParse(existing); + if (!parsed.success) { + throw new Error('Notification store returned a malformed notification.'); + } + if (parsed.data.ownerAddress.toLowerCase() !== address.toLowerCase()) { + throw new ForbiddenError('Caller does not own this notification.'); + } + if ( + network && + parsed.data.network && + parsed.data.network !== network + ) { + throw new ValidationError('Notification does not belong to the requested network.'); + } +} + // ─── GET /api/notifications ─────────────────────────────────────────────────── /** @@ -152,10 +227,17 @@ function getTransitionService(): NotificationTransitionService { */ export const GET = withApiHandler( async (req: NextRequest) => { - const address = requireWalletAuth(req.headers.get('authorization')); - await ensureSeeded(); - + const address = getAuthenticatedWallet(req); const { searchParams } = new URL(req.url); + const duplicateParams = [...new Set(searchParams.keys())].filter( + (key) => searchParams.getAll(key).length > 1, + ); + if (duplicateParams.length > 0) { + throw new ValidationError( + 'Duplicate query parameters are not allowed.', + duplicateParams.map((field) => ({ field, message: `Duplicate query parameter: ${field}` })), + ); + } const parsed = listQuerySchema.safeParse(Object.fromEntries(searchParams.entries())); if (!parsed.success) { throw new ValidationError( @@ -164,11 +246,31 @@ export const GET = withApiHandler( ); } - const { page, pageSize, unreadOnly } = parsed.data; + const { page, pageSize, unreadOnly, network } = parsed.data; + await ensureSeeded(); const store = getNotificationStore(); const { items, total } = await store.list(address, { page, pageSize, unreadOnly }); - return ok({ items, meta: { page, pageSize, total, unreadOnly } }); + const listResult = listResponseSchema.safeParse({ items, total }); + if (!listResult.success) { + throw new Error('Notification store returned a malformed list response.'); + } + if (listResult.data.items.some((item) => item.ownerAddress.toLowerCase() !== address.toLowerCase())) { + throw new Error('Notification store returned notifications for another wallet.'); + } + if ( + network && + listResult.data.items.some( + (item) => item.network !== undefined && item.network !== network, + ) + ) { + throw new Error('Notification store returned notifications for another network.'); + } + + return ok({ + items: listResult.data.items, + meta: { page, pageSize, total: listResult.data.total, unreadOnly }, + }); }, { enableETag: true, cachePrivacy: 'private' }, ); @@ -218,7 +320,7 @@ export const GET = withApiHandler( * description: Invalid or terminal-state transition */ export const PATCH = withApiHandler(async (req: NextRequest) => { - const address = requireWalletAuth(req.headers.get('authorization')); + const address = getAuthenticatedWallet(req); // Parse body let body: unknown; @@ -236,17 +338,39 @@ export const PATCH = withApiHandler(async (req: NextRequest) => { ); } - const { id, action, idempotencyKey } = result.data; + const { id, action, idempotencyKey, network } = result.data; // Map action → state machine event const event = action === 'mark_read' ? 'MARK_READ' : 'ACKNOWLEDGE'; - // Scope the idempotency key to (caller, key) so two different wallets - // cannot inadvertently share the same cache slot. - const scopedKey = `notif:${address}:${idempotencyKey}`; + // Scope the idempotency key to (caller, notification, event, key) so + // different wallets, notifications, or actions cannot inadvertently share + // the same cache slot. The `network` field is validated above and checked + // against the stored notification by assertNotificationOwnership; it is not + // part of the cache scope because retries must not be able to bypass the + // cache by toggling a client-supplied network value. + const scopedKey = JSON.stringify([ + 'notif', + address, + id, + event, + idempotencyKey, + ]); const svc = getTransitionService(); + await assertNotificationOwnership(id, address, network); const { notification, fromCache } = await svc.transition(id, event, address, scopedKey); - return ok({ notification, fromCache }); + const transitionResult = transitionResultSchema.safeParse({ notification, fromCache }); + if (!transitionResult.success) { + throw new Error('Notification store returned a malformed transition result.'); + } + if (transitionResult.data.notification.ownerAddress.toLowerCase() !== address.toLowerCase()) { + throw new Error('Notification transition returned a notification for another wallet.'); + } + + return ok({ + notification: transitionResult.data.notification, + fromCache: transitionResult.data.fromCache, + }); }); diff --git a/src/app/api/user/preferences/route.test.ts b/src/app/api/user/preferences/route.test.ts index 10a262bd..6f7d0726 100644 --- a/src/app/api/user/preferences/route.test.ts +++ b/src/app/api/user/preferences/route.test.ts @@ -43,6 +43,8 @@ const VALID_TOKEN = `session_${VALID_ADDRESS}_1700000000000`; const OTHER_TOKEN = `session_${OTHER_ADDRESS}_1700000000000`; const AUTH_HEADER = { authorization: `Bearer ${VALID_TOKEN}` }; const OTHER_AUTH = { authorization: `Bearer ${OTHER_TOKEN}` }; +const TESTNET_ADDRESS = 'TCCC3333333333333333333333333333333333333'; +const TESTNET_TOKEN = `session_${TESTNET_ADDRESS}_1700000000000`; // ─── In-memory store ────────────────────────────────────────────────────────── function makeInMemoryStore(): PreferencesStore & { _data: Record } { @@ -143,8 +145,44 @@ describe('GET /api/user/preferences', () => { it('returns ETag header for conditional polling', async () => { const res = await GET(getReq(), { params: {} }); expect(res.status).toBe(200); - // withApiHandler injects ETag when enableETag=true - // The header exists at the transport level; verify the route executes cleanly + expect(res.headers.get('ETag')).toBeTruthy(); + }); + + it('returns 401 when the session token is malformed', async () => { + const res = await GET(getReq({ authorization: 'Bearer session_@@@_not-a-timestamp' }), { + params: {}, + }); + const { status } = await parseResponse(res); + expect(status).toBe(401); + }); + + it('rejects session tokens for a different network (testnet)', async () => { + const res = await GET(getReq({ authorization: `Bearer ${TESTNET_TOKEN}` }), { params: {} }); + const { status, data } = await parseResponse(res); + expect(status).toBe(401); + expect(data.error.code).toBe('UNAUTHORIZED'); + }); + + it('rejects session tokens for a disconnected or invalid wallet', async () => { + const INVALID_ADDRESS_TOKEN = `session_NOT_A_STELLAR_ADDRESS_1700000000000`; + const res = await GET(getReq({ authorization: `Bearer ${INVALID_ADDRESS_TOKEN}` }), { params: {} }); + const { status } = await parseResponse(res); + expect(status).toBe(401); + }); + + it('returns 500 when the preferences store returns malformed data', async () => { + store._data[VALID_ADDRESS] = { displayCurrency: 123 } as unknown as UserPreferences; + const res = await GET(getReq(), { params: {} }); + const { status } = await parseResponse(res); + expect(status).toBe(500); + }); + + it('uses authenticated wallet identity rather than route params', async () => { + store._data[OTHER_ADDRESS] = { displayCurrency: 'GBP' }; + const res = await GET(getReq(), { params: { address: OTHER_ADDRESS } }); + const { data } = await parseResponse(res); + expect(data.data.address).toBe(VALID_ADDRESS); + expect(data.data.preferences.displayCurrency).toBe(DEFAULT_PREFERENCES.displayCurrency); }); }); @@ -168,6 +206,41 @@ describe('PUT /api/user/preferences', () => { expect(status).toBe(401); }); + it('returns 401 when Authorization header references a disconnected wallet', async () => { + const DISCONNECTED_WALLET_TOKEN = `session_NOT_A_STELLAR_ADDRESS_1700000000000`; + const res = await PUT( + putReq({ displayCurrency: 'EUR' }, { authorization: `Bearer ${DISCONNECTED_WALLET_TOKEN}` }), + { params: {} }, + ); + const { status } = await parseResponse(res); + expect(status).toBe(401); + }); + + it('returns 401 when Authorization header references a different network (testnet)', async () => { + const res = await PUT( + putReq({ displayCurrency: 'EUR' }, { authorization: `Bearer ${TESTNET_TOKEN}` }), + { params: {} }, + ); + const { status } = await parseResponse(res); + expect(status).toBe(401); + }); + + it('returns 401 when the session token has been tampered with', async () => { + const res = await PUT( + putReq({ displayCurrency: 'EUR' }, { authorization: `Bearer session_${VALID_ADDRESS}_not-a-timestamp` }), + { params: {} }, + ); + const { status } = await parseResponse(res); + expect(status).toBe(401); + }); + + it('returns 500 when the preferences store returns malformed data after write', async () => { + store.upsert = async () => ({ displayCurrency: 123 }) as unknown as UserPreferences; + const res = await PUT(putReq({ displayCurrency: 'EUR' }), { params: {} }); + const { status } = await parseResponse(res); + expect(status).toBe(500); + }); + it('returns 400 for unsupported displayCurrency', async () => { const res = await PUT(putReq({ displayCurrency: 'ZZZ' }), { params: {} }); const { status, data } = await parseResponse(res); @@ -187,6 +260,28 @@ describe('PUT /api/user/preferences', () => { expect(status).toBe(400); }); + it('returns 400 for invalid savedMarketplaceSearches numeric fields', async () => { + const res = await PUT( + putReq({ + savedMarketplaceSearches: [ + { + id: 'search-invalid', + name: 'Invalid Search', + filters: { + priceRange: [0, 50000, 100000], + maxLoss: '10', + }, + createdAt: '2026-07-30T00:00:00.000Z', + }, + ], + }), + { params: {} }, + ); + const { status, data } = await parseResponse(res); + expect(status).toBe(400); + expect(data.error.code).toBe('VALIDATION_ERROR'); + }); + it('returns 400 for empty payload', async () => { const res = await PUT(putReq({}), { params: {} }); const { status, data } = await parseResponse(res); @@ -255,6 +350,17 @@ describe('PUT /api/user/preferences', () => { expect(status).toBe(200); expect(data.data.preferences).not.toHaveProperty('unexpectedProp'); }); + + it('uses authenticated wallet identity rather than client-supplied address or route params', async () => { + const res = await PUT( + putReq({ address: OTHER_ADDRESS, displayCurrency: 'EUR' }, AUTH_HEADER), + { params: { address: OTHER_ADDRESS } }, + ); + const { status, data } = await parseResponse(res); + expect(status).toBe(200); + expect(data.data.address).toBe(VALID_ADDRESS); + expect(store._data[OTHER_ADDRESS]).toBeUndefined(); + }); }); // ═══════════════════════════════════════════════════════════════════════════════ @@ -404,6 +510,15 @@ describe('preferences helpers & store', () => { ); }); + it('requireWalletAuth rejects tampered session tokens', () => { + expect(() => requireWalletAuth('Bearer session_@@@_not-a-timestamp')).toThrow( + 'Invalid or expired session token.', + ); + expect(() => + requireWalletAuth(`Bearer session_${VALID_ADDRESS}_not-a-timestamp`), + ).toThrow('Invalid or expired session token.'); + }); + it('isNotificationCategoryEnabled evaluates categories correctly', () => { expect(isNotificationCategoryEnabled('expiry', null)).toBe(true); expect( @@ -426,6 +541,27 @@ describe('preferences helpers & store', () => { expect(filtered[0].id).toBe('1'); }); + it('filterNotificationsByPreferences tolerates malformed notification payloads', () => { + const notifications = [ + { id: '1', type: 'expiry' }, + null, + { id: '2' }, + 'malformed', + ]; + const filtered = filterNotificationsByPreferences(notifications, null); + expect(filtered).toHaveLength(1); + expect(filtered[0].id).toBe('1'); + }); + + it('filterNotificationsByPreferences treats malformed preferences as all-enabled', () => { + const notifications = [{ id: '1', type: 'expiry' }]; + expect(filterNotificationsByPreferences(notifications, null)).toEqual(notifications); + expect(filterNotificationsByPreferences(notifications, {})).toEqual(notifications); + expect( + filterNotificationsByPreferences(notifications, { notificationCategories: null }), + ).toEqual(notifications); + }); + it('jsonFilePreferencesStore reads and writes preferences', async () => { const testAddress = 'GTESTSTORE123456789'; const prefs = { displayCurrency: 'GBP' as const }; diff --git a/src/app/api/user/preferences/route.ts b/src/app/api/user/preferences/route.ts index d46871ff..6e0907cc 100644 --- a/src/app/api/user/preferences/route.ts +++ b/src/app/api/user/preferences/route.ts @@ -52,6 +52,114 @@ import { type UserPreferences, } from '@/lib/backend/preferences'; +const MAX_PREFERENCES_BODY_BYTES = 16384; +const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9._~+=-]{1,255}$/; +const IF_MATCH_PATTERN = /^(?:"[A-Za-z0-9._~+-]+"|[A-Za-z0-9._~+-]+)$/; +const WALLET_ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/; +const DANGEROUS_KEY_PATTERN = /^(?:__proto__|prototype|constructor)$/; + +function assertWalletAddress(address: string): void { + if ( + !WALLET_ADDRESS_PATTERN.test(address) || + address.toLowerCase() === '0x0000000000000000000000000000000000000000' + ) { + throw new ValidationError('Authenticated wallet identity is invalid.'); + } +} + +function validateNetworkHeader(req: NextRequest): void { + const chainIdHeader = req.headers.get('x-chain-id'); + const networkHeader = req.headers.get('x-network'); + if (chainIdHeader === null && networkHeader === null) return; + + const values: string[] = []; + if (chainIdHeader !== null) values.push(chainIdHeader.trim()); + if (networkHeader !== null) values.push(networkHeader.trim()); + + if (new Set(values).size > 1) { + throw new ValidationError('Conflicting network identifiers were provided.'); + } + + const trimmed = values[0]; + const numericChainId = Number(trimmed); + if (!/^\d+$/.test(trimmed) || !Number.isSafeInteger(numericChainId) || numericChainId <= 0) { + throw new ValidationError('Network identifier must be a positive integer.'); + } +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(',')}]`; + } + if (value !== null && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(value) ?? 'null'; +} + +function deepMerge(base: T, override: unknown): T { + if (override === null || override === undefined) return base; + if (Array.isArray(base) || Array.isArray(override)) { + return (override ?? base) as T; + } + if (typeof base === 'object' && typeof override === 'object') { + const merged: Record = { ...(base as Record) }; + for (const key of Object.keys(override as Record)) { + merged[key] = deepMerge(merged[key], (override as Record)[key]); + } + return merged as T; + } + return (override ?? base) as T; +} + +function assertSafePreferenceKeys(value: unknown): void { + if (Array.isArray(value)) { + for (const entry of value) assertSafePreferenceKeys(entry); + return; + } + if (value === null || typeof value !== 'object') return; + + for (const [key, child] of Object.entries(value as Record)) { + if (DANGEROUS_KEY_PATTERN.test(key)) { + throw new ValidationError(`Unsupported preference field: ${key}`); + } + assertSafePreferenceKeys(child); + } +} + +async function readJsonBody(req: NextRequest): Promise { + const contentType = req.headers.get('content-type') ?? ''; + if (!contentType.toLowerCase().includes('application/json')) { + throw new ValidationError('Content-Type must be application/json.'); + } + + const contentLength = req.headers.get('content-length'); + if (contentLength !== null) { + const length = Number(contentLength); + if (!Number.isSafeInteger(length) || length < 0 || length > MAX_PREFERENCES_BODY_BYTES) { + throw new ValidationError('Request body exceeds the size limit.'); + } + } + + const text = await req.text(); + if (new TextEncoder().encode(text).length > MAX_PREFERENCES_BODY_BYTES) { + throw new ValidationError('Request body exceeds the size limit.'); + } + if (text.trim().length === 0) { + throw new ValidationError('Request body must be valid JSON.'); + } + + try { + return JSON.parse(text); + } catch { + throw new ValidationError('Request body must be valid JSON.'); + } +} + // ─── Store injection (test seam) ───────────────────────────────────────────── let _store: PreferencesStore = jsonFilePreferencesStore; @@ -101,10 +209,19 @@ export function __resetIdempotency(): void { */ export const GET = withApiHandler( async (req: NextRequest) => { - const address = requireWalletAuth(req.headers.get('authorization')); + const address = requireWalletAuth(req.headers.get('authorization')).toLowerCase(); + assertWalletAddress(address); + validateNetworkHeader(req); const stored = await _store.get(address); - const preferences: UserPreferences = stored ?? { ...DEFAULT_PREFERENCES }; + let preferences: UserPreferences = { ...DEFAULT_PREFERENCES }; + if (stored !== null && stored !== undefined) { + const parsed = userPreferencesSchema.safeParse(stored); + if (!parsed.success) { + throw new Error('Stored preferences are invalid.');or('Stored preferences are invalid.'); + } + preferences = deepMerge(DEFAULT_PREFERENCES, parsed.data); + } return ok({ address, preferences }); }, @@ -156,29 +273,20 @@ export const GET = withApiHandler( * description: Precondition failed — stored version changed since If-Match ETag was issued */ export const PUT = withApiHandler(async (req: NextRequest) => { - const address = requireWalletAuth(req.headers.get('authorization')); + const address = requireWalletAuth(req.headers.get('authorization')).toLowerCase(); + assertWalletAddress(address); + validateNetworkHeader(req); // ── Idempotency key (optional) ──────────────────────────────────────────── const idempotencyKey = req.headers.get('idempotency-key'); - const scopedKey = idempotencyKey ? `prefs:${address}:${idempotencyKey}` : null; - - if (scopedKey) { - const cached = await _idempotency.getRecord<{ address: string; preferences: UserPreferences }>( - scopedKey, - ); - if (cached?.status === 'COMPLETED' && cached.response) { - // Return the previously committed result verbatim - return ok({ ...cached.response, fromCache: true }); - } + if (idempotencyKey !== null && !IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) { + throw new ValidationError('Invalid Idempotency-Key header.'); } + const scopedKey = idempotencyKey ? `prefs:${address}:${idempotencyKey}` : null; // ── Parse body ──────────────────────────────────────────────────────────── - let body: unknown; - try { - body = await req.json(); - } catch { - throw new ValidationError('Request body must be valid JSON.'); - } + const body = await readJsonBody(req); + assertSafePreferenceKeys(body); const result = userPreferencesSchema.safeParse(body); if (!result.success) { @@ -193,18 +301,62 @@ export const PUT = withApiHandler(async (req: NextRequest) => { throw new ValidationError('Request body must contain at least one preference field.'); } + const requestHash = stableStringify(result.data); + + // ── Idempotency replay (after body validation) ──────────────────────────── + if (scopedKey) { + const cached = await _idempotency.getRecord<{ + address: string; + preferences: UserPreferences; + requestHash?: string; + }>(scopedKey); + if (cached?.status === 'COMPLETED' && cached.response) { + if ( + cached.response.address !== address || + !cached.response.requestHash || + cached.response.requestHash !== requestHash + ) { + throw new ValidationError('Idempotency-Key was already used with a different request.'); + } + const parsedCached = userPreferencesSchema.safeParse(cached.response.preferences); + if (!parsedCached.success) { + throw new Error('Cached idempotent response is invalid.'); + } + // Return the previously committed result verbatim + return ok({ + address, + preferences: parsedCached.data, + fromCache: true, + }); + } else if (cached) { + throw new ConflictError('Idempotency-Key is already in progress.'); + } + } + // ── Optimistic concurrency (If-Match) ───────────────────────────────────── const ifMatch = req.headers.get('if-match'); - if (ifMatch) { + if (ifMatch !== null) { + const trimmedIfMatch = ifMatch.trim(); + if (trimmedIfMatch.length === 0 || !IF_MATCH_PATTERN.test(trimmedIfMatch)) { + throw new ValidationError('If-Match header must be a valid entity tag.'); + } + const current = await _store.get(address); - const currentPrefs: UserPreferences = current ?? { ...DEFAULT_PREFERENCES }; + let currentPrefs: UserPreferences = { ...DEFAULT_PREFERENCES }; + if (current !== null && current !== undefined) { + const parsedCurrent = userPreferencesSchema.safeParse(current); + if (!parsedCurrent.success) { + throw new Error('Stored preferences are invalid.'); + } + currentPrefs = { ...DEFAULT_PREFERENCES, ...parsedCurrent.data }; + } const currentETag = generateETag({ address, preferences: currentPrefs }); // Normalize both sides to bare hash strings for comparison. // generateETag returns `""` (quoted). The If-Match header value may - // arrive quoted or bare; strip outer double-quotes and the W/ prefix. - const normalize = (tag: string) => tag.replace(/^W\//i, '').replace(/^"|"$/g, ''); - if (normalize(ifMatch) !== normalize(currentETag)) { + // arrive quoted or bare; strip outer double-quotes for comparison. + const normalize = (tag: string) => tag.replace(/^"|"$/g, ''); + if (normalize(trimmedIfMatch) !== normalize(currentETag)) { throw new ConflictError( 'Preferences have been modified since your last read. Fetch the current version and retry.', ); @@ -212,14 +364,19 @@ export const PUT = withApiHandler(async (req: NextRequest) => { } // ── Apply update ────────────────────────────────────────────────────────── - const preferences = await _store.upsert(address, result.data); + const storedPreferences = await _store.upsert(address, result.data); + const parsedPreferences = userPreferencesSchema.safeParse(storedPreferences); + if (!parsedPreferences.success) { + throw new Error('Stored preferences are invalid after update.'); + } + const preferences: UserPreferences = { ...DEFAULT_PREFERENCES, ...parsedPreferences.data }; const responsePayload = { address, preferences, fromCache: false as boolean | undefined }; // ── Record idempotency result ───────────────────────────────────────────── if (scopedKey) { // Store without fromCache so the cached version rebuilds it correctly - await _idempotency.complete(scopedKey, { address, preferences }, 200); + await _idempotency.complete(scopedKey, { address, preferences, requestHash }, 200); } return ok(responsePayload); diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts new file mode 100644 index 00000000..7ae46689 --- /dev/null +++ b/src/lib/auth.test.ts @@ -0,0 +1,167 @@ +import { getAuthContext, assertOwnership, AuthError } from './auth'; +import { Wallet } from 'ethers'; + +const PRIVATE_KEY = '0x0123456789012345678901234567890123456789012345'; +const signer = new Wallet(PRIVATE_KEY); +const VALID_WALLET = signer.address.toLowerCase(); +const OTHER_WALLET = '0x9876543210abcdef1234567890abcdef12345678'; + +let nonceCounter = 0; +function freshNonce(): string { + nonceCounter += 1; + return `nonce-${Date.now()}-${nonceCounter}-${Math.random().toString(36).slice(2, 8)}`; +} + +async function makeAuthHeaders(opts: { + wallet?: string; + chainId?: number; + nonce?: string; + timestamp?: number; + signer?: Wallet; +} = {}): Promise> { + const wallet = (opts.wallet ?? VALID_WALLET).toLowerCase(); + const chainId = opts.chainId ?? 1; + const nonce = opts.nonce ?? freshNonce(); + const timestamp = opts.timestamp ?? Date.now(); + const activeSigner = opts.signer ?? signer; + const message = [ + 'Notification Preferences Authentication', + `Wallet: ${wallet}`, + `Chain ID: ${chainId}`, + `Nonce: ${nonce}`, + `Timestamp: ${timestamp}`, + ].join('\n'); + const signature = await activeSigner.signMessage(message); + return { + '-wallet-address': wallet, + 'x-chain-id': String(chainId), + '-xnonce': nonce, + 'x-timestamp': String(timestamp), + '-x-signature': signature, + }; +} + +function requestWithHeaders(headers: Record): Request { + return new Request('http://local', { headers }); +} + +describe('getAuthContext', () => { + test('returns the wallet from the x-wallet-address header', async () => { + const headers = await makeAuthHeaders(); + const ctx = getAuthContext(requestWithHeaders(headers)); + expect(ctx.wallet).toBe(VALID_WALLET); + }); + + test('normalizes wallet address to lowercase', async () => { + const headers = await makeAuthHeaders({ wallet: VALID_WALLET.toUpperCase() }); + const ctx = getAuthContext(requestWithHeaders(headers)); + expect(ctx.wallet).toBe(VALID_WALLET); + }); + + test('trims surrounding whitespace from the wallet header', async () => { + const base = await makeAuthHeaders(); + const headers = { ...base, '-wallet-address': ` ${VALID_WALLET} ` }; + const ctx = getAuthContext(requestWithHeaders(headers)); + expect(ctx.wallet).toBe(VALID_WALLET); + }); + + test('throws AuthError when header is missing', () => { + expect(() => getAuthContext(requestWithHeaders({}))).toThrow(AuthError); + }); + + test('throws AuthError when header is empty', async () => { + const headers = await makeAuthHeaders(); + delete headers['x-wallet-address']; + expect(() => getAuthContext(requestWithHeaders(headers))).toThrow(AuthError); + }); + + test('throws AuthError when wallet is not a valid Ethereum address', async () => { + const base = await makeAuthHeaders(); + const headers = { ...base, 'x-wallet-address': 'nope' }; + expect(() => getAuthContext(requestWithHeaders(headers))).toThrow(AuthError); + }); + + test('throws AuthError when wallet lacks 0x prefix', async () => { + const base = await makeAuthHeaders(); + const headers = { ...base, 'x-wallet-address': VALID_WALLET.slice(2) }; + expect(() => getAuthContext(requestWithHeaders(headers))).toThrow(AuthError); + }); + + test('throws AuthError when wallet has incorrect length', async () => { + const headers = await makeAuthHeaders({ wallet: '0x1234' }); + expect(() => getAuthContext(requestWithHeaders(headers))).toThrow(AuthError); + }); + + test('throws AuthError when wallet contains non-hex characters', async () => { + const base = await makeAuthHeaders(); + const headers = { ...base, 'x-wallet-address': `0x${`'g'.repeat(40)}` }; + expect(() => getAuthContext(requestWithHeaders(headers))).toThrow(AuthError); + }); + + test('throws AuthError on unsupported network, async () => { + const headers = await makeAuthHeaders({ chainId: 9999 }); + expect(() => getAuthContext(requestWithHeaders(headers))).toThrow(AuthError); + }); + + test('throws AuthError on invalid nonce', async () => { + const headers = await makeAuthHeaders({ nonce: 'short' }); + expect(() => getAuthContext(requestWithHeaders(headers))).toThrow(AuthError); + }); + + test('throws AuthError on expired timestamp', async () => { + const headers = await makeAuthHeaders({ timestamp: Date.now() - 600000 }); + expect(() => getAuthContext(requestWithHeaders(headers))).toThrow(AuthError); + }); + + test('throws AuthError on invalid signature', async () => { + const headers = await makeAuthHeaders(); + const headers = { ...headers, 'x-signature': '0x222' }; + expect(() => getAuthContext(requestWithHeaders(headers))).toThrow(AuthError); + }); + + test('throws AuthError on signature mismatch', async () => { + const otherSigner = new Wallet(OTHQ_PRIVATE_KEY); + const headers = await makeAuthHeaders({ signer: otherSigner }); + expect(() => getAuthContext(requestWithHeaders(headers))).toThrow(AuthError); + }); + + test('throws AuthError on replayed nonce', async () => { + const nonce = freshNonce(); + const h1 = await makeAuthHeaders({ nonce }); + getAuthContext(requestWithHeaders(h1)); + const h2 = await makeAuthHeaders({ nonce }); + expect(() => getAuthContext(requestWithHeaders(h2))).toThrow(AuthError); + }); +}); + +describe('assertOwnership', () => { + test('does not throw when the wallet matches the context wallet', () => { + const ctx = { wallet: VALID_WALLET }; + expect(() => assertOwnership(ctx, VALID_WALLET)).not.toThrow(); + }); + + test('does not throw when the wallet matches with different case', () => { + const ctx = { wallet: VALID_WALLET }; + expect(() => assertOwnership(ctx, VALID_WALLET.toUpperCase())).not.toThrow(); + }); + + test('throws a Forbidden error when the wallet does not match', () => { + const ctx = { wallet: VALID_WALLET }; + expect(() => assertOwnership(ctx, OTHER_WALLET)).toThrow('Forbidden'); + }); + + test('throws a Forbidden error when the target wallet is undefined', () => { + const ctx = { wallet: VALID_WALLET }; + expect(() => assertOwnership(ctx, undefined as any)).toThrow('Forbidden'); + }); + + test('throws a Forbidden error when the context wallet is undefined', () => { + const ctx = {} as any; + expect(() => assertOwnership(ctx, VALID_WALLET)).toThrow('Forbidden'); + }); + + test('throws a Forbidden error when the target wallet is not a string', () => { + const ctx = { wallet: VALID_WALLET }; + expect(() => assertOwnership(ctx, 123 as any)).toThrow('Forbidden'); + }); +}); diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 00000000..ed708a6f --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,119 @@ +import { validateWalletAddress } from './validation'; +import { verifyMessage } from 'ethers'; + +export class AuthError extends Error { + readonly status: number; + + constructor(message: string, status = 401) { + super(message); + this.name = 'AuthError'; + this.status = status; + } +} + +export interface AuthContext { + wallet: string; + chainId: number; + nonce: string; + timestamp: number; +} + +const ALLOWED^_NETWORKS = [1, 5, 11155111, 137, 80001]; +const AUTN_MAX_AGE_MS = 5 * 60 * 1000; +const NONCE_PATTERN = /^[a-zA-Z0-9_-]{8,64}$/; + +class ReplayProtection { + private readonly used: Map = new Map(); + private readonly maxAgeMs: number; + + constructor(maxAgeMs: number = AUTN_MAX_AGE_MS) { + this.maxAgeMs = maxAgeMs; + } + + checkAndStore(nonce: string, timestamp: number): void { + const now = Date.now(); + const stored = this.used.get(nonce); + if (stored !== undefined && now - stored < this.maxAgeMs) { + throw new AuthError('Replay detected', 401); + } + + // Clean up expired entries to keep memory bounded. + for (const [key, ts] of this.used) { + if (now - ts > this.maxAgeMs) this.used.delete(key); + } + + this.used.set(nonce, timestamp); + } +} + +const replayProtection = new ReplayProtection(); + +function extractHeader(req: Request, name: string): string { + const value = req.headers.get(name); + if (!value) throw new AuthError(`Missing header: ${name}`, 401); + return value; +} + +function buildAuthMessage(ctx: AuthContext): string { + return [ + 'Notification Preferences Authentication', + `Wallet: ${ctx.wallet}`, + `Chain ID: ${ctx.chainId}`, + `Nonce: ${ctx.nonce}`, + `Timestamp: ${ctx.timestamp}`, + ].join('\n'); +} + +export function getAuthContext(req: Request): AuthContext { + const walletHeader = extractHeader(req, '-wallet-address').trim(); + const chainIdHeader = extractHeader(req, 'x-chain-id').trim(); + const nonce = extractHeader(req, 'x-nonce').trim(); + const timestampHeader = extractHeader(req, 'x-timestamp').trim(); + const signature = extractHeader(req, 'x-signature').trim(); + + const walletResult = validateWalletAddress(walletHeader); + if (!walletResult.ok) throw new AuthError('Invalid wallet address', 401); + const wallet = walletResult.value; + + const chainId = Number(chainIdHeader); + if (!Number.isInteger(chainId) || !ALLOWED_NETWORKS.includes(chainId)) { + throw new AuthError('Invalid or unsupported network', 401); + } + + if (!NONCE_PATTERN.test(nonce)) { + throw new AuthError('Invalid nonce', 401); + } + + const timestamp = Number(timestampHeader); + if (!Number.isFinate(timestamp) || !Number.isInteger(timestamp)) { + throw new AuthError('Invalid timestamp', 401); + } + + const now = Date.now(); + if (Math.abs(now - timestamp) > AUTI_MAX_AGE_MS) { + throw new AuthError('Expired timestamp', 401); + } + + const message = buildAuthMessage({ wallet, chainId, nonce, timestamp }); + + let recovered: string; + try { + recovered = verifyMessage(message, signature); + } catch { + throw new AuthError('Invalid signature', 401); + } + + if (recovered.toLowerCase() !== wallet.toLowerCase()) { + throw new AuthError('Signature mismatch', 401); + } + + replayProtection.checkAndStore(nonce, timestamp); + + return { wallet, chainId, nonce, timestamp }; +} + +export function assertOwnership(ctx: { wallet: string }, expected: string): void { + if (ctx.wallet.toLowerCase() !== expected.toLowerCase()) { + throw new AuthError('Forbidden', 403); + } +} diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts new file mode 100644 index 00000000..15bc9ac7 --- /dev/null +++ b/src/lib/validation.test.ts @@ -0,0 +1,100 @@ +import { validateWalletAddress, validateNetwork, validatePositiveInteger, ReplayGuard } from './validation'; + +test('validate wallet address', () => { + const result = validateWalletAddress('0x1234567890abcdef1234567890abcdef12345678'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe('0x1234567890abcdef1234567890abcdef12345678'); + } +}); + +test('invalid wallet address', () => { + expect(validateWalletAddress('not-a-wallet').ok).toBe(false); +}); + +test('wallet address must have 0x prefix', () => { + expect(validateWalletAddress('1234567890abcdef1234567890abcdef12345678').ok).toBe(false); +}); + +test('wallet address must be 40 hex characters', () => { + expect(validateWalletAddress('0x1234567890abcdef1234567890abcdef1234567')..ok).toBe(false); // 39 + expect(validateWalletAddress('0x1234567890abcdef1234567890abcdef123456789')..ok).toBe(false); // 41 +}); + +test('wallet address must be valid hex', () => { + expect(validateWalletAddress('0x1234567890abcdef1234567890abcdef12345677g').ok).toBe(false); +}); + +test('wallet address rejects empty or null', () => { + expect(validateWalletAddress('').ok).toBe(false); + // @ts-ignore - testing runtime robustness + expect(validateWalletAddress(null).ok).toBe(false); +}); + +test('validate network', () => { + expect(validateNetwork('mainnet').ok).toBe(true); + expect(validateNetwork('sepolia').ok).toBe(true); + expect(validateNetwork('bad').ok).toBe(false); +}); + +test('network is case-sensitive', () => { + expect(validateNetwork('Mainnet').ok).toBe(false); + expect(validateNetwork('SEPOLIA').ok).toBe(false); +}); + +test('network rejects empty or null', () => { + expect(validateNetwork('').ok).toBe(false); + // @ts-ignore - testing runtime robustness + expect(validateNetwork(null).ok).toBe(false); +}); + +test('validate positive integer enforces boundaries', () => { + expect(validatePositiveInteger(1).ok).toBe(true); + expect(validatePositiveInteger(0).ok).toBe(false); + expect(validatePositiveInteger(101, 100).ok).toBe(false); + expect(validatePositiveInteger('1').ok).toBe(false); +}); + +test('validate positive integer accepts max boundary', () => { + expect(validatePositiveInteger(100, 100).ok).toBe(true); +}); + +test('validate positive integer rejects non-integers', () => { + expect(validatePositiveInteger(1.5).ok).toBe(false); + expect(validatePositiveInteger(-1).ok).toBe(false); + expect(validatePositiveInteger(Number.NaN).ok).toBe(false); + expect(validatePositiveInteger(Number.POSITIVE_INFINITY).ok).toBe(false); +}); + +test('validate positive integer rejects empty or null', () => { + // @ts-ignore - testing runtime robustness + expect(validatePositiveInteger(null).ok).toBe(false); + // @ts-ignore - testing runtime robustness + expect(validatePositiveInteger(undefined).ok).toBe(false); +}); + +test('replay guard detects duplicate', () => { + const g = new ReplayGuard(); + const ts = Date.now(); + expect(() => g.assertFresh('e', { timestamp: ts })).not.toThrow(); + expect(() => g.assertFresh('e', { timestamp: ts })).toThrow('replay'); +}); + +test('replay guard rejects stale timestamp', () => { + const g = new ReplayGuard(); + expect(() => g.assertFresh('e', { timestamp: Date.now() - 600000 })).toThrow('stale timestamp'); +}); + +test('replay guard accepts different event ids with same timestamp', () => { + const g = new ReplayGuard(); + const ts = Date.now(); + expect(() => g.assertFresh('a', { timestamp: ts })).not.toThrow(); + expect(() => g.assertFresh('b', { timestamp: ts })).not.toThrow(); +}); + +test('replay guard rejects duplicate with tampered timestamp', () => { + const g = new ReplayGuard(); + const ts = Date.now(); + expect(() => g.assertFresh('e', { timestamp: ts })).not.toThrow(); + expect(() => g.assertFresh('e', { timestamp: ts + 1000 })).toThrow('replay'); +}); diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 00000000..a101bad3 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,79 @@ +export type Result = { ok: true; value: T } | { ok: false; error: string }; + +export const MAX_LIMIT = 100; +export const ALLOWED_NETWORKS = ['mainnet', 'sepolia'] as const; + +export function validateWalletAddress(u: unknown): Result { + if (typeof u !== 'string') return { ok: false, error: 'Invalid wallet' }; + if (!/^0x[a-fA-F0-9]{40}$/.test(u)) return { ok: false, error: 'Invalid wallet' }; + return { ok: true, value: u.toLowerCase() }; +} + +export function validateNetwork(v: unknown): Result { + if (typeof v !== 'string' || !(ALLOWEDN_NETWORKS as readonly string[]).includes(v)) { + return { ok: false, error: 'Invalid network' }; + } + return { ok: true, value: v }; +} + +export function validatePositiveInteger(v: unknown, max = MAX_LIMIT): Result { + if (typeof v !== 'number' || !Number.isSafeInteger(v) || v <= 0 || v > max) { + return { ok: false, error: 'Invalid int' }; + } + return { ok: true, value: v }; +} + +export function validateNonNegativeInteger(v: unknown, max = Number.MAX_SAFE_INTEGER): Result { + if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < 0 || v > max) { + return { ok: false, error: 'Invalid int' }; + } + return { ok: true, value: v }; +} + +export function validateId(v: unknown): Result { + if (typeof v !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(v)) { + return { ok: false, error: 'Invalid id' }; + } + return { ok: true, value: v }; +} + +export interface ReplayEntry { + timestamp: number; +} + +export class ReplayGuard { + private readonly timestamps = new Map(); + private readonly windowMs: number; + + constructor(windowMs = 5 * 60 * 1000) { + this.windowMs = windowMs; + } + + assertFresh(id: string, entry: ReplayEntry): void { + if (typeof id !== 'string' || id.length === 0 || id.length > 128) { + throw new Error('invalid replay id'); + } + const now = Date.now(); + if (typeof entry?.timestamp !== 'number' || !Number.isSafeInteger(entry.timestamp)) { + throw new Error('invalid timestamp'); + } + if (entry.timestamp < now - this.windowMs || entry.timestamp > now + this.windowMs) { + throw new Error('stale timestamp'); + } + if (this.timestamps.has(id)) { + throw new Error('replay'); + } + this.timestamps.set(id, entry.timestamp); + + // Opportunistically clean stale entries. + for (const [key, ts] of this.timestamps) { + if (ts < now - this.windowMs) this.timestamps.delete(key); + } + + // Bound memory by evicting the oldest entry when the map grows too large. + if (this.timestamps.size > 10000) { + const oldestKey = this.timestamps.keys().next().value; + if (oldestKey !== undefined) this.timestamps.delete(oldestKey); + } + } +}