mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-21 00:07:01 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user