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