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

View File

@@ -30,6 +30,7 @@ import {
createTestingApp,
createWorkspace,
inviteUser,
smallestPng,
TestingApp,
TestUser,
} from './utils';
@@ -453,8 +454,6 @@ test('should create message correctly', async t => {
randomUUID(),
textPromptName
);
const smallestPng =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
const messageId = await createCopilotMessage(
app,
@@ -475,8 +474,6 @@ test('should create message correctly', async t => {
randomUUID(),
textPromptName
);
const smallestPng =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
const messageId = await createCopilotMessage(
app,

View File

@@ -6,6 +6,8 @@ import ava from 'ava';
import {
createTestingApp,
getPublicUserById,
smallestGif,
smallestPng,
TestingApp,
updateAvatar,
} from '../utils';
@@ -27,7 +29,9 @@ test('should be able to upload user avatar', async t => {
const { app } = t.context;
await app.signup();
const avatar = Buffer.from('test');
const avatar = await fetch(smallestPng)
.then(res => res.arrayBuffer())
.then(b => Buffer.from(b));
const res = await updateAvatar(app, avatar);
t.is(res.status, 200);
@@ -36,19 +40,23 @@ test('should be able to upload user avatar', async t => {
const avatarRes = await app.GET(new URL(avatarUrl).pathname);
t.deepEqual(avatarRes.body, Buffer.from('test'));
t.deepEqual(avatarRes.body, avatar);
});
test('should be able to update user avatar, and invalidate old avatar url', async t => {
const { app } = t.context;
await app.signup();
const avatar = Buffer.from('test');
const avatar = await fetch(smallestPng)
.then(res => res.arrayBuffer())
.then(b => Buffer.from(b));
let res = await updateAvatar(app, avatar);
const oldAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
const newAvatar = Buffer.from('new');
const newAvatar = await fetch(smallestGif)
.then(res => res.arrayBuffer())
.then(b => Buffer.from(b));
res = await updateAvatar(app, newAvatar);
const newAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
@@ -58,14 +66,16 @@ test('should be able to update user avatar, and invalidate old avatar url', asyn
t.is(avatarRes.status, 404);
const newAvatarRes = await app.GET(new URL(newAvatarUrl).pathname);
t.deepEqual(newAvatarRes.body, Buffer.from('new'));
t.deepEqual(newAvatarRes.body, newAvatar);
});
test('should be able to get public user by id', async t => {
const { app } = t.context;
const u1 = await app.signup();
const avatar = Buffer.from('test');
const avatar = await fetch(smallestPng)
.then(res => res.arrayBuffer())
.then(b => Buffer.from(b));
await updateAvatar(app, avatar);
const u2 = await app.signup();

View File

@@ -3,6 +3,10 @@ import { type Blob } from '@prisma/client';
import { TestingApp } from './testing-app';
import { TEST_LOG_LEVEL } from './utils';
export const smallestPng =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
export const smallestGif = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=';
export async function listBlobs(
app: TestingApp,
workspaceId: string

View File

@@ -135,4 +135,4 @@ export const StorageJSONSchema: JSONSchema = {
};
export type * from './provider';
export { autoMetadata, toBuffer } from './utils';
export { applyAttachHeaders, autoMetadata, sniffMime, toBuffer } from './utils';

View File

@@ -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

View File

@@ -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

View File

@@ -1,7 +1,11 @@
import { Controller, Get, Param, Res } from '@nestjs/common';
import type { Response } from 'express';
import { ActionForbidden, UserAvatarNotFound } from '../../base';
import {
ActionForbidden,
applyAttachHeaders,
UserAvatarNotFound,
} from '../../base';
import { Public } from '../auth/guard';
import { AvatarStorage } from '../storage';
@@ -30,6 +34,10 @@ export class UserAvatarController {
res.setHeader('last-modified', metadata.lastModified.toISOString());
res.setHeader('content-length', metadata.contentLength);
}
applyAttachHeaders(res, {
contentType: metadata?.contentType,
filename: `${id}`,
});
body.pipe(res);
}

View File

@@ -17,6 +17,8 @@ import { isNil, omitBy } from 'lodash-es';
import {
CannotDeleteOwnAccount,
type FileUpload,
readBufferWithLimit,
sniffMime,
Throttle,
UserNotFound,
} from '../../base';
@@ -98,20 +100,20 @@ export class UserResolver {
@Args({ name: 'avatar', type: () => GraphQLUpload })
avatar: FileUpload
) {
if (!avatar.mimetype.startsWith('image/')) {
throw new Error('Invalid file type');
}
if (!user) {
throw new UserNotFound();
}
const avatarBuffer = await readBufferWithLimit(avatar.createReadStream());
const contentType = sniffMime(avatarBuffer, avatar.mimetype);
if (!contentType || !contentType.startsWith('image/')) {
throw new Error(`Invalid file type: ${contentType || 'unknown'}`);
}
const avatarUrl = await this.storage.put(
`${user.id}-avatar-${Date.now()}`,
avatar.createReadStream(),
{
contentType: avatar.mimetype,
}
avatarBuffer,
{ contentType }
);
if (user.avatarUrl) {

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Logger, Param, Query, Res } from '@nestjs/common';
import type { Response } from 'express';
import {
applyAttachHeaders,
BlobNotFound,
CallMetric,
CommentAttachmentNotFound,
@@ -83,6 +84,10 @@ export class WorkspacesController {
} else {
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
}
applyAttachHeaders(res, {
contentType: metadata?.contentType,
filename: name,
});
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
body.pipe(res);
@@ -215,6 +220,10 @@ export class WorkspacesController {
`Comment attachment ${workspaceId}/${docId}/${key} has no metadata`
);
}
applyAttachHeaders(res, {
contentType: metadata?.contentType,
filename: key,
});
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
body.pipe(res);

View File

@@ -31,6 +31,7 @@ import {
EventBus,
type FileUpload,
RequestMutex,
sniffMime,
Throttle,
TooManyRequest,
UserFriendlyError,
@@ -671,7 +672,11 @@ export class CopilotContextResolver {
const { filename, mimetype } = content;
await this.storage.put(user.id, session.workspaceId, blobId, buffer);
const file = await session.addFile(blobId, filename, mimetype);
const file = await session.addFile(
blobId,
filename,
sniffMime(buffer, mimetype) || mimetype
);
await this.jobs.addFileEmbeddingQueue({
userId: user.id,

View File

@@ -32,6 +32,7 @@ import {
} from 'rxjs';
import {
applyAttachHeaders,
BlobNotFound,
CallMetric,
Config,
@@ -795,6 +796,10 @@ export class CopilotController implements BeforeApplicationShutdown {
} else {
this.logger.warn(`Blob ${workspaceId}/${key} has no metadata`);
}
applyAttachHeaders(res, {
contentType: metadata?.contentType,
filename: key,
});
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
body.pipe(res);

View File

@@ -30,6 +30,7 @@ import {
Paginated,
PaginationInput,
RequestMutex,
sniffMime,
Throttle,
TooManyRequest,
UserFriendlyError,
@@ -806,7 +807,10 @@ export class CopilotResolver {
filename,
uploaded.buffer
);
attachments.push({ attachment, mimeType: blob.mimetype });
attachments.push({
attachment,
mimeType: sniffMime(uploaded.buffer, blob.mimetype) || blob.mimetype,
});
}
}

View File

@@ -12,6 +12,7 @@ import {
NoCopilotProviderAvailable,
OnEvent,
OnJob,
sniffMime,
} from '../../../base';
import { Models } from '../../../models';
import { PromptService } from '../prompt';
@@ -85,7 +86,10 @@ export class CopilotTranscriptionService {
`${blobId}-${idx}`,
buffer
);
infos.push({ url, mimeType: blob.mimetype });
infos.push({
url,
mimeType: sniffMime(buffer, blob.mimetype) || blob.mimetype,
});
}
const model = await this.getModel(userId);

View File

@@ -2,7 +2,12 @@ import { createHash } from 'node:crypto';
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { FileUpload, JobQueue, PaginationInput } from '../../../base';
import {
FileUpload,
JobQueue,
PaginationInput,
sniffMime,
} from '../../../base';
import { ServerFeature, ServerService } from '../../../core';
import { Models } from '../../../models';
import { CopilotStorage } from '../storage';
@@ -64,7 +69,7 @@ export class CopilotWorkspaceService implements OnApplicationBootstrap {
const file = await this.models.copilotWorkspace.addFile(workspaceId, {
fileName,
blobId,
mimeType: content.mimetype,
mimeType: sniffMime(buffer, content.mimetype) || content.mimetype,
size: buffer.length,
});
return { blobId, file };