b4104b9769
- media upload policy now allows text/html, text/markdown, text/x-markdown, text/csv (in addition to images/av, pdf, txt, zip, office docs) - blob endpoint adds X-Content-Type-Options: nosniff, and forces Content-Disposition: attachment for script-capable types (html, xhtml, svg, xml) so an uploaded file can't render/execute inline from the IIOS origin (stored-XSS). Images/video/audio/pdf still serve inline for preview. - dev-opa test covering the allowed types + unknown/oversize denials Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
71 lines
3.3 KiB
TypeScript
71 lines
3.3 KiB
TypeScript
import { BadRequestException, Body, Controller, Get, Headers, Param, Post, Put, Req, Res } from '@nestjs/common';
|
|
import type { Request, Response } from 'express';
|
|
import { MediaService } from './media.service';
|
|
import { SessionVerifier } from '../platform/session.verifier';
|
|
import { PresignDownloadDto, PresignUploadDto } from './media.dto';
|
|
import type { MessagePrincipal } from '../identity/actor.resolver';
|
|
|
|
/** Types that can execute script if a browser renders them top-level — served as downloads only. */
|
|
const SCRIPTABLE_MIMES = new Set(['text/html', 'application/xhtml+xml', 'image/svg+xml', 'text/xml', 'application/xml']);
|
|
|
|
@Controller('v1/media')
|
|
export class MediaController {
|
|
constructor(
|
|
private readonly media: MediaService,
|
|
private readonly session: SessionVerifier,
|
|
) {}
|
|
|
|
/** Authorize an upload → short-lived signed PUT url + object key. */
|
|
@Post('presign-upload')
|
|
async presignUpload(@Body() body: PresignUploadDto, @Headers('authorization') auth?: string) {
|
|
return this.media.presignUpload(this.principal(auth), { mime: body.mime, sizeBytes: body.sizeBytes });
|
|
}
|
|
|
|
/** Authorize a download → short-lived signed GET url. */
|
|
@Post('presign-download')
|
|
async presignDownload(@Body() body: PresignDownloadDto, @Headers('authorization') auth?: string) {
|
|
return this.media.presignDownload(this.principal(auth), body.contentRef, body.mime);
|
|
}
|
|
|
|
/** The presigned PUT target — token IS the auth. Reads the raw binary body stream. */
|
|
@Put('upload/:token')
|
|
async upload(@Param('token') token: string, @Req() req: Request) {
|
|
const data = await readBody(req);
|
|
if (data.length === 0) throw new BadRequestException('empty upload body');
|
|
return this.media.put(token, data);
|
|
}
|
|
|
|
/** The presigned GET target — token IS the auth. Streams the bytes with their mime. */
|
|
@Get('blob/:token')
|
|
async blob(@Param('token') token: string, @Res() res: Response) {
|
|
const { data, mime } = await this.media.get(token);
|
|
res.setHeader('Content-Type', mime);
|
|
// Never let the browser MIME-sniff an upload into something executable.
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
// Script-capable types must not render inline from our origin (stored-XSS) — force a download.
|
|
// Images/video/audio/pdf stay inline so the app can preview them. Note <img>/<video> still embed
|
|
// fine even with attachment disposition; only top-level navigation to the blob is affected.
|
|
if (SCRIPTABLE_MIMES.has(mime)) res.setHeader('Content-Disposition', 'attachment');
|
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
|
res.send(data);
|
|
}
|
|
|
|
private principal(auth?: string): MessagePrincipal {
|
|
const token = (auth ?? '').replace(/^Bearer\s+/i, '');
|
|
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
|
return this.session.verify(token);
|
|
}
|
|
}
|
|
|
|
/** Collect a request body stream into a Buffer (binary media upload; no body parser touches it). */
|
|
function readBody(req: Request): Promise<Buffer> {
|
|
const raw = (req as Request & { rawBody?: Buffer }).rawBody;
|
|
if (raw && raw.length) return Promise.resolve(raw);
|
|
return new Promise<Buffer>((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on('data', (c: Buffer) => chunks.push(c));
|
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
req.on('error', reject);
|
|
});
|
|
}
|