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