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