From d99253ba7957c8106cb3c1e6980ecc2e28070881 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 7 Jul 2026 15:34:02 +0200 Subject: [PATCH] feat(favorites): GET /favorites/:id/icon proxy endpoint Ownership-scoped (userId, matching update/remove) icon byte proxy. Loads the row's stored iconUrl server-side and streams it through IconDiscoveryService.fetchIconBytes -- never accepts a client-supplied URL, so this can't become an open SSRF proxy. Not-found/not-owned/no-icon -> 404. Upstream fetch failure (unreachable, timeout, non-image, SSRF-blocked) -> 502, never a 200 with a placeholder. Success sets Cache-Control so the browser doesn't refetch every load. Co-Authored-By: Claude Sonnet 5 --- .../api/src/favorites/favorites.controller.ts | 29 ++++++++++++++- apps/api/src/favorites/favorites.service.ts | 36 ++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/apps/api/src/favorites/favorites.controller.ts b/apps/api/src/favorites/favorites.controller.ts index 355290b..a31a060 100644 --- a/apps/api/src/favorites/favorites.controller.ts +++ b/apps/api/src/favorites/favorites.controller.ts @@ -10,8 +10,9 @@ import { Post, Query, Req, + Res, } from '@nestjs/common'; -import { Request } from 'express'; +import { Request, Response } from 'express'; import { CreateFavoriteDto } from './dto/create-favorite.dto'; import { UpdateFavoriteDto } from './dto/update-favorite.dto'; import { FavoritesService } from './favorites.service'; @@ -66,6 +67,32 @@ export class FavoritesController { return this.favoritesService.create(userId, tenantId, dto); } + /** + * GET /favorites/:id/icon — streams the stored icon bytes for a favorite + * owned by the caller, from Tessera's own origin. This avoids the browser + * blocking a cross-origin hotlink when the external site sends + * Cross-Origin-Resource-Policy: same-origin (e.g. claude.ai). + * + * Takes only a FavoriteLink id — never a client-supplied URL — so this + * cannot be used as an arbitrary-URL SSRF proxy (T-QFIP-01). + */ + @Get(':id/icon') + async getIcon( + @Param('id') id: string, + @Req() req: Request, + @Res() res: Response, + ) { + const { userId } = this.extractContext(req); + const { contentType, body } = await this.favoritesService.getIconBytes( + id, + userId, + ); + + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.send(body); + } + @Patch(':id') async update( @Param('id') id: string, diff --git a/apps/api/src/favorites/favorites.service.ts b/apps/api/src/favorites/favorites.service.ts index 8419d26..63b85c7 100644 --- a/apps/api/src/favorites/favorites.service.ts +++ b/apps/api/src/favorites/favorites.service.ts @@ -1,4 +1,10 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + HttpException, + HttpStatus, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateFavoriteDto } from './dto/create-favorite.dto'; import { UpdateFavoriteDto } from './dto/update-favorite.dto'; @@ -94,4 +100,32 @@ export class FavoritesService { await this.prisma.favoriteLink.delete({ where: { id } }); } + + /** + * Fetches the raw bytes of a favorite's stored icon, scoped to the + * requesting user (T-08-06 — same ownership check as update/remove). + * Never accepts a client-supplied URL — only the stored iconUrl on a + * row the caller owns is fetched (T-QFIP-01). + * + * Throws NotFoundException (404) if the row doesn't exist, isn't owned + * by the caller, or has no icon on record. Throws a 502 HttpException + * if the upstream fetch fails (unreachable, timeout, non-image, or + * SSRF-blocked) -- never returns a placeholder image. + */ + async getIconBytes( + id: string, + userId: string, + ): Promise<{ contentType: string; body: Buffer }> { + const link = await this.prisma.favoriteLink.findUnique({ where: { id } }); + + if (!link || link.userId !== userId || !link.iconUrl) { + throw new NotFoundException('FavoriteLink not found'); + } + + try { + return await this.iconDiscovery.fetchIconBytes(link.iconUrl); + } catch { + throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY); + } + } }