636fe0df8f
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf apps/web+packages, noUselessEscapeInRegex, useConst, useExponentiationOperator) sowie fuenf ungesicherte Regeln (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate, useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei fehlender Sitzung geprueft) - noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60, die Fallmarke dokumentiert Absicht) - Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts), fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten) - Sechs weitere, im Plan nicht namentlich gelistete aber gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich bereinigt (groups.service.spec.ts, cert-manager.test.tsx, ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/ noUnusedImports/noUnusedFunctionParameters zu erreichen Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...). Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten 621/542 — eine Differenz von 1, weil das Streichen des Namens aus `catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint --force 5/5. Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben): - force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad, nicht die HTTP-Methode - change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf der Seite stehen (keine Weiterleitung, keine Aktualisierung der Benutzerablage) - VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst sich doppelt ausloesen - SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
557 lines
18 KiB
TypeScript
557 lines
18 KiB
TypeScript
import {
|
|
ForbiddenException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
|
import { CryptoService } from '../crypto/crypto.service';
|
|
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
|
|
import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto';
|
|
import { TestCalendarSourceConfigDto } from './dto/test-calendar-source-config.dto';
|
|
import { ICSProvider } from './providers/ics.provider';
|
|
import { CalDAVProvider } from './providers/caldav.provider';
|
|
import { ExchangeProvider } from './providers/exchange.provider';
|
|
|
|
/**
|
|
* Common interface for normalized calendar events across all provider types.
|
|
*/
|
|
export interface CalendarEvent {
|
|
id: string;
|
|
sourceId: string;
|
|
title: string;
|
|
start: Date;
|
|
end: Date;
|
|
allDay: boolean;
|
|
location?: string;
|
|
description?: string;
|
|
color?: string;
|
|
}
|
|
|
|
/**
|
|
* Provider interface for calendar source integrations.
|
|
* Each provider (ICS, CalDAV, Exchange) implements this contract.
|
|
*/
|
|
export interface CalendarProvider {
|
|
fetchEvents(
|
|
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; domain?: string; id: string; color?: string | null },
|
|
from: Date,
|
|
to: Date,
|
|
): Promise<CalendarEvent[]>;
|
|
|
|
testConnection(
|
|
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; domain?: string; id: string },
|
|
): Promise<boolean>;
|
|
}
|
|
|
|
/**
|
|
* Prisma `select` for safe source responses — NEVER includes encryptedPassword.
|
|
* T-05-09: Return hasCredentials boolean instead.
|
|
*/
|
|
const SOURCE_SAFE_SELECT = {
|
|
id: true,
|
|
userId: true,
|
|
tenantId: true,
|
|
name: true,
|
|
type: true,
|
|
exchangeMode: true,
|
|
domain: true,
|
|
url: true,
|
|
username: true,
|
|
// encryptedPassword: NEVER included — T-05-09
|
|
color: true,
|
|
isVisible: true,
|
|
syncIntervalMin: true,
|
|
lastSyncAt: true,
|
|
lastSyncError: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
} as const;
|
|
|
|
/**
|
|
* Private IP ranges for SSRF protection (T-05-11).
|
|
*/
|
|
const PRIVATE_IP_PATTERNS = [
|
|
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
|
|
/^192\.168\.\d{1,3}\.\d{1,3}$/,
|
|
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
|
|
/^169\.254\.\d{1,3}\.\d{1,3}$/,
|
|
/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/,
|
|
/^0\.0\.0\.0$/,
|
|
/^\[::1\]$/,
|
|
/^\[fc00:/,
|
|
/^\[fd00:/,
|
|
/^\[fe80:/,
|
|
];
|
|
|
|
/**
|
|
* In-memory event cache entry with TTL (Pitfall 4).
|
|
*/
|
|
interface CacheEntry {
|
|
events: CalendarEvent[];
|
|
expiresAt: number;
|
|
}
|
|
|
|
/** Cache TTL in milliseconds (5 minutes). */
|
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
|
|
/**
|
|
* Service for calendar source CRUD and event aggregation.
|
|
*
|
|
* Source config is per-user AND per-tenant bound (Mandantentrennung Etappe
|
|
* 2, 260911-cwh) — `CalendarSource` carries a mandatory `tenantId`
|
|
* (D-09/T-05-*: per-user ownership; row-level security: per-tenant
|
|
* isolation). Every method with database access binds its own
|
|
* `tenantPrisma` via `forTenant()`.
|
|
*
|
|
* The three ownership checks (`updateSource`/`deleteSource`/
|
|
* `testConnection`, comparing `existing.userId` against the calling user)
|
|
* are kept UNCHANGED alongside the binding, not replaced by it: the RLS
|
|
* policy on `CalendarSource` carried no user dimension when measured
|
|
* 260911-cwh, Aufgabe 1 (`calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`),
|
|
* so a colleague of the SAME tenant would otherwise see and modify a
|
|
* fellow user's encrypted Exchange/CalDAV credentials.
|
|
*
|
|
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt die
|
|
* `tenant_isolation_policy` auf `CalendarSource` die Benutzerdimension
|
|
* (`current_user_id() IS NULL OR "userId" = current_user_id()`) — jeder
|
|
* `forTenant()`-Aufruf oben reicht `userId` als drittes Argument durch. Die
|
|
* drei anwendungsseitigen Besitzpruefungen bleiben trotzdem UNVERAENDERT
|
|
* bestehen: die Datenbankregel ist ein ZWEITES Netz, kein Ersatz dafuer, und
|
|
* ein Aufrufer, der `userId` vergisst, saehe ohne sie den ganzen Mandanten
|
|
* (siehe .planning/WINDOWS.md).
|
|
*
|
|
* Credentials encrypted at rest via CryptoService (T-05-10).
|
|
*/
|
|
@Injectable()
|
|
export class CalendarService {
|
|
private readonly logger = new Logger(CalendarService.name);
|
|
|
|
/**
|
|
* Per-user event cache with TTL (Pitfall 4). Key: `userId:from:to`.
|
|
*
|
|
* Cache-key judgment (260911-cwh, Aufgabe 1, Befund F — chain checked
|
|
* link by link at execution time): `userId` here is `User.id`
|
|
* (`apps/api/prisma/schema.prisma`, `model User`, `@id @default(uuid())`),
|
|
* reached via `calendar.controller.ts` `extractContext()`
|
|
* (`req.user?.id`), which is `JwtStrategy.validate()`'s `id: payload.sub`
|
|
* (`apps/api/src/auth/strategies/jwt.strategy.ts`), which is
|
|
* `sub: user.id` at token-issue time (`apps/api/src/auth/auth.service.ts`,
|
|
* lines 143/332) — the database identity, not a login name. The
|
|
* Etappe-3-Entscheidung (1) (tenant-scoped uniqueness for
|
|
* `User.username`/`User.email`) does NOT touch `User.id`, which remains
|
|
* a platform-wide UUID no tenant can share. The key therefore stays
|
|
* without a tenant component. If any link of this chain changes, the key
|
|
* needs a tenant component — the decision follows the measurement, not
|
|
* this comment.
|
|
*/
|
|
private readonly eventCache = new Map<string, CacheEntry>();
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly crypto: CryptoService,
|
|
private readonly icsProvider: ICSProvider,
|
|
private readonly caldavProvider: CalDAVProvider,
|
|
private readonly exchangeProvider: ExchangeProvider,
|
|
) {}
|
|
|
|
/**
|
|
* Returns all calendar sources for a user WITHOUT encryptedPassword.
|
|
* Adds a `hasCredentials` boolean so the UI knows if credentials are set.
|
|
*/
|
|
async getSources(userId: string, tenantId: string) {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
|
const sources = await tenantPrisma.calendarSource.findMany({
|
|
where: { userId },
|
|
select: {
|
|
...SOURCE_SAFE_SELECT,
|
|
encryptedPassword: true, // Need it only to derive hasCredentials
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
|
|
return sources.map(({ encryptedPassword, ...source }) => ({
|
|
...source,
|
|
hasCredentials: !!encryptedPassword,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Creates a new calendar source. Encrypts password before storage.
|
|
* T-05-11: Validates URL against private IP ranges (SSRF).
|
|
*/
|
|
async addSource(userId: string, tenantId: string, dto: CreateCalendarSourceDto) {
|
|
// Exchange EWS servers are commonly on internal networks — SSRF check skipped for exchange type
|
|
if (dto.type !== 'exchange') await this.validateUrlNotPrivate(dto.url);
|
|
|
|
const data: Record<string, unknown> = {
|
|
userId,
|
|
tenantId,
|
|
name: dto.name,
|
|
type: dto.type,
|
|
url: dto.url,
|
|
username: dto.username ?? null,
|
|
exchangeMode: dto.exchangeMode ?? null,
|
|
domain: dto.domain ?? null,
|
|
color: dto.color ?? '#3B82F6',
|
|
};
|
|
|
|
if (dto.password) {
|
|
data.encryptedPassword = this.crypto.encrypt(dto.password);
|
|
}
|
|
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
|
const created = await tenantPrisma.calendarSource.create({
|
|
data: data as any,
|
|
select: SOURCE_SAFE_SELECT,
|
|
});
|
|
|
|
return { ...created, hasCredentials: !!dto.password };
|
|
}
|
|
|
|
/**
|
|
* Updates a calendar source. Ownership check ensures user can only modify their own sources.
|
|
* Re-encrypts password if provided; T-05-12 ownership enforcement.
|
|
*/
|
|
async updateSource(id: string, userId: string, tenantId: string, dto: UpdateCalendarSourceDto) {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
|
const existing = await tenantPrisma.calendarSource.findUnique({
|
|
where: { id },
|
|
select: { userId: true, type: true },
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new NotFoundException('Calendar source not found');
|
|
}
|
|
if (existing.userId !== userId) {
|
|
throw new ForbiddenException('Not your calendar source');
|
|
}
|
|
|
|
const effectiveType = dto.type ?? existing.type;
|
|
// Exchange EWS servers are commonly on internal networks — SSRF check skipped for exchange type
|
|
if (dto.url && effectiveType !== 'exchange') {
|
|
await this.validateUrlNotPrivate(dto.url);
|
|
}
|
|
|
|
const data: Record<string, unknown> = {};
|
|
if (dto.name !== undefined) data.name = dto.name;
|
|
if (dto.type !== undefined) data.type = dto.type;
|
|
if (dto.url !== undefined) data.url = dto.url;
|
|
if (dto.username !== undefined) data.username = dto.username;
|
|
if (dto.exchangeMode !== undefined) data.exchangeMode = dto.exchangeMode;
|
|
if (dto.domain !== undefined) data.domain = dto.domain;
|
|
if (dto.color !== undefined) data.color = dto.color;
|
|
if (dto.isVisible !== undefined) data.isVisible = dto.isVisible;
|
|
|
|
if (dto.password !== undefined) {
|
|
data.encryptedPassword = dto.password
|
|
? this.crypto.encrypt(dto.password)
|
|
: null;
|
|
}
|
|
|
|
const updated = await tenantPrisma.calendarSource.update({
|
|
where: { id },
|
|
data: data as any,
|
|
select: {
|
|
...SOURCE_SAFE_SELECT,
|
|
encryptedPassword: true,
|
|
},
|
|
});
|
|
|
|
const { encryptedPassword, ...safe } = updated;
|
|
return { ...safe, hasCredentials: !!encryptedPassword };
|
|
}
|
|
|
|
/**
|
|
* Deletes a calendar source. Ownership check enforced (T-05-12).
|
|
*/
|
|
async deleteSource(id: string, userId: string, tenantId: string) {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
|
const existing = await tenantPrisma.calendarSource.findUnique({
|
|
where: { id },
|
|
select: { userId: true },
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new NotFoundException('Calendar source not found');
|
|
}
|
|
if (existing.userId !== userId) {
|
|
throw new ForbiddenException('Not your calendar source');
|
|
}
|
|
|
|
await tenantPrisma.calendarSource.delete({ where: { id } });
|
|
return { deleted: true };
|
|
}
|
|
|
|
/**
|
|
* Test connection to a calendar source via its provider.
|
|
* Updates lastSyncAt/lastSyncError on the source record.
|
|
*/
|
|
async testConnection(id: string, userId: string, tenantId: string): Promise<{ success: boolean; error?: string }> {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
|
const source = await tenantPrisma.calendarSource.findUnique({ where: { id } });
|
|
if (!source) throw new NotFoundException('Calendar source not found');
|
|
if (source.userId !== userId) throw new ForbiddenException('Not your calendar source');
|
|
|
|
const provider = this.getProvider(source.type);
|
|
const decryptedSource = {
|
|
url: source.url,
|
|
username: source.username ?? undefined,
|
|
password: source.encryptedPassword
|
|
? this.crypto.decrypt(source.encryptedPassword)
|
|
: undefined,
|
|
exchangeMode: source.exchangeMode,
|
|
domain: source.domain ?? undefined,
|
|
id: source.id,
|
|
};
|
|
|
|
try {
|
|
const success = await provider.testConnection(decryptedSource);
|
|
|
|
await tenantPrisma.calendarSource.update({
|
|
where: { id },
|
|
data: {
|
|
lastSyncAt: success ? new Date() : undefined,
|
|
lastSyncError: success ? null : 'Connection test failed',
|
|
},
|
|
});
|
|
|
|
return { success };
|
|
} catch {
|
|
const errorMsg = 'Connection failed'; // T-05-13: generic error, no credentials
|
|
await tenantPrisma.calendarSource.update({
|
|
where: { id },
|
|
data: { lastSyncError: errorMsg },
|
|
});
|
|
return { success: false, error: errorMsg };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tests a calendar source configuration without saving it to the database.
|
|
* Used by the "Test connection" button in the form before the source is created.
|
|
*/
|
|
async testConnectionFromConfig(
|
|
dto: TestCalendarSourceConfigDto,
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
// Exchange EWS servers are commonly on internal networks — SSRF check skipped for exchange type
|
|
if (dto.type !== 'exchange') await this.validateUrlNotPrivate(dto.url);
|
|
} catch (e: any) {
|
|
return { success: false, error: e?.message ?? 'URL not allowed' };
|
|
}
|
|
|
|
const provider = this.getProvider(dto.type);
|
|
const tempSource = {
|
|
url: dto.url,
|
|
username: dto.username,
|
|
password: dto.password,
|
|
exchangeMode: dto.exchangeMode ?? null,
|
|
domain: dto.domain,
|
|
id: 'test-config',
|
|
};
|
|
|
|
try {
|
|
const success = await provider.testConnection(tempSource);
|
|
return { success };
|
|
} catch {
|
|
return { success: false, error: 'Connection failed' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Aggregate events from all visible sources for a user (CAL-02/CAL-03).
|
|
*
|
|
* - Loads only isVisible: true sources
|
|
* - Decrypts credentials per source
|
|
* - Fetches in parallel with Promise.allSettled (one failing source doesn't break others)
|
|
* - Merges + sorts by start ascending
|
|
* - Caches per user with 5-minute TTL (Pitfall 4)
|
|
*/
|
|
async aggregateEvents(
|
|
userId: string,
|
|
tenantId: string,
|
|
from?: string,
|
|
to?: string,
|
|
): Promise<CalendarEvent[]> {
|
|
const now = new Date();
|
|
const fromDate = from ? new Date(from) : now;
|
|
const toDate = to ? new Date(to) : new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // Default: +30 days
|
|
|
|
// Check cache first
|
|
const cacheKey = `${userId}:${fromDate.toISOString()}:${toDate.toISOString()}`;
|
|
const cached = this.eventCache.get(cacheKey);
|
|
if (cached && cached.expiresAt > Date.now()) {
|
|
// Serve cached immediately, trigger background refresh if close to expiry
|
|
if (cached.expiresAt - Date.now() < CACHE_TTL_MS / 2) {
|
|
this.refreshCacheInBackground(userId, tenantId, fromDate, toDate, cacheKey);
|
|
}
|
|
return cached.events;
|
|
}
|
|
|
|
// Fetch fresh
|
|
const events = await this.fetchAndCacheEvents(userId, tenantId, fromDate, toDate, cacheKey);
|
|
return events;
|
|
}
|
|
|
|
/**
|
|
* Fetches events from all visible sources, caches them, and returns.
|
|
*/
|
|
private async fetchAndCacheEvents(
|
|
userId: string,
|
|
tenantId: string,
|
|
from: Date,
|
|
to: Date,
|
|
cacheKey: string,
|
|
): Promise<CalendarEvent[]> {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
|
const sources = await tenantPrisma.calendarSource.findMany({
|
|
where: { userId, isVisible: true },
|
|
});
|
|
|
|
if (sources.length === 0) return [];
|
|
|
|
// Fetch from all sources in parallel — Promise.allSettled so one failure doesn't break others
|
|
const results = await Promise.allSettled(
|
|
sources.map(async (source) => {
|
|
const provider = this.getProvider(source.type);
|
|
const decryptedSource = {
|
|
url: source.url,
|
|
username: source.username ?? undefined,
|
|
password: source.encryptedPassword
|
|
? this.crypto.decrypt(source.encryptedPassword)
|
|
: undefined,
|
|
exchangeMode: source.exchangeMode,
|
|
domain: source.domain ?? undefined,
|
|
id: source.id,
|
|
color: source.color,
|
|
};
|
|
|
|
try {
|
|
const events = await provider.fetchEvents(decryptedSource, from, to);
|
|
|
|
// Update sync status on success
|
|
await tenantPrisma.calendarSource.update({
|
|
where: { id: source.id },
|
|
data: { lastSyncAt: new Date(), lastSyncError: null },
|
|
});
|
|
|
|
return events;
|
|
} catch (error) {
|
|
// Update sync error — generic message (T-05-13)
|
|
this.logger.warn(
|
|
`Failed to fetch events from source ${source.id} (${source.type}): ${(error as Error).message}`,
|
|
);
|
|
await tenantPrisma.calendarSource.update({
|
|
where: { id: source.id },
|
|
data: { lastSyncError: 'Event fetch failed' },
|
|
});
|
|
return [] as CalendarEvent[];
|
|
}
|
|
}),
|
|
);
|
|
|
|
// Merge all successful results
|
|
const allEvents: CalendarEvent[] = [];
|
|
for (const result of results) {
|
|
if (result.status === 'fulfilled') {
|
|
allEvents.push(...result.value);
|
|
}
|
|
}
|
|
|
|
// Sort by start ascending
|
|
allEvents.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
|
|
// Cache result
|
|
this.eventCache.set(cacheKey, {
|
|
events: allEvents,
|
|
expiresAt: Date.now() + CACHE_TTL_MS,
|
|
});
|
|
|
|
return allEvents;
|
|
}
|
|
|
|
/**
|
|
* Refreshes cache in the background without blocking the response.
|
|
*
|
|
* This is a request context that outlives the request (Befund B,
|
|
* 260911-cwh): it is NOT the "read across tenants, then bind per tenant"
|
|
* shape of the background-service section in
|
|
* docs/mandantentrennung-zugriffsklassifikation.md — it carries the
|
|
* tenant of the ORIGINAL request that triggered it (`aggregateEvents`)
|
|
* and cannot have any other tenant, because it never reads across
|
|
* tenants in the first place.
|
|
*/
|
|
private refreshCacheInBackground(
|
|
userId: string,
|
|
tenantId: string,
|
|
from: Date,
|
|
to: Date,
|
|
cacheKey: string,
|
|
): void {
|
|
this.fetchAndCacheEvents(userId, tenantId, from, to, cacheKey).catch((error) => {
|
|
this.logger.warn(`Background cache refresh failed: ${(error as Error).message}`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the provider instance for a given source type.
|
|
*/
|
|
private getProvider(type: string): CalendarProvider {
|
|
switch (type) {
|
|
case 'ics':
|
|
return this.icsProvider;
|
|
case 'caldav':
|
|
return this.caldavProvider;
|
|
case 'exchange':
|
|
return this.exchangeProvider;
|
|
default:
|
|
throw new Error(`Unknown calendar source type: ${type}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decrypts stored credentials for a source (used internally by providers).
|
|
* NEVER expose this in API responses.
|
|
*/
|
|
decryptSourcePassword(encryptedPassword: string): string {
|
|
return this.crypto.decrypt(encryptedPassword);
|
|
}
|
|
|
|
/**
|
|
* SSRF protection: reject URLs that resolve to private IP ranges.
|
|
* T-05-11: Combined with https-only DTO validation.
|
|
*/
|
|
private async validateUrlNotPrivate(url: string): Promise<void> {
|
|
try {
|
|
const parsed = new URL(url);
|
|
const hostname = parsed.hostname;
|
|
|
|
// Check against private IP patterns
|
|
for (const pattern of PRIVATE_IP_PATTERNS) {
|
|
if (pattern.test(hostname)) {
|
|
throw new ForbiddenException(
|
|
'Calendar source URL must not point to private/internal networks',
|
|
);
|
|
}
|
|
}
|
|
|
|
// Also block localhost variants
|
|
if (
|
|
hostname === 'localhost' ||
|
|
hostname === 'ip6-localhost' ||
|
|
hostname.endsWith('.local') ||
|
|
hostname.endsWith('.internal')
|
|
) {
|
|
throw new ForbiddenException(
|
|
'Calendar source URL must not point to localhost or local networks',
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ForbiddenException) throw error;
|
|
throw new ForbiddenException('Invalid calendar source URL');
|
|
}
|
|
}
|
|
}
|