fix(08): apply code review findings (CR-01, CR-02, WR-01–05, IN-01)

- CR-01: fix SSRF bypass — isPrivateIpv6 now delegates ::ffff:<ipv4> to
  isPrivateIpv4, covering 172.16-31.x and 169.254.x ranges
- CR-02: add ParseUUIDPipe to GET /favorites widgetId param + service guard
  so missing widgetId returns 400 instead of leaking all user favorites
- WR-01: link-widget — replace raw 'link.error' key with t('link.error') (4 sites)
- WR-02: favorites-widget — fix load-path error to use t('favorites.error')
- WR-03: widget-catalog-modal — move aria-hidden from outer wrapper to backdrop
- WR-04: calculator — remove duplicate M button (MR clone); MC/MR/M+/M−/MS remain
- WR-05: schema — add FavoriteLink→WidgetInstance FK with onDelete:Cascade
- IN-01: create-widget.dto.ts — update comment from four to eight supported types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 11:03:16 +02:00
parent 54e4731b36
commit eebceb298d
9 changed files with 45 additions and 35 deletions
+19 -17
View File
@@ -124,13 +124,14 @@ model DashboardLayout {
} }
model WidgetInstance { model WidgetInstance {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
tenantId String tenantId String
widgetType String widgetType String
config Json @default("{}") config Json @default("{}")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
favoriteLinks FavoriteLink[]
@@index([userId]) @@index([userId])
@@index([tenantId]) @@index([tenantId])
@@ -235,16 +236,17 @@ model SmtpConfig {
} }
model FavoriteLink { model FavoriteLink {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
tenantId String tenantId String
widgetId String widgetId String
title String title String
url String url String
iconUrl String? iconUrl String?
position Int @default(0) position Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
widgetInstance WidgetInstance @relation(fields: [widgetId], references: [id], onDelete: Cascade)
@@index([userId]) @@index([userId])
@@index([tenantId]) @@index([tenantId])
@@ -2,7 +2,7 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
/** /**
* DTO for creating a new widget instance on a user's dashboard. * DTO for creating a new widget instance on a user's dashboard.
* widgetType must be one of the four supported types. * widgetType must be one of the eight supported types.
* config is optional and defaults to {} on the model. * config is optional and defaults to {} on the model.
*/ */
export class CreateWidgetDto { export class CreateWidgetDto {
@@ -5,6 +5,7 @@ import {
ForbiddenException, ForbiddenException,
Get, Get,
Param, Param,
ParseUUIDPipe,
Patch, Patch,
Post, Post,
Query, Query,
@@ -47,7 +48,7 @@ export class FavoritesController {
@Get() @Get()
async list( async list(
@Query('widgetId') widgetId: string, @Query('widgetId', ParseUUIDPipe) widgetId: string,
@Req() req: Request, @Req() req: Request,
) { ) {
const { userId } = this.extractContext(req); const { userId } = this.extractContext(req);
+2 -1
View File
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateFavoriteDto } from './dto/create-favorite.dto'; import { CreateFavoriteDto } from './dto/create-favorite.dto';
import { UpdateFavoriteDto } from './dto/update-favorite.dto'; import { UpdateFavoriteDto } from './dto/update-favorite.dto';
@@ -24,6 +24,7 @@ export class FavoritesService {
* Scoped by userId AND widgetId (Pitfall 3 — separate widgets must not share links). * Scoped by userId AND widgetId (Pitfall 3 — separate widgets must not share links).
*/ */
async list(userId: string, widgetId: string) { async list(userId: string, widgetId: string) {
if (!widgetId) throw new BadRequestException('widgetId is required');
return this.prisma.favoriteLink.findMany({ return this.prisma.favoriteLink.findMany({
where: { userId, widgetId }, where: { userId, widgetId },
orderBy: [{ position: 'asc' }, { title: 'asc' }], orderBy: [{ position: 'asc' }, { title: 'asc' }],
@@ -55,16 +55,25 @@ function isPrivateIpv4(address: string): boolean {
function isPrivateIpv6(address: string): boolean { function isPrivateIpv6(address: string): boolean {
const lower = address.toLowerCase(); const lower = address.toLowerCase();
return ( if (
lower === '::' || lower === '::' ||
lower === '::1' || lower === '::1' ||
lower.startsWith('fc') || lower.startsWith('fc') ||
lower.startsWith('fd') || lower.startsWith('fd') ||
lower.startsWith('fe80:') || lower.startsWith('fe80:') ||
lower.startsWith('::ffff:127.') || lower.startsWith('ff')
lower.startsWith('::ffff:10.') || ) {
lower.startsWith('::ffff:192.168.') return true;
); }
// IPv4-mapped IPv6 (::ffff:<ipv4>) — delegate to isPrivateIpv4 to cover all
// RFC 1918 ranges (10.x, 172.16-31.x, 192.168.x) and 169.254.x link-local
const v4MappedMatch = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4MappedMatch) {
return isPrivateIpv4(v4MappedMatch[1]);
}
return false;
} }
function isPrivateIpAddress(address: string): boolean { function isPrivateIpAddress(address: string): boolean {
@@ -57,10 +57,9 @@ export function WidgetCatalogModal({
<div <div
className="fixed inset-0 z-50 flex items-center justify-center" className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose} onClick={onClose}
aria-hidden="true"
> >
{/* Backdrop */} {/* Backdrop — visually only, hidden from assistive tech */}
<div className="fixed inset-0 bg-black/50" /> <div className="fixed inset-0 bg-black/50" aria-hidden="true" />
{/* Dialog */} {/* Dialog */}
<div <div
@@ -337,7 +337,6 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
<button type="button" className={memBtn} onClick={memoryAdd}>M+</button> <button type="button" className={memBtn} onClick={memoryAdd}>M+</button>
<button type="button" className={memBtn} onClick={memorySubtract}>M−</button> <button type="button" className={memBtn} onClick={memorySubtract}>M−</button>
<button type="button" className={memBtn} onClick={memoryStore}>MS</button> <button type="button" className={memBtn} onClick={memoryStore}>MS</button>
<button type="button" className={memBtn} onClick={memoryRecall} disabled={memory === 0}>M</button>
</div> </div>
{/* Keypad — 4 columns × 5 rows */} {/* Keypad — 4 columns × 5 rows */}
@@ -70,8 +70,7 @@ export function FavoritesWidget({
const data = await fetchFavorites(instanceId); const data = await fetchFavorites(instanceId);
if (!cancelled) setFavorites(data); if (!cancelled) setFavorites(data);
} catch { } catch {
// Use a stable error key — t is excluded from deps intentionally if (!cancelled) setError(t('favorites.error'));
if (!cancelled) setError('favorites.error');
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
@@ -63,7 +63,7 @@ export function LinkWidget({
if (!cancelled) setLink(data[0] ?? null); if (!cancelled) setLink(data[0] ?? null);
} catch { } catch {
// Stable error key — t excluded from deps to prevent re-fetch loops // Stable error key — t excluded from deps to prevent re-fetch loops
if (!cancelled) setError('link.error'); if (!cancelled) setError(t('link.error'));
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
@@ -107,7 +107,7 @@ export function LinkWidget({
setNewTitle(''); setNewTitle('');
setNewUrl(''); setNewUrl('');
} catch { } catch {
setError('link.error'); setError(t('link.error'));
} }
} }
@@ -144,7 +144,7 @@ export function LinkWidget({
setLink(updated); setLink(updated);
cancelEdit(); cancelEdit();
} catch { } catch {
setError('link.error'); setError(t('link.error'));
} }
} }
@@ -156,7 +156,7 @@ export function LinkWidget({
setLink(null); setLink(null);
cancelEdit(); cancelEdit();
} catch { } catch {
setError('link.error'); setError(t('link.error'));
} }
} }