Compare commits
2 Commits
32aa04503d
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 00d22be714 | |||
| b4104b9769 |
@@ -5,6 +5,9 @@ import { SessionVerifier } from '../platform/session.verifier';
|
|||||||
import { PresignDownloadDto, PresignUploadDto } from './media.dto';
|
import { PresignDownloadDto, PresignUploadDto } from './media.dto';
|
||||||
import type { MessagePrincipal } from '../identity/actor.resolver';
|
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')
|
@Controller('v1/media')
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -37,6 +40,12 @@ export class MediaController {
|
|||||||
async blob(@Param('token') token: string, @Res() res: Response) {
|
async blob(@Param('token') token: string, @Res() res: Response) {
|
||||||
const { data, mime } = await this.media.get(token);
|
const { data, mime } = await this.media.get(token);
|
||||||
res.setHeader('Content-Type', mime);
|
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.setHeader('Cache-Control', 'private, max-age=3600');
|
||||||
res.send(data);
|
res.send(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => {
|
|||||||
expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
|
expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => {
|
||||||
|
const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes });
|
||||||
|
for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) {
|
||||||
|
expect((await up(mime)).allow).toBe(true);
|
||||||
|
}
|
||||||
|
const bad = await up('application/x-msdownload');
|
||||||
|
expect(bad.allow).toBe(false);
|
||||||
|
expect(bad.obligations[0]?.reason).toMatch(/not allowed/);
|
||||||
|
const big = await up('image/png', 30 * 1024 * 1024);
|
||||||
|
expect(big.allow).toBe(false);
|
||||||
|
expect(big.obligations[0]?.reason).toMatch(/too large/);
|
||||||
|
});
|
||||||
|
|
||||||
it('self-join is governed only on membership threads', async () => {
|
it('self-join is governed only on membership threads', async () => {
|
||||||
// generic / support thread (no membership attr) → open join, unchanged
|
// generic / support thread (no membership attr) → open join, unchanged
|
||||||
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
|
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface OpaInput {
|
|||||||
const MEDIA_MAX_BYTES = 25 * 1024 * 1024;
|
const MEDIA_MAX_BYTES = 25 * 1024 * 1024;
|
||||||
const MEDIA_ALLOWED = /^(image|video|audio)\//;
|
const MEDIA_ALLOWED = /^(image|video|audio)\//;
|
||||||
const MEDIA_ALLOWED_DOCS = new Set([
|
const MEDIA_ALLOWED_DOCS = new Set([
|
||||||
'application/pdf', 'text/plain', 'application/zip',
|
'application/pdf', 'text/plain', 'text/markdown', 'text/x-markdown', 'text/html', 'text/csv', 'application/zip',
|
||||||
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
|||||||
Reference in New Issue
Block a user