feat: improve attachment headers (#13709)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Safer, consistent file downloads with automatic attachment headers and
filenames.
- Smarter MIME detection for uploads (avatars, workspace blobs, Copilot
files/transcripts).
  - Sensible default buffer limit when reading uploads.

- **Bug Fixes**
- Prevents risky content from rendering inline by forcing downloads and
adding no‑sniff protection.
- More accurate content types when original metadata is missing or
incorrect.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-10-09 16:04:18 +08:00
committed by GitHub
parent bf72833f05
commit 1b859a37c5
18 changed files with 143 additions and 33 deletions
@@ -135,4 +135,4 @@ export const StorageJSONSchema: JSONSchema = {
};
export type * from './provider';
export { autoMetadata, toBuffer } from './utils';
export { applyAttachHeaders, autoMetadata, sniffMime, toBuffer } from './utils';
@@ -1,6 +1,7 @@
import { Readable } from 'node:stream';
import { crc32 } from '@node-rs/crc32';
import type { Response } from 'express';
import { getStreamAsBuffer } from 'get-stream';
import { getMime } from '../../../native';
@@ -43,4 +44,53 @@ export function autoMetadata(
return metadata;
}
const DANGEROUS_INLINE_MIME_PREFIXES = [
'text/html',
'application/xhtml+xml',
'image/svg+xml',
'application/xml',
'text/xml',
'text/javascript',
];
export function isDangerousInlineMime(mime: string | undefined) {
if (!mime) return false;
const lower = mime.toLowerCase();
return DANGEROUS_INLINE_MIME_PREFIXES.some(p => lower.startsWith(p));
}
export function applyAttachHeaders(
res: Response,
options: { filename?: string; buffer?: Buffer; contentType?: string }
) {
let { filename, buffer, contentType } = options;
res.setHeader('X-Content-Type-Options', 'nosniff');
if (!contentType && buffer) contentType = sniffMime(buffer);
if (contentType && isDangerousInlineMime(contentType)) {
const safeName = (filename || 'download')
.replace(/[\r\n]/g, '')
.replace(/[^\w\s.-]/g, '_');
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(safeName)}"; filename*=UTF-8''${encodeURIComponent(
safeName
)}`
);
}
if (!res.getHeader('Content-Type')) {
res.setHeader('Content-Type', contentType || 'application/octet-stream');
}
}
export function sniffMime(
buffer: Buffer,
declared?: string
): string | undefined {
try {
const detected = getMime(buffer);
if (detected) return detected;
} catch {}
return declared;
}
export const SIGNED_URL_EXPIRED = 60 * 60; // 1 hour
@@ -1,6 +1,7 @@
import { Readable } from 'node:stream';
import { BlobQuotaExceeded, StorageQuotaExceeded } from '../error';
import { OneKB } from './unit';
export type CheckExceededResult =
| {
@@ -52,7 +53,7 @@ export async function readBuffer(
export async function readBufferWithLimit(
readable: Readable,
limit: number
limit: number = 500 * OneKB
): Promise<Buffer> {
return readBuffer(readable, size =>
size > limit