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:
@@ -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);
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user