diff --git a/app/gateway-api/package.json b/app/gateway-api/package.json index 2bcfd108..261a220d 100644 --- a/app/gateway-api/package.json +++ b/app/gateway-api/package.json @@ -41,6 +41,7 @@ "date-fns": "^4.1.0", "es-toolkit": "^1.43.0", "fastify": "^5.6.2", + "ipaddr.js": "2.5.0", "pm2": "^7.0.3", "redis": "^5.10.0", "sanitize-html": "2.17.6", diff --git a/app/gateway-api/src/auth/oauthSessionStore.ts b/app/gateway-api/src/auth/oauthSessionStore.ts index 8dbe6a74..2928ad77 100644 --- a/app/gateway-api/src/auth/oauthSessionStore.ts +++ b/app/gateway-api/src/auth/oauthSessionStore.ts @@ -148,12 +148,9 @@ export class RedisOAuthSessionStore implements OAuthSessionStore { async consumePendingState(state: string): Promise { const key = this.stateKey(state); - const raw = await this.client.get(key); - if (!raw) { - return null; - } - await this.client.del(key); - return parseJson(raw); + // Callback 재사용 방지는 읽기와 삭제를 같은 Redis 실행에서 완료해야 한다. + const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] }); + return typeof raw === 'string' ? parseJson(raw) : null; } async createSession(session: Omit): Promise { diff --git a/app/gateway-api/src/webPush/coordinator.ts b/app/gateway-api/src/webPush/coordinator.ts index 11e16686..43ded139 100644 --- a/app/gateway-api/src/webPush/coordinator.ts +++ b/app/gateway-api/src/webPush/coordinator.ts @@ -12,6 +12,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra'; import webPush from 'web-push'; import { resolveGatewayProfileDisplayName } from '../profileOrder.js'; +import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js'; export interface WebPushCoordinatorConfig { enabled: boolean; @@ -200,8 +201,7 @@ export class WebPushCoordinator { async subscribe(userId: string, subscription: WebPushClientSubscription, userAgent?: string): Promise { if (!this.configured) throw new Error('웹 알림 전송이 아직 활성화되지 않았습니다.'); - const endpointUrl = new URL(subscription.endpoint); - if (endpointUrl.protocol !== 'https:') throw new Error('보안 연결의 Push 구독만 저장할 수 있습니다.'); + await validatePushEndpoint(subscription.endpoint); const expirationTime = subscription.expirationTime ? new Date(subscription.expirationTime) : null; await this.prisma.webPushSubscription.upsert({ where: { endpoint: subscription.endpoint }, @@ -428,7 +428,7 @@ export class WebPushCoordinator { const expired = await this.prisma.$transaction(async (tx) => { const count = await tx.$executeRaw(GatewayPrisma.sql` UPDATE web_push_delivery AS delivery - SET status = 'FAILED'::"WebPushDeliveryStatus", + SET status = 'FAILED', locked_at = NULL, lock_owner = NULL, last_error = 'Push subscription expired.' @@ -453,6 +453,8 @@ export class WebPushCoordinator { continue; } try { + // 기존 DB 구독도 검사한다. DNS 검사는 실제 socket lookup에서 다시 수행한다. + parsePushEndpoint(delivery.subscription.endpoint); await webPush.sendNotification( { endpoint: delivery.subscription.endpoint, @@ -464,11 +466,11 @@ export class WebPushCoordinator { url: delivery.notification.url, tag: delivery.notification.tag, }), - { TTL: 60 * 60 } + { TTL: 60 * 60, timeout: 10_000, agent: publicPushAgent } ); await this.prisma.$executeRaw(GatewayPrisma.sql` UPDATE web_push_delivery - SET status = 'DELIVERED'::"WebPushDeliveryStatus", + SET status = 'DELIVERED', delivered_at = CURRENT_TIMESTAMP, locked_at = NULL, lock_owner = NULL, @@ -481,6 +483,7 @@ export class WebPushCoordinator { ? Number((error as { statusCode?: unknown }).statusCode) : 0; const terminal = + error instanceof PushEndpointPolicyError || statusCode === 404 || statusCode === 410 || (statusCode >= 400 && statusCode < 500 && statusCode !== 429); @@ -493,7 +496,7 @@ export class WebPushCoordinator { const nextStatus = terminal || exhausted ? 'FAILED' : 'PENDING'; await tx.$executeRaw(GatewayPrisma.sql` UPDATE web_push_delivery - SET status = ${nextStatus}::"WebPushDeliveryStatus", + SET status = ${nextStatus}, available_at = CURRENT_TIMESTAMP + ${delaySeconds * 1_000} * INTERVAL '1 millisecond', locked_at = NULL, @@ -501,7 +504,7 @@ export class WebPushCoordinator { last_error = ${safeError} WHERE id = ${delivery.id} AND lock_owner = ${this.owner} `); - if (statusCode === 404 || statusCode === 410) { + if (error instanceof PushEndpointPolicyError || statusCode === 404 || statusCode === 410) { await tx.$executeRaw(GatewayPrisma.sql` UPDATE web_push_subscription SET disabled_at = CURRENT_TIMESTAMP, diff --git a/app/gateway-api/src/webPush/endpointPolicy.ts b/app/gateway-api/src/webPush/endpointPolicy.ts new file mode 100644 index 00000000..7d21de22 --- /dev/null +++ b/app/gateway-api/src/webPush/endpointPolicy.ts @@ -0,0 +1,108 @@ +import { lookup } from 'node:dns/promises'; +import type { LookupAddress } from 'node:dns'; +import { Agent } from 'node:https'; +import { isIP, type LookupFunction } from 'node:net'; +import ipaddr from 'ipaddr.js'; + +export class PushEndpointPolicyError extends Error { + constructor() { + // URL, DNS 응답과 구독 capability는 API 오류나 로그에 포함하지 않는다. + super('Push endpoint must use HTTPS on port 443 and a public destination.'); + this.name = 'PushEndpointPolicyError'; + } +} + +export type PushAddressResolver = (hostname: string) => Promise; +const resolveAddresses: PushAddressResolver = (hostname) => lookup(hostname, { all: true }); + +export const isPublicPushAddress = (address: string): boolean => { + try { + // 사설, loopback, link-local, multicast, 문서용·전환·예약 주소를 모두 거부한다. + return ipaddr.process(address).range() === 'unicast'; + } catch { + return false; + } +}; + +export const parsePushEndpoint = (endpoint: string): URL => { + let url: URL; + try { + url = new URL(endpoint); + } catch { + throw new PushEndpointPolicyError(); + } + const hostname = url.hostname.replace(/^\[|\]$/gu, ''); + if ( + url.protocol !== 'https:' || + (url.port !== '' && url.port !== '443') || + url.username || + url.password || + url.hash || + !hostname || + isIP(hostname) !== 0 + ) { + // IP literal은 Node가 custom lookup을 건너뛰므로 구독으로 받지 않는다. + throw new PushEndpointPolicyError(); + } + return url; +}; + +export const resolvePublicPushAddresses = async ( + hostname: string, + resolver: PushAddressResolver = resolveAddresses +): Promise => { + let timer: NodeJS.Timeout | undefined; + try { + const addresses = await Promise.race([ + resolver(hostname), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Push endpoint resolution failed.')), 5_000); + }), + ]); + if ( + addresses.length === 0 || + addresses.some(({ address, family }) => !isPublicPushAddress(address) || isIP(address) !== family) + ) { + throw new PushEndpointPolicyError(); + } + return addresses; + } catch (error) { + if (error instanceof PushEndpointPolicyError) throw error; + // 일시 DNS 장애는 전송 재시도 대상으로 남기고 구독을 영구 비활성화하지 않는다. + // eslint-disable-next-line preserve-caught-error -- DNS 오류의 hostname 등 비공개 목적지는 cause에도 복제하지 않는다. + throw new Error('Push endpoint resolution failed.'); + } finally { + clearTimeout(timer); + } +}; + +export const validatePushEndpoint = async (endpoint: string): Promise => { + const url = parsePushEndpoint(endpoint); + await resolvePublicPushAddresses(url.hostname); +}; + +export const createPublicPushLookup = + (resolver: PushAddressResolver = resolveAddresses): LookupFunction => + (hostname, options, callback) => { + // 저장 시 검사만으로는 DNS rebinding을 막지 못한다. socket이 실제 연결할 + // DNS 응답을 검사하고 바로 그 주소만 반환하여 재조회 경로를 없앤다. + void resolvePublicPushAddresses(hostname, resolver).then( + (addresses) => { + const requestedFamily = typeof options.family === 'number' ? options.family : 0; + const matching = requestedFamily + ? addresses.filter(({ family }) => family === requestedFamily) + : addresses; + if (matching.length === 0) { + callback(new PushEndpointPolicyError(), []); + } else if (options.all) { + callback(null, matching); + } else { + callback(null, matching[0].address, matching[0].family); + } + }, + (error: Error) => callback(error, []) + ); + }; + +// web-push는 https.request를 직접 호출하고 redirect를 따라가지 않는다. +export const publicPushAgent = new Agent({ lookup: createPublicPushLookup(), maxSockets: 10 }); diff --git a/app/gateway-api/test/oauthSessionStore.test.ts b/app/gateway-api/test/oauthSessionStore.test.ts index 6dc7b89b..e9fa2b65 100644 --- a/app/gateway-api/test/oauthSessionStore.test.ts +++ b/app/gateway-api/test/oauthSessionStore.test.ts @@ -14,6 +14,7 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => { const userIds = new Set(); const challengeIds = new Set(); const sessionIds = new Set(); + const stateIds = new Set(); beforeAll(async () => { await client.connect(); @@ -24,6 +25,7 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => { ...[...challengeIds].map((id) => `${prefix}:kakao-login-challenge:${id}`), ...[...userIds].map((id) => `${prefix}:kakao-login-challenge-user:${id}`), ...[...sessionIds].map((id) => `${prefix}:oauth-session:${id}`), + ...[...stateIds].map((id) => `${prefix}:oauth-state:${id}`), ]; if (keys.length > 0) { await client.del(keys); @@ -45,6 +47,14 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => { return challenge; }; + it('consumes a pending OAuth state once under concurrent callbacks', async () => { + const pending = await store.createPendingState('verify', ['account_email'], randomUUID()); + stateIds.add(pending.state); + const results = await Promise.all(Array.from({ length: 8 }, () => store.consumePendingState(pending.state))); + expect(results.filter((result) => result !== null)).toEqual([pending]); + await expect(store.consumePendingState(pending.state)).resolves.toBeNull(); + }); + it('preserves and consumes a retained-email recovery target once', async () => { const targetUserId = randomUUID(); const session = await store.createSession({ diff --git a/app/gateway-api/test/webPushCoordinator.integration.test.ts b/app/gateway-api/test/webPushCoordinator.integration.test.ts index 360a9f47..9aea58c3 100644 --- a/app/gateway-api/test/webPushCoordinator.integration.test.ts +++ b/app/gateway-api/test/webPushCoordinator.integration.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import webPush from 'web-push'; import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra'; @@ -22,6 +22,7 @@ integration('web push Gateway persistence boundary', () => { let db: GatewayPrismaClient; let closeDb: (() => Promise) | undefined; let coordinator: WebPushCoordinator; + const dispatchErrors: string[] = []; beforeAll(async () => { assertDedicatedSchema(); @@ -64,12 +65,16 @@ integration('web push Gateway persistence boundary', () => { }, }); const vapid = webPush.generateVAPIDKeys(); - coordinator = new WebPushCoordinator(db, { - enabled: true, - vapidSubject: 'mailto:web-push-test@example.invalid', - vapidPublicKey: vapid.publicKey, - vapidPrivateKey: vapid.privateKey, - }); + coordinator = new WebPushCoordinator( + db, + { + enabled: true, + vapidSubject: 'mailto:web-push-test@example.invalid', + vapidPublicKey: vapid.publicKey, + vapidPrivateKey: vapid.privateKey, + }, + (error) => dispatchErrors.push(error instanceof Error ? error.message : 'Unknown dispatcher error') + ); }); afterAll(async () => { @@ -166,4 +171,72 @@ integration('web push Gateway persistence boundary', () => { db.webPushEventReceipt.findUnique({ where: { eventId: 'integration:disabled:1' } }) ).resolves.toBeNull(); }); + + it('rejects a private endpoint before persisting a subscription', async () => { + const before = await db.webPushSubscription.count({ where: { userId } }); + await expect( + coordinator.subscribe(userId, { + endpoint: 'https://127.0.0.1/internal', + expirationTime: null, + keys: { p256dh: 'unused', auth: 'unused' }, + }) + ).rejects.toThrow('public destination'); + expect(await db.webPushSubscription.count({ where: { userId } })).toBe(before); + }); + + it('quarantines an existing unsafe subscription without sending a request', async () => { + await coordinator.stop(); + await db.webPushDelivery.deleteMany({ where: { notification: { userId } } }); + dispatchErrors.length = 0; + const subscription = await db.webPushSubscription.create({ + data: { + userId, + endpoint: 'https://[::1]/internal', + p256dh: 'unused', + auth: 'unused', + }, + }); + const notification = await db.webPushNotification.create({ + data: { + userId, + profileName, + dedupeKey: 'integration:unsafe-endpoint', + eventType: 'PRIVATE_MESSAGE_RECEIVED', + title: 'synthetic notification', + body: 'synthetic body', + url: '/hwe/', + tag: 'synthetic-tag', + }, + }); + const delivery = await db.webPushDelivery.create({ + data: { + subscriptionId: subscription.id, + notificationId: notification.id, + }, + }); + const sender = vi.spyOn(webPush, 'sendNotification').mockRejectedValue({ statusCode: 410 }); + try { + coordinator.start(); + await vi.waitFor( + async () => { + expect(dispatchErrors).toEqual([]); + expect((await db.webPushDelivery.findUniqueOrThrow({ where: { id: delivery.id } })).status).toBe( + 'FAILED' + ); + }, + { timeout: 5_000 } + ); + expect(await db.webPushSubscription.findUnique({ where: { id: subscription.id } })).toMatchObject({ + disabledAt: expect.any(Date), + }); + expect(sender).not.toHaveBeenCalledWith( + expect.objectContaining({ endpoint: subscription.endpoint }), + expect.anything(), + expect.anything() + ); + } finally { + await coordinator.stop(); + sender.mockRestore(); + } + }); }); diff --git a/app/gateway-api/test/webPushEndpointPolicy.test.ts b/app/gateway-api/test/webPushEndpointPolicy.test.ts new file mode 100644 index 00000000..f289f477 --- /dev/null +++ b/app/gateway-api/test/webPushEndpointPolicy.test.ts @@ -0,0 +1,131 @@ +import { createServer, request, Agent } from 'node:https'; +import type { LookupAddress, LookupOptions } from 'node:dns'; +import type { AddressInfo } from 'node:net'; +import { describe, expect, it, vi } from 'vitest'; + +import { + createPublicPushLookup, + isPublicPushAddress, + parsePushEndpoint, + PushEndpointPolicyError, + resolvePublicPushAddresses, +} from '../src/webPush/endpointPolicy.js'; + +describe('Web Push outbound destination policy', () => { + it.each([ + '127.0.0.1', + '0.0.0.0', + '10.0.0.1', + '172.16.0.1', + '192.168.1.1', + '169.254.169.254', + '100.64.0.1', + '192.0.2.1', + '198.18.0.1', + '224.0.0.1', + '255.255.255.255', + '::1', + '::', + 'fc00::1', + 'fe80::1', + 'ff02::1', + '2001:db8::1', + '::ffff:127.0.0.1', + '64:ff9b::a00:1', + '2002:0a00:0001::1', + 'not-an-ip', + ])('rejects non-public address %s', (address) => { + expect(isPublicPushAddress(address)).toBe(false); + }); + + it.each(['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111'])('accepts public address %s', (address) => { + expect(isPublicPushAddress(address)).toBe(true); + }); + + it.each([ + 'http://push.example/subscription', + 'https://push.example:8443/subscription', + 'https://user:password@push.example/subscription', + 'https://push.example/subscription#secret', + 'https://127.0.0.1/subscription', + 'https://2130706433/subscription', + 'https://0x7f000001/subscription', + 'https://[::1]/subscription', + 'https://[::ffff:127.0.0.1]/subscription', + 'not-a-url', + ])('rejects endpoint %s without DNS or network I/O', (endpoint) => { + expect(() => parsePushEndpoint(endpoint)).toThrow(PushEndpointPolicyError); + }); + + it('preserves provider capability paths and query parameters', () => { + const endpoint = 'https://fcm.googleapis.com:443/fcm/send/synthetic-capability?version=1'; + expect(parsePushEndpoint(endpoint).hostname).toBe('fcm.googleapis.com'); + expect(parsePushEndpoint(endpoint).search).toBe('?version=1'); + }); + + it('rejects mixed public/private answers, empty answers and resolution errors without leaking values', async () => { + for (const addresses of [ + [], + [ + { address: '8.8.8.8', family: 4 }, + { address: '10.0.0.1', family: 4 }, + ], + ]) { + await expect(resolvePublicPushAddresses('push.example', async () => addresses)).rejects.toThrow( + PushEndpointPolicyError + ); + } + await expect( + resolvePublicPushAddresses('push.example', async () => { + throw new Error('sensitive resolver details'); + }) + ).rejects.toThrow('Push endpoint resolution failed.'); + }); + + it('returns only the validated DNS answer to the socket and respects family/all', async () => { + const resolver = vi.fn(async () => [ + { address: '8.8.8.8', family: 4 }, + { address: '2606:4700:4700::1111', family: 6 }, + ]); + const lookup = createPublicPushLookup(resolver); + const runLookup = (options: LookupOptions) => + new Promise((resolve, reject) => { + lookup('push.example', options, (error, address) => (error ? reject(error) : resolve(address))); + }); + await expect(runLookup({ family: 6 })).resolves.toBe('2606:4700:4700::1111'); + await expect(runLookup({ all: true, family: 4 })).resolves.toEqual([{ address: '8.8.8.8', family: 4 }]); + expect(resolver).toHaveBeenCalledTimes(2); + }); + + it('blocks a rebound hostname at the actual HTTPS socket before contacting a local listener', async () => { + let connections = 0; + const server = createServer(); + server.on('connection', () => { + connections += 1; + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + const resolver = vi + .fn() + .mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }]) + .mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }]); + const agent = new Agent({ lookup: createPublicPushLookup(resolver) }); + try { + await resolvePublicPushAddresses('push.example', resolver); + const failure = await new Promise((resolve, reject) => { + const req = request({ hostname: 'push.example', port, agent, timeout: 1_000 }, () => + reject(new Error('Unexpected connection')) + ); + req.on('error', resolve); + req.on('timeout', () => req.destroy(new Error('Unexpected timeout'))); + req.end(); + }); + expect(failure).toBeInstanceOf(PushEndpointPolicyError); + expect(connections).toBe(0); + expect(resolver).toHaveBeenCalledTimes(2); + } finally { + agent.destroy(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c02fbaa7..f544656e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -466,6 +466,9 @@ importers: fastify: specifier: ^5.6.2 version: 5.12.0 + ipaddr.js: + specifier: 2.5.0 + version: 2.5.0 pm2: specifier: ^7.0.3 version: 7.0.3(supports-color@7.2.0)