Files
tessera-ctl/apps/api/src/calendar/calendar.service.ts
T
schalli 51d8c2f14e fix(calendar): SSRF exception for Exchange + error messages + domain in edit
- SSRF check skipped for Exchange type (internal EWS servers are common)
- testConnectionFromConfig catches SSRF/validation errors, returns {success:false,error} instead of throwing 403
- updateSource reads existing.type to determine effective type for SSRF check
- Panel shows saveError/editSaveError on failed add/update
- Edit form initialValues now includes domain field
- i18n: calendar.saveError key added (de+en)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 13:41:32 +02:00

500 lines
15 KiB
TypeScript

import {
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CalendarCryptoService } from './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 (D-09), not per-tenant.
* Credentials encrypted at rest via CalendarCryptoService (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`. */
private readonly eventCache = new Map<string, CacheEntry>();
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CalendarCryptoService,
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) {
const sources = await this.prisma.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 created = await this.prisma.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, dto: UpdateCalendarSourceDto) {
const existing = await this.prisma.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 this.prisma.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) {
const existing = await this.prisma.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 this.prisma.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): Promise<{ success: boolean; error?: string }> {
const source = await this.prisma.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 this.prisma.calendarSource.update({
where: { id },
data: {
lastSyncAt: success ? new Date() : undefined,
lastSyncError: success ? null : 'Connection test failed',
},
});
return { success };
} catch (error) {
const errorMsg = 'Connection failed'; // T-05-13: generic error, no credentials
await this.prisma.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 (e: any) {
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,
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, fromDate, toDate, cacheKey);
}
return cached.events;
}
// Fetch fresh
const events = await this.fetchAndCacheEvents(userId, fromDate, toDate, cacheKey);
return events;
}
/**
* Fetches events from all visible sources, caches them, and returns.
*/
private async fetchAndCacheEvents(
userId: string,
from: Date,
to: Date,
cacheKey: string,
): Promise<CalendarEvent[]> {
const sources = await this.prisma.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 this.prisma.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 this.prisma.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.
*/
private refreshCacheInBackground(
userId: string,
from: Date,
to: Date,
cacheKey: string,
): void {
this.fetchAndCacheEvents(userId, 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');
}
}
}