feat(media): allow HTML/Markdown/CSV uploads; serve scriptable types as downloads

- 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>
This commit is contained in:
2026-07-20 14:28:31 +05:30
parent 32aa04503d
commit b4104b9769
3 changed files with 23 additions and 1 deletions
@@ -5,6 +5,9 @@ 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(
@@ -37,6 +40,12 @@ export class MediaController {
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);
}