Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/api/src/common/config/assert-secure-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ describe('assertSecureConfig', () => {
expect(() => assertSecureConfig({ NODE_ENV: 'production', AUTH_ENABLED: 'true', STRIPE_MODE: 'mock' } as any)).toThrow(/STRIPE_MODE=mock/);
});

it('also refuses to boot in staging with AUTH disabled (not just production)', () => {
expect(() => assertSecureConfig({ NODE_ENV: 'staging', AUTH_ENABLED: 'false', STRIPE_MODE: 'live' } as any)).toThrow(/AUTH_ENABLED=false/);
});

it('allows the explicit insecure opt-in (public demo)', () => {
expect(() =>
assertSecureConfig({ NODE_ENV: 'production', AUTH_ENABLED: 'false', STRIPE_MODE: 'mock', HAIP_ALLOW_INSECURE: 'true' } as any),
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/common/config/assert-secure-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@
* process.env at startup.
*/
export function assertSecureConfig(env: NodeJS.ProcessEnv = process.env): void {
if (env['NODE_ENV'] !== 'production') return;
// Enforce for any production-like environment, not just NODE_ENV=production —
// a host run as 'staging' must not silently boot with auth off either. Local
// dev/test ('development', 'test', or unset) stays permissive.
const nodeEnv = env['NODE_ENV'];
const productionLike = nodeEnv === 'production' || nodeEnv === 'staging';
if (!productionLike) return;
if (env['HAIP_ALLOW_INSECURE'] === 'true') return;
const problems: string[] = [];
if (env['AUTH_ENABLED'] === 'false') problems.push('AUTH_ENABLED=false');
Expand Down
43 changes: 41 additions & 2 deletions apps/api/src/common/security/url-guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, it, expect } from 'vitest';
import { isPrivateIp, isLiterallySafeHttpUrl, assertSafeOutboundUrl, UnsafeUrlError } from './url-guard';
import { describe, it, expect, afterEach } from 'vitest';
import {
isPrivateIp,
isLiterallySafeHttpUrl,
assertSafeOutboundUrl,
assertSafeChannelEndpoint,
UnsafeUrlError,
} from './url-guard';

describe('isPrivateIp', () => {
it.each([
Expand Down Expand Up @@ -50,3 +56,36 @@ describe('assertSafeOutboundUrl', () => {
await expect(assertSafeOutboundUrl('https://1.1.1.1/', { requireHttps: true })).resolves.toBeUndefined();
});
});

describe('assertSafeChannelEndpoint', () => {
const prevEnv = process.env['NODE_ENV'];
const prevAllow = process.env['CHANNEL_ALLOW_PRIVATE_ENDPOINTS'];
afterEach(() => {
process.env['NODE_ENV'] = prevEnv;
if (prevAllow === undefined) delete process.env['CHANNEL_ALLOW_PRIVATE_ENDPOINTS'];
else process.env['CHANNEL_ALLOW_PRIVATE_ENDPOINTS'] = prevAllow;
});

it('blocks an internal/metadata host in production', async () => {
process.env['NODE_ENV'] = 'production';
delete process.env['CHANNEL_ALLOW_PRIVATE_ENDPOINTS'];
await expect(assertSafeChannelEndpoint('http://169.254.169.254/latest/meta-data/')).rejects.toBeInstanceOf(UnsafeUrlError);
});

it('allows a public host in production', async () => {
process.env['NODE_ENV'] = 'production';
delete process.env['CHANNEL_ALLOW_PRIVATE_ENDPOINTS'];
await expect(assertSafeChannelEndpoint('https://1.1.1.1/ota')).resolves.toBeUndefined();
});

it('allows private hosts outside production (local mock OTA servers)', async () => {
process.env['NODE_ENV'] = 'test';
await expect(assertSafeChannelEndpoint('http://127.0.0.1:8080/ota')).resolves.toBeUndefined();
});

it('allows private hosts in production when explicitly opted in', async () => {
process.env['NODE_ENV'] = 'production';
process.env['CHANNEL_ALLOW_PRIVATE_ENDPOINTS'] = 'true';
await expect(assertSafeChannelEndpoint('http://10.0.0.5/ota')).resolves.toBeUndefined();
});
});
16 changes: 16 additions & 0 deletions apps/api/src/common/security/url-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ export async function assertSafeOutboundUrl(
}
}

/**
* SSRF guard for outbound OTA channel-adapter requests. The endpoint base URL
* comes from tenant-supplied channel-connection config, so a property admin could
* point it at an internal/metadata host and trigger a server-side fetch. Block
* private targets in production. Local/dev (docker mock OTA servers on private
* hosts) is allowed unless explicitly locked down, mirroring the project's
* NODE_ENV / opt-in posture (cf. HAIP_ALLOW_INSECURE).
*/
export async function assertSafeChannelEndpoint(raw: string): Promise<void> {
const enforce =
process.env['NODE_ENV'] === 'production' &&
process.env['CHANNEL_ALLOW_PRIVATE_ENDPOINTS'] !== 'true';
if (!enforce) return;
await assertSafeOutboundUrl(raw);
}

/** Sync, literal-only check for DTO validation (no DNS). */
export function isLiterallySafeHttpUrl(raw: string, opts: { requireHttps?: boolean } = {}): boolean {
let url: URL;
Expand Down
12 changes: 9 additions & 3 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,20 @@ async function bootstrap() {
.addTag('health', 'System health checks')
.build();

const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
// Expose the Swagger UI everywhere except production (it maps the full API
// surface for an attacker). The public demo can opt back in with SWAGGER_ENABLED=true.
const serveDocs =
process.env['NODE_ENV'] !== 'production' || process.env['SWAGGER_ENABLED'] === 'true';
if (serveDocs) {
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document);
}

const port = process.env['PORT'] ?? 3000;
await app.listen(port);

console.log(`HAIP API running on http://localhost:${port}`);
console.log(`OpenAPI docs at http://localhost:${port}/docs`);
if (serveDocs) console.log(`OpenAPI docs at http://localhost:${port}/docs`);
}

bootstrap();
19 changes: 19 additions & 0 deletions apps/api/src/modules/booking-engine/booking-engine.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function makeService(overrides: Partial<Record<string, any>> = {}) {
const ratePlan = {
calculateDerivedRate: vi.fn().mockResolvedValue({ effectiveRate: 100, currency: 'USD' }),
assertSellable: vi.fn().mockResolvedValue(undefined),
findById: vi.fn().mockResolvedValue({ id: RP, roomTypeId: RT, currencyCode: 'USD' }),
};
const tax = { calculateTaxes: vi.fn().mockResolvedValue([{ amount: '10.00' }]) };
const guest = { create: vi.fn().mockResolvedValue({ id: 'guest-1' }) };
Expand Down Expand Up @@ -154,4 +155,22 @@ describe('BookingEngineService.book', () => {
const { paymentToken, ...noToken } = bookDto as any;
await expect(svc.book(PROP, noToken)).rejects.toBeInstanceOf(BadRequestException);
});

it('rejects a rate plan that belongs to a different room type (price-tampering guard)', async () => {
// Attacker pairs a pricey room type with a cheap room's rate plan. Both are
// individually sellable, but the rate plan is bound to a DIFFERENT room type.
const { svc, ratePlan } = makeService();
ratePlan.findById.mockResolvedValue({ id: RP, roomTypeId: 'rt000000-0000-4000-a000-0000000000ff', currencyCode: 'USD' });
await expect(svc.book(PROP, bookDto as any)).rejects.toBeInstanceOf(BadRequestException);
});
});

describe('BookingEngineService.quote — rate/room pairing', () => {
it('rejects a rate plan that does not belong to the requested room type', async () => {
const { svc, ratePlan } = makeService();
ratePlan.findById.mockResolvedValue({ id: RP, roomTypeId: 'rt000000-0000-4000-a000-0000000000ff', currencyCode: 'USD' });
await expect(
svc.quote(PROP, { roomTypeId: RT, ratePlanId: RP, checkIn: '2026-07-01', checkOut: '2026-07-03', adults: 2 } as any),
).rejects.toBeInstanceOf(BadRequestException);
});
});
10 changes: 10 additions & 0 deletions apps/api/src/modules/booking-engine/booking-engine.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ export class BookingEngineService {
const config = await this.configService.getPublicConfig(propertyId);
this.assertSellable(config, dto.roomTypeId, dto.ratePlanId);

// Price-tampering guard: `roomTypeId` and `ratePlanId` arrive as two
// independent client-supplied ids. `assertSellable` only checks each id is
// individually sellable, so a caller could pair a pricey room type with a
// cheap room's rate plan and be charged the cheap rate. Each rate plan is
// bound to exactly one room type — enforce that they match.
const ratePlanRow = await this.ratePlanService.findById(dto.ratePlanId, propertyId);
if (ratePlanRow.roomTypeId !== dto.roomTypeId) {
throw new BadRequestException('Rate plan does not apply to the selected room type');
}

const nights = this.nightsBetween(dto.checkIn, dto.checkOut);

// Re-confirm availability for the requested room type.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
import type { BookingComConfig } from './booking-com.config';
import { DEFAULT_BOOKING_COM_CONFIG } from './booking-com.config';
import { buildOtaXml, parseOtaXml } from './booking-com.xml';
import { assertSafeChannelEndpoint } from '../../../../common/security/url-guard';
import {
mapAvailabilityToOta,
mapRatesToOta,
Expand Down Expand Up @@ -158,6 +159,7 @@ export class BookingComAdapter implements ChannelAdapter {
body: unknown,
): Promise<{ ok: boolean; status: number; error?: string; data?: unknown }> {
const url = `${config.baseUrl.replace(/\/$/, '')}${path}`;
await assertSafeChannelEndpoint(url); // SSRF: baseUrl is tenant-supplied
const auth = Buffer.from(`${config.username}:${config.password}`).toString('base64');
const timeoutMs = config.timeoutMs ?? 30_000;
const maxRetries = config.maxRetries ?? 3;
Expand All @@ -171,6 +173,7 @@ export class BookingComAdapter implements ChannelAdapter {
method,
headers: { 'Content-Type': 'application/json', Authorization: `Basic ${auth}` },
body: JSON.stringify(body),
redirect: 'manual', // don't follow a redirect to an internal host (SSRF)
signal: controller.signal,
});
clearTimeout(timer);
Expand Down Expand Up @@ -307,6 +310,7 @@ export class BookingComAdapter implements ChannelAdapter {
xml: string,
): Promise<ReturnType<typeof parseOtaXml>> {
const url = `${config.baseUrl}/${endpoint}`;
await assertSafeChannelEndpoint(url); // SSRF: baseUrl is tenant-supplied
const auth = Buffer.from(`${config.username}:${config.password}`).toString('base64');
const timeoutMs = config.timeoutMs ?? 30_000;
const maxRetries = config.maxRetries ?? 3;
Expand All @@ -325,6 +329,7 @@ export class BookingComAdapter implements ChannelAdapter {
Authorization: `Basic ${auth}`,
},
body: xml,
redirect: 'manual', // don't follow a redirect to an internal host (SSRF)
signal: controller.signal,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
EXPEDIA_AR_NS,
EXPEDIA_BC_NS,
} from './expedia.config';
import { assertSafeChannelEndpoint } from '../../../../common/security/url-guard';
import { buildExpediaXml, parseExpediaResponse } from './expedia.xml';
import {
mapAvailabilityToExpedia,
Expand Down Expand Up @@ -182,14 +183,15 @@ export class ExpediaAdapter implements ChannelAdapter {
url: string,
init: RequestInit,
): Promise<{ ok: boolean; status: number; text: string; error?: string }> {
await assertSafeChannelEndpoint(url); // SSRF: baseUrl is tenant-supplied
const timeoutMs = config.timeoutMs ?? 30_000;
const maxRetries = config.maxRetries ?? 3;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const res = await fetch(url, { ...init, signal: controller.signal });
const res = await fetch(url, { ...init, redirect: 'manual', signal: controller.signal });
clearTimeout(timer);
const text = await res.text();
if (!res.ok) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
import type { SiteMinderConfig } from './siteminder.config';
import { DEFAULT_SITEMINDER_CONFIG } from './siteminder.config';
import { buildSoapEnvelope, parseSoapResponse } from './siteminder.soap';
import { assertSafeChannelEndpoint } from '../../../../common/security/url-guard';
import {
mapAvailabilityToOta,
mapRatesToOta,
Expand Down Expand Up @@ -261,6 +262,7 @@ export class SiteMinderAdapter implements ChannelAdapter {
soapAction: string,
): Promise<ReturnType<typeof parseSoapResponse>> {
const url = config.baseUrl;
await assertSafeChannelEndpoint(url); // SSRF: baseUrl is tenant-supplied
const timeoutMs = config.timeoutMs ?? 30_000;
const maxRetries = config.maxRetries ?? 3;

Expand All @@ -278,6 +280,7 @@ export class SiteMinderAdapter implements ChannelAdapter {
SOAPAction: soapAction,
},
body: soapXml,
redirect: 'manual', // don't follow a redirect to an internal host (SSRF)
signal: controller.signal,
});

Expand Down
37 changes: 37 additions & 0 deletions apps/api/src/modules/channel/inbound-reservation.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ describe('InboundReservationService', () => {
if (callCount === 3) return Promise.resolve([{ id: 'rt-1' }]); // roomTypes FK OK
if (callCount === 4) return Promise.resolve([{ id: 'rp-1' }]); // ratePlans FK OK
if (callCount === 5) return Promise.resolve([existingGuest]); // existing guest by email
if (callCount === 6) return Promise.resolve([{ id: 'res-link' }]); // linked at THIS property → reuse
return Promise.resolve([]);
}),
}),
Expand All @@ -345,5 +346,41 @@ describe('InboundReservationService', () => {

expect(result.guestId).toBe('guest-existing');
});

it('should NOT reuse a guest with no reservation link at this property', async () => {
let callCount = 0;
const foreignGuest = { id: 'guest-foreign', firstName: 'John', email: 'john@example.com' };
mockDb.select.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) return Promise.resolve([mockConnection]);
if (callCount === 2) return Promise.resolve([]); // no existing booking
if (callCount === 3) return Promise.resolve([{ id: 'rt-1' }]); // roomTypes FK OK
if (callCount === 4) return Promise.resolve([{ id: 'rp-1' }]); // ratePlans FK OK
if (callCount === 5) return Promise.resolve([foreignGuest]); // email matches a guest...
if (callCount === 6) return Promise.resolve([]); // ...but NO reservation link at this property
return Promise.resolve([]);
}),
}),
}));

const reservation = makeReservation();
const result = await service.processInboundReservation('conn-1', reservation);

// A fresh guest row is created (beforeEach insert mock → 'guest-1'), not the foreign one.
expect(result.guestId).toBe('guest-1');
});
});

describe('confirmation number entropy', () => {
it('generates a high-entropy, non-time-derived confirmation number', () => {
const gen = () => (service as any).generateConfirmationNumber() as string;
const n = gen();
// 128-bit Crockford token (CH- + 32 chars), not `CH-<timestamp>-<4 random>`.
expect(n).toMatch(/^CH-[0-9A-Z]{32}$/);
const many = new Set(Array.from({ length: 200 }, gen));
expect(many.size).toBe(200);
});
});
});
Loading
Loading