OAuth state 중복 소비와 Web Push 내부망 요청을 차단한다

This commit is contained in:
2026-10-09 00:59:06 +09:00
parent df6c9269fa
commit e378936051
8 changed files with 346 additions and 20 deletions
+1
View File
@@ -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",
@@ -148,12 +148,9 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
async consumePendingState(state: string): Promise<OAuthPendingState | null> {
const key = this.stateKey(state);
const raw = await this.client.get(key);
if (!raw) {
return null;
}
await this.client.del(key);
return parseJson<OAuthPendingState>(raw);
// Callback 재사용 방지는 읽기와 삭제를 같은 Redis 실행에서 완료해야 한다.
const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] });
return typeof raw === 'string' ? parseJson<OAuthPendingState>(raw) : null;
}
async createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession> {
+10 -7
View File
@@ -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<void> {
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,
@@ -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<LookupAddress[]>;
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<LookupAddress[]> => {
let timer: NodeJS.Timeout | undefined;
try {
const addresses = await Promise.race([
resolver(hostname),
new Promise<never>((_, 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<void> => {
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 });
@@ -14,6 +14,7 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
const userIds = new Set<string>();
const challengeIds = new Set<string>();
const sessionIds = new Set<string>();
const stateIds = new Set<string>();
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({
@@ -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<void>) | 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();
}
});
});
@@ -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<string | LookupAddress[]>((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<void>((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<Error>((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<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
});
+3
View File
@@ -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)