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
@@ -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 });