OAuth state 중복 소비와 Web Push 내부망 요청을 차단한다
This commit is contained in:
@@ -41,6 +41,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"es-toolkit": "^1.43.0",
|
"es-toolkit": "^1.43.0",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
|
"ipaddr.js": "2.5.0",
|
||||||
"pm2": "^7.0.3",
|
"pm2": "^7.0.3",
|
||||||
"redis": "^5.10.0",
|
"redis": "^5.10.0",
|
||||||
"sanitize-html": "2.17.6",
|
"sanitize-html": "2.17.6",
|
||||||
|
|||||||
@@ -148,12 +148,9 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
|
|||||||
|
|
||||||
async consumePendingState(state: string): Promise<OAuthPendingState | null> {
|
async consumePendingState(state: string): Promise<OAuthPendingState | null> {
|
||||||
const key = this.stateKey(state);
|
const key = this.stateKey(state);
|
||||||
const raw = await this.client.get(key);
|
// Callback 재사용 방지는 읽기와 삭제를 같은 Redis 실행에서 완료해야 한다.
|
||||||
if (!raw) {
|
const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] });
|
||||||
return null;
|
return typeof raw === 'string' ? parseJson<OAuthPendingState>(raw) : null;
|
||||||
}
|
|
||||||
await this.client.del(key);
|
|
||||||
return parseJson<OAuthPendingState>(raw);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession> {
|
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 webPush from 'web-push';
|
||||||
|
|
||||||
import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
|
import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
|
||||||
|
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
|
||||||
|
|
||||||
export interface WebPushCoordinatorConfig {
|
export interface WebPushCoordinatorConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -200,8 +201,7 @@ export class WebPushCoordinator {
|
|||||||
|
|
||||||
async subscribe(userId: string, subscription: WebPushClientSubscription, userAgent?: string): Promise<void> {
|
async subscribe(userId: string, subscription: WebPushClientSubscription, userAgent?: string): Promise<void> {
|
||||||
if (!this.configured) throw new Error('웹 알림 전송이 아직 활성화되지 않았습니다.');
|
if (!this.configured) throw new Error('웹 알림 전송이 아직 활성화되지 않았습니다.');
|
||||||
const endpointUrl = new URL(subscription.endpoint);
|
await validatePushEndpoint(subscription.endpoint);
|
||||||
if (endpointUrl.protocol !== 'https:') throw new Error('보안 연결의 Push 구독만 저장할 수 있습니다.');
|
|
||||||
const expirationTime = subscription.expirationTime ? new Date(subscription.expirationTime) : null;
|
const expirationTime = subscription.expirationTime ? new Date(subscription.expirationTime) : null;
|
||||||
await this.prisma.webPushSubscription.upsert({
|
await this.prisma.webPushSubscription.upsert({
|
||||||
where: { endpoint: subscription.endpoint },
|
where: { endpoint: subscription.endpoint },
|
||||||
@@ -428,7 +428,7 @@ export class WebPushCoordinator {
|
|||||||
const expired = await this.prisma.$transaction(async (tx) => {
|
const expired = await this.prisma.$transaction(async (tx) => {
|
||||||
const count = await tx.$executeRaw(GatewayPrisma.sql`
|
const count = await tx.$executeRaw(GatewayPrisma.sql`
|
||||||
UPDATE web_push_delivery AS delivery
|
UPDATE web_push_delivery AS delivery
|
||||||
SET status = 'FAILED'::"WebPushDeliveryStatus",
|
SET status = 'FAILED',
|
||||||
locked_at = NULL,
|
locked_at = NULL,
|
||||||
lock_owner = NULL,
|
lock_owner = NULL,
|
||||||
last_error = 'Push subscription expired.'
|
last_error = 'Push subscription expired.'
|
||||||
@@ -453,6 +453,8 @@ export class WebPushCoordinator {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// 기존 DB 구독도 검사한다. DNS 검사는 실제 socket lookup에서 다시 수행한다.
|
||||||
|
parsePushEndpoint(delivery.subscription.endpoint);
|
||||||
await webPush.sendNotification(
|
await webPush.sendNotification(
|
||||||
{
|
{
|
||||||
endpoint: delivery.subscription.endpoint,
|
endpoint: delivery.subscription.endpoint,
|
||||||
@@ -464,11 +466,11 @@ export class WebPushCoordinator {
|
|||||||
url: delivery.notification.url,
|
url: delivery.notification.url,
|
||||||
tag: delivery.notification.tag,
|
tag: delivery.notification.tag,
|
||||||
}),
|
}),
|
||||||
{ TTL: 60 * 60 }
|
{ TTL: 60 * 60, timeout: 10_000, agent: publicPushAgent }
|
||||||
);
|
);
|
||||||
await this.prisma.$executeRaw(GatewayPrisma.sql`
|
await this.prisma.$executeRaw(GatewayPrisma.sql`
|
||||||
UPDATE web_push_delivery
|
UPDATE web_push_delivery
|
||||||
SET status = 'DELIVERED'::"WebPushDeliveryStatus",
|
SET status = 'DELIVERED',
|
||||||
delivered_at = CURRENT_TIMESTAMP,
|
delivered_at = CURRENT_TIMESTAMP,
|
||||||
locked_at = NULL,
|
locked_at = NULL,
|
||||||
lock_owner = NULL,
|
lock_owner = NULL,
|
||||||
@@ -481,6 +483,7 @@ export class WebPushCoordinator {
|
|||||||
? Number((error as { statusCode?: unknown }).statusCode)
|
? Number((error as { statusCode?: unknown }).statusCode)
|
||||||
: 0;
|
: 0;
|
||||||
const terminal =
|
const terminal =
|
||||||
|
error instanceof PushEndpointPolicyError ||
|
||||||
statusCode === 404 ||
|
statusCode === 404 ||
|
||||||
statusCode === 410 ||
|
statusCode === 410 ||
|
||||||
(statusCode >= 400 && statusCode < 500 && statusCode !== 429);
|
(statusCode >= 400 && statusCode < 500 && statusCode !== 429);
|
||||||
@@ -493,7 +496,7 @@ export class WebPushCoordinator {
|
|||||||
const nextStatus = terminal || exhausted ? 'FAILED' : 'PENDING';
|
const nextStatus = terminal || exhausted ? 'FAILED' : 'PENDING';
|
||||||
await tx.$executeRaw(GatewayPrisma.sql`
|
await tx.$executeRaw(GatewayPrisma.sql`
|
||||||
UPDATE web_push_delivery
|
UPDATE web_push_delivery
|
||||||
SET status = ${nextStatus}::"WebPushDeliveryStatus",
|
SET status = ${nextStatus},
|
||||||
available_at = CURRENT_TIMESTAMP
|
available_at = CURRENT_TIMESTAMP
|
||||||
+ ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
|
+ ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
|
||||||
locked_at = NULL,
|
locked_at = NULL,
|
||||||
@@ -501,7 +504,7 @@ export class WebPushCoordinator {
|
|||||||
last_error = ${safeError}
|
last_error = ${safeError}
|
||||||
WHERE id = ${delivery.id} AND lock_owner = ${this.owner}
|
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`
|
await tx.$executeRaw(GatewayPrisma.sql`
|
||||||
UPDATE web_push_subscription
|
UPDATE web_push_subscription
|
||||||
SET disabled_at = CURRENT_TIMESTAMP,
|
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 userIds = new Set<string>();
|
||||||
const challengeIds = new Set<string>();
|
const challengeIds = new Set<string>();
|
||||||
const sessionIds = new Set<string>();
|
const sessionIds = new Set<string>();
|
||||||
|
const stateIds = new Set<string>();
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@@ -24,6 +25,7 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
|
|||||||
...[...challengeIds].map((id) => `${prefix}:kakao-login-challenge:${id}`),
|
...[...challengeIds].map((id) => `${prefix}:kakao-login-challenge:${id}`),
|
||||||
...[...userIds].map((id) => `${prefix}:kakao-login-challenge-user:${id}`),
|
...[...userIds].map((id) => `${prefix}:kakao-login-challenge-user:${id}`),
|
||||||
...[...sessionIds].map((id) => `${prefix}:oauth-session:${id}`),
|
...[...sessionIds].map((id) => `${prefix}:oauth-session:${id}`),
|
||||||
|
...[...stateIds].map((id) => `${prefix}:oauth-state:${id}`),
|
||||||
];
|
];
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
await client.del(keys);
|
await client.del(keys);
|
||||||
@@ -45,6 +47,14 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
|
|||||||
return challenge;
|
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 () => {
|
it('preserves and consumes a retained-email recovery target once', async () => {
|
||||||
const targetUserId = randomUUID();
|
const targetUserId = randomUUID();
|
||||||
const session = await store.createSession({
|
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 webPush from 'web-push';
|
||||||
|
|
||||||
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
|
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||||
@@ -22,6 +22,7 @@ integration('web push Gateway persistence boundary', () => {
|
|||||||
let db: GatewayPrismaClient;
|
let db: GatewayPrismaClient;
|
||||||
let closeDb: (() => Promise<void>) | undefined;
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
let coordinator: WebPushCoordinator;
|
let coordinator: WebPushCoordinator;
|
||||||
|
const dispatchErrors: string[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
assertDedicatedSchema();
|
assertDedicatedSchema();
|
||||||
@@ -64,12 +65,16 @@ integration('web push Gateway persistence boundary', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const vapid = webPush.generateVAPIDKeys();
|
const vapid = webPush.generateVAPIDKeys();
|
||||||
coordinator = new WebPushCoordinator(db, {
|
coordinator = new WebPushCoordinator(
|
||||||
|
db,
|
||||||
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
vapidSubject: 'mailto:web-push-test@example.invalid',
|
vapidSubject: 'mailto:web-push-test@example.invalid',
|
||||||
vapidPublicKey: vapid.publicKey,
|
vapidPublicKey: vapid.publicKey,
|
||||||
vapidPrivateKey: vapid.privateKey,
|
vapidPrivateKey: vapid.privateKey,
|
||||||
});
|
},
|
||||||
|
(error) => dispatchErrors.push(error instanceof Error ? error.message : 'Unknown dispatcher error')
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -166,4 +171,72 @@ integration('web push Gateway persistence boundary', () => {
|
|||||||
db.webPushEventReceipt.findUnique({ where: { eventId: 'integration:disabled:1' } })
|
db.webPushEventReceipt.findUnique({ where: { eventId: 'integration:disabled:1' } })
|
||||||
).resolves.toBeNull();
|
).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:
|
fastify:
|
||||||
specifier: ^5.6.2
|
specifier: ^5.6.2
|
||||||
version: 5.12.0
|
version: 5.12.0
|
||||||
|
ipaddr.js:
|
||||||
|
specifier: 2.5.0
|
||||||
|
version: 2.5.0
|
||||||
pm2:
|
pm2:
|
||||||
specifier: ^7.0.3
|
specifier: ^7.0.3
|
||||||
version: 7.0.3(supports-color@7.2.0)
|
version: 7.0.3(supports-color@7.2.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user