OAuth state 중복 소비와 Web Push 내부망 요청을 차단한다
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
});
|
||||
});
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user