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>
This commit is contained in:
2026-07-01 13:41:32 +02:00
parent b719291bdc
commit 51d8c2f14e
4 changed files with 29 additions and 8 deletions
+13 -5
View File
@@ -141,7 +141,8 @@ export class CalendarService {
* T-05-11: Validates URL against private IP ranges (SSRF). * T-05-11: Validates URL against private IP ranges (SSRF).
*/ */
async addSource(userId: string, tenantId: string, dto: CreateCalendarSourceDto) { async addSource(userId: string, tenantId: string, dto: CreateCalendarSourceDto) {
await this.validateUrlNotPrivate(dto.url); // 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> = { const data: Record<string, unknown> = {
userId, userId,
@@ -174,7 +175,7 @@ export class CalendarService {
async updateSource(id: string, userId: string, dto: UpdateCalendarSourceDto) { async updateSource(id: string, userId: string, dto: UpdateCalendarSourceDto) {
const existing = await this.prisma.calendarSource.findUnique({ const existing = await this.prisma.calendarSource.findUnique({
where: { id }, where: { id },
select: { userId: true }, select: { userId: true, type: true },
}); });
if (!existing) { if (!existing) {
@@ -184,7 +185,9 @@ export class CalendarService {
throw new ForbiddenException('Not your calendar source'); throw new ForbiddenException('Not your calendar source');
} }
if (dto.url) { 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); await this.validateUrlNotPrivate(dto.url);
} }
@@ -287,7 +290,12 @@ export class CalendarService {
async testConnectionFromConfig( async testConnectionFromConfig(
dto: TestCalendarSourceConfigDto, dto: TestCalendarSourceConfigDto,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
await this.validateUrlNotPrivate(dto.url); 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 provider = this.getProvider(dto.type);
const tempSource = { const tempSource = {
@@ -302,7 +310,7 @@ export class CalendarService {
try { try {
const success = await provider.testConnection(tempSource); const success = await provider.testConnection(tempSource);
return { success }; return { success };
} catch { } catch (e: any) {
return { success: false, error: 'Connection failed' }; return { success: false, error: 'Connection failed' };
} }
} }
@@ -37,6 +37,8 @@ export function CalendarSettingsPanel() {
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [editSaveError, setEditSaveError] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Record<string, 'success' | 'error' | 'testing'>>({}); const [testResults, setTestResults] = useState<Record<string, 'success' | 'error' | 'testing'>>({});
// Load sources on mount // Load sources on mount
@@ -76,6 +78,7 @@ export function CalendarSettingsPanel() {
// Add new source // Add new source
const handleAddSource = useCallback(async (payload: CreateSourcePayload) => { const handleAddSource = useCallback(async (payload: CreateSourcePayload) => {
setIsSaving(true); setIsSaving(true);
setSaveError(null);
try { try {
const created = await addSource(payload); const created = await addSource(payload);
setSources((prev) => [...prev, created]); setSources((prev) => [...prev, created]);
@@ -93,11 +96,11 @@ export function CalendarSettingsPanel() {
setTestResults((prev) => ({ ...prev, [created.id]: 'error' })); setTestResults((prev) => ({ ...prev, [created.id]: 'error' }));
} }
} catch { } catch {
// Error handled silently setSaveError(t('calendar.saveError') || 'Fehler beim Speichern');
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, []); }, [t]);
// Delete source // Delete source
const handleDeleteSource = useCallback(async (id: string) => { const handleDeleteSource = useCallback(async (id: string) => {
@@ -282,6 +285,9 @@ export function CalendarSettingsPanel() {
{/* Edit form (below the source being edited) */} {/* Edit form (below the source being edited) */}
{editingId && ( {editingId && (
<div className="rounded-md border border-border bg-card p-4"> <div className="rounded-md border border-border bg-card p-4">
{editSaveError && (
<p className="mb-3 text-sm text-destructive">{editSaveError}</p>
)}
<h3 className="mb-3 text-sm font-semibold text-foreground"> <h3 className="mb-3 text-sm font-semibold text-foreground">
Edit Source Edit Source
</h3> </h3>
@@ -296,12 +302,14 @@ export function CalendarSettingsPanel() {
url: s.url, url: s.url,
username: s.username ?? '', username: s.username ?? '',
exchangeMode: s.exchangeMode ?? undefined, exchangeMode: s.exchangeMode ?? undefined,
domain: s.domain ?? undefined,
color: s.color ?? undefined, color: s.color ?? undefined,
} }
: undefined; : undefined;
})()} })()}
onSave={async (payload) => { onSave={async (payload) => {
setIsSaving(true); setIsSaving(true);
setEditSaveError(null);
try { try {
const updated = await updateSource(editingId, payload); const updated = await updateSource(editingId, payload);
setSources((prev) => setSources((prev) =>
@@ -309,7 +317,7 @@ export function CalendarSettingsPanel() {
); );
setEditingId(null); setEditingId(null);
} catch { } catch {
// Error handled silently setEditSaveError(t('calendar.saveError') || 'Fehler beim Speichern');
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -337,6 +345,9 @@ export function CalendarSettingsPanel() {
<h3 className="mb-3 text-sm font-semibold text-foreground"> <h3 className="mb-3 text-sm font-semibold text-foreground">
{t('calendar.addSource')} {t('calendar.addSource')}
</h3> </h3>
{saveError && (
<p className="mb-3 text-sm text-destructive">{saveError}</p>
)}
<CalendarSourceForm <CalendarSourceForm
onSave={handleAddSource} onSave={handleAddSource}
onCancel={() => setShowAddForm(false)} onCancel={() => setShowAddForm(false)}
+1
View File
@@ -208,6 +208,7 @@
"formTesting": "Teste...", "formTesting": "Teste...",
"formTestSuccess": "Verbindung erfolgreich", "formTestSuccess": "Verbindung erfolgreich",
"formTestFailed": "Verbindung fehlgeschlagen", "formTestFailed": "Verbindung fehlgeschlagen",
"saveError": "Speichern fehlgeschlagen. Bitte Eingaben prüfen.",
"formSave": "Speichern", "formSave": "Speichern",
"formSaving": "Wird gespeichert...", "formSaving": "Wird gespeichert...",
"formCancel": "Abbrechen", "formCancel": "Abbrechen",
+1
View File
@@ -208,6 +208,7 @@
"formTesting": "Testing...", "formTesting": "Testing...",
"formTestSuccess": "Connection successful", "formTestSuccess": "Connection successful",
"formTestFailed": "Connection failed", "formTestFailed": "Connection failed",
"saveError": "Save failed. Please check your input.",
"formSave": "Save", "formSave": "Save",
"formSaving": "Saving...", "formSaving": "Saving...",
"formCancel": "Cancel", "formCancel": "Cancel",