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 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 15:34:02 +02:00
parent 30f62682c6
commit d99253ba79
2 changed files with 63 additions and 2 deletions
+28 -1
View File
@@ -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 <img> 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,
+35 -1
View File
@@ -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);
}
}
}