feat(media-proxy): sign attachment URLs and gate origins (#2830)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-18 15:57:32 +02:00
1 parent 025c01ab13
commit 522cf08e61
108 files changed
+10148 -441

No files matched your search

@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {signAttachmentUrl} from '@app/api/attachment/AttachmentUrls';
import {Config} from '@app/api/Config';
import {
IMediaService,
@@ -9,11 +10,13 @@ import {
type MediaProxyMetadataRequest,
type MediaProxyMetadataResponse,
type MediaProxyNsfwMode,
type MediaProxySniffResponse,
} from '@app/api/infrastructure/IMediaService';
import {Logger} from '@app/api/Logger';
import * as FetchUtils from '@app/api/utils/FetchUtils';
import {isJsonRecord, parseJsonWithGuard} from '@app/api/utils/JsonBoundaryUtils';
import {ExplicitContentCannotBeSentError} from '@fluxer/errors/src/domains/moderation/ExplicitContentCannotBeSentError';
import {attachmentStorageKeyFromUrl} from '@pkgs/media_proxy_utils/src/AttachmentUrlSignature';
import * as MediaProxyUtils from '@pkgs/media_proxy_utils/src/MediaProxyUtils';
import {ms} from 'itty-time';
@@ -30,7 +33,13 @@ const MEDIA_PROXY_METADATA_WITH_BASE64_MAX_BYTES = 64 * 1024 * 1024;
const MEDIA_PROXY_ERROR_MAX_BYTES = 16 * 1024;
const MEDIA_PROXY_THUMBNAIL_MAX_BYTES = 8 * 1024 * 1024;
const MEDIA_PROXY_FRAMES_MAX_BYTES = 512 * 1024;
const MEDIA_PROXY_SNIFF_MAX_BYTES = 1024;
const MEDIA_PROXY_REQUEST_TIMEOUT_MS = ms('30 seconds');
const MEDIA_PROXY_SNIFF_TIMEOUT_MS = ms('2 seconds');
function isMediaProxySniffResponse(value: unknown): value is MediaProxySniffResponse {
return isJsonRecord(value) && (value.content_type === null || typeof value.content_type === 'string');
}
function isMediaProxyMetadataResponse(value: unknown): value is MediaProxyMetadataResponse {
if (!isJsonRecord(value)) return false;
@@ -135,6 +144,9 @@ export class MediaService extends IMediaService {
}
getExternalMediaProxyURL(url: string): string {
if (attachmentStorageKeyFromUrl(url, Config.endpoints.media) !== null) {
return signAttachmentUrl(url);
}
let urlObj: URL;
try {
urlObj = new URL(url);
@@ -166,6 +178,34 @@ export class MediaService extends IMediaService {
}
}
async sniffUpload(uploadFilename: string): Promise<MediaProxySniffResponse | null> {
const response = await this.makeRequest(
'/_sniff',
{
type: 'upload',
upload_filename: uploadFilename,
},
MEDIA_PROXY_SNIFF_TIMEOUT_MS,
);
if (!response) return null;
try {
const responseText = await FetchUtils.streamToStringWithLimit(response.body, {
maxBytes: MEDIA_PROXY_SNIFF_MAX_BYTES,
headers: response.headers,
description: 'Media proxy sniff response',
});
const sniff = parseJsonWithGuard(responseText, isMediaProxySniffResponse);
if (!sniff) {
Logger.error({uploadFilename}, 'Media proxy returned invalid sniff response');
return null;
}
return {content_type: sniff.content_type};
} catch (error) {
Logger.error({error, uploadFilename}, 'Failed to read media proxy sniff response');
return null;
}
}
async extractFrames(request: MediaProxyFrameRequest): Promise<MediaProxyFrameResponse> {
const response = await this.makeRequest('/_frames', request);
if (!response) {
@@ -183,7 +223,11 @@ export class MediaService extends IMediaService {
return data;
}
private async makeRequest(endpoint: string, body: MediaProxyRequestBody): Promise<Response | null> {
private async makeRequest(
endpoint: string,
body: MediaProxyRequestBody,
timeoutMs: number = MEDIA_PROXY_REQUEST_TIMEOUT_MS,
): Promise<Response | null> {
try {
const url = `http://${Config.mediaProxy.host}:${Config.mediaProxy.port}${endpoint}`;
const response = await fetch(url, {
@@ -193,7 +237,7 @@ export class MediaService extends IMediaService {
'Content-Type': 'application/json',
Authorization: `Bearer ${Config.mediaProxy.secretKey}`,
},
signal: AbortSignal.timeout(MEDIA_PROXY_REQUEST_TIMEOUT_MS),
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
const errorText = await FetchUtils.streamToStringWithLimit(response.body, {