From f2ea10f951fefe37c1f21eef423e48323e95d640 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 29 Aug 2026 14:55:09 +0200 Subject: [PATCH] fix(api): harden ratelimit, SSRF, uploads and DM guards (#2074) --- .../src/PublicInternetRequestUrlPolicy.ts | 86 +++++++++++++++---- .../services/AttachmentUploadService.ts | 14 ++- .../channel/services/DMPermissionValidator.ts | 8 +- .../src/api/download/DownloadService.ts | 9 +- .../src/api/infrastructure/IStorageService.ts | 1 + .../src/api/infrastructure/StorageService.ts | 3 + .../src/api/middleware/RateLimitMiddleware.ts | 6 +- .../user/services/UserAccountLookupService.ts | 19 ++-- .../user/services/UserRelationshipService.ts | 16 ++-- 9 files changed, 121 insertions(+), 41 deletions(-) diff --git a/fluxer_api/pkgs/http_client/src/PublicInternetRequestUrlPolicy.ts b/fluxer_api/pkgs/http_client/src/PublicInternetRequestUrlPolicy.ts index cd41a0a25..9502951a4 100644 --- a/fluxer_api/pkgs/http_client/src/PublicInternetRequestUrlPolicy.ts +++ b/fluxer_api/pkgs/http_client/src/PublicInternetRequestUrlPolicy.ts @@ -89,29 +89,77 @@ function isFqdnHostname(hostname: string): boolean { return !/^\d+$/.test(topLevelDomain); } -function parseIpv4MappedIpv6Address(ipv6Address: string): string | null { +function expandIpv6ToBytes(ipv6Address: string): Uint8Array | null { const normalized = stripIpv6Brackets(ipv6Address.trim().toLowerCase()); - if (!normalized.startsWith('::ffff:')) { + if (isIP(normalized) !== 6) { return null; } - const suffix = normalized.slice('::ffff:'.length); - if (isIP(suffix) === 4) { - return suffix; + let head = normalized; + let embeddedIpv4Octets: Array | null = null; + const lastColonIndex = head.lastIndexOf(':'); + const trailing = head.slice(lastColonIndex + 1); + if (trailing.includes('.')) { + if (isIP(trailing) !== 4) { + return null; + } + embeddedIpv4Octets = trailing.split('.').map((part) => Number.parseInt(part, 10)); + head = `${head.slice(0, lastColonIndex + 1)}0:0`; } - const groups = suffix.split(':'); - if (groups.length !== 2) { + let groups: Array; + if (head.indexOf('::') === -1) { + groups = head.split(':'); + if (groups.length !== 8) { + return null; + } + } else { + const [beforePart, afterPart] = head.split('::'); + const before = beforePart.length > 0 ? beforePart.split(':') : []; + const after = afterPart.length > 0 ? afterPart.split(':') : []; + const missing = 8 - before.length - after.length; + if (missing < 1) { + return null; + } + groups = [...before, ...new Array(missing).fill('0'), ...after]; + } + const bytes = new Uint8Array(16); + for (let index = 0; index < 8; index += 1) { + const value = parseHexGroup(groups[index]); + if (value === null) { + return null; + } + bytes[index * 2] = (value >> 8) & 0xff; + bytes[index * 2 + 1] = value & 0xff; + } + if (embeddedIpv4Octets) { + bytes[12] = embeddedIpv4Octets[0]; + bytes[13] = embeddedIpv4Octets[1]; + bytes[14] = embeddedIpv4Octets[2]; + bytes[15] = embeddedIpv4Octets[3]; + } + return bytes; +} + +function parseEmbeddedIpv4Address(ipv6Address: string): string | null { + const bytes = expandIpv6ToBytes(ipv6Address); + if (!bytes) { return null; } - const high = parseHexGroup(groups[0]); - const low = parseHexGroup(groups[1]); - if (high === null || low === null) { - return null; + const hasPrefix = (prefix: Array): boolean => prefix.every((byte, index) => bytes[index] === byte); + const dottedQuadAt = (start: number): string => + `${bytes[start]}.${bytes[start + 1]}.${bytes[start + 2]}.${bytes[start + 3]}`; + if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff])) { + return dottedQuadAt(12); } - const octet1 = (high >> 8) & 0xff; - const octet2 = high & 0xff; - const octet3 = (low >> 8) & 0xff; - const octet4 = low & 0xff; - return `${octet1}.${octet2}.${octet3}.${octet4}`; + if (hasPrefix([0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0])) { + return dottedQuadAt(12); + } + if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) { + return dottedQuadAt(12); + } + if (hasPrefix([0x20, 0x02])) { + return dottedQuadAt(2); + } + return null; } function parseHexGroup(value: string | undefined): number | null { @@ -128,9 +176,9 @@ function isBlockedIpAddress(address: string): boolean { return blockedIpv4List.check(normalizedAddress, 'ipv4'); } if (family === 6) { - const mappedIpv4 = parseIpv4MappedIpv6Address(normalizedAddress); - if (mappedIpv4) { - return blockedIpv4List.check(mappedIpv4, 'ipv4'); + const embeddedIpv4 = parseEmbeddedIpv4Address(normalizedAddress); + if (embeddedIpv4) { + return blockedIpv4List.check(embeddedIpv4, 'ipv4'); } return blockedIpv6List.check(normalizedAddress, 'ipv6'); } diff --git a/fluxer_api/src/api/channel/services/AttachmentUploadService.ts b/fluxer_api/src/api/channel/services/AttachmentUploadService.ts index 05eae545f..b39b22347 100644 --- a/fluxer_api/src/api/channel/services/AttachmentUploadService.ts +++ b/fluxer_api/src/api/channel/services/AttachmentUploadService.ts @@ -233,11 +233,14 @@ export class AttachmentUploadService { const parts = await Promise.all( Array.from({length: partCount}, async (_, index) => { const partNumber = index + 1; + const partContentLength = + partNumber < partCount ? partSize : attachment.file_size - partSize * (partCount - 1); const presigned_upload_url = await this.storageService.getPresignedUploadPartURL({ bucket, key: uploadKey, uploadId, partNumber, + contentLength: partContentLength, }); const upload_url = applyUploadRelayDecision({ presignedUrl: presigned_upload_url, @@ -246,7 +249,7 @@ export class AttachmentUploadService { relayDecision: uploadRelayDecision, uploadId, partNumber, - maxBytes: partSize, + maxBytes: partContentLength, }); return {part_number: partNumber, upload_url}; }), @@ -275,7 +278,7 @@ export class AttachmentUploadService { if (!Config.presignedAttachmentUploadsEnabled) { throw new FeatureTemporarilyDisabledError(); } - await this.getUploadPermissionAndLimit({userId, channelId}); + const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId}); const bucket = Config.s3.buckets.uploads; return Promise.all( uploads.map(async ({upload_filename, upload_id}, index) => { @@ -305,6 +308,13 @@ export class AttachmentUploadService { .catch(() => undefined); throw InputValidationError.fromCode('parts', ValidationErrorCodes.NO_UPLOADED_PARTS_TO_FINALIZE); } + const totalUploadedBytes = parts.reduce((sum, part) => sum + (part.size ?? 0), 0); + if (totalUploadedBytes > maxFileSize) { + await this.storageService + .abortMultipartUpload({bucket, key: upload_filename, uploadId: upload_id}) + .catch(() => undefined); + throw new FileSizeTooLargeError(maxFileSize); + } try { await runAttachmentStorageOperation(() => this.storageService.completeMultipartUpload({ diff --git a/fluxer_api/src/api/channel/services/DMPermissionValidator.ts b/fluxer_api/src/api/channel/services/DMPermissionValidator.ts index 85994aed7..a79b9dc4e 100644 --- a/fluxer_api/src/api/channel/services/DMPermissionValidator.ts +++ b/fluxer_api/src/api/channel/services/DMPermissionValidator.ts @@ -40,9 +40,8 @@ export class DMPermissionValidator { if (isBugHunterBotUser(senderUser)) { return; } - if (!senderUser.isBot && (senderUser.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER) { - return; - } + const isShadowbannedSpammer = + !senderUser.isBot && (senderUser.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER; const [senderBlockedTarget, targetBlockedSender, areFriends, targetSettings, senderSettings] = await Promise.all([ this.deps.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.BLOCKED), this.deps.userRepository.getRelationship(recipientId, senderId, RelationshipTypes.BLOCKED), @@ -53,6 +52,9 @@ export class DMPermissionValidator { if (senderBlockedTarget || targetBlockedSender) { throw new CannotSendMessagesToUserError(); } + if (isShadowbannedSpammer) { + return; + } if (areFriends) { return; } diff --git a/fluxer_api/src/api/download/DownloadService.ts b/fluxer_api/src/api/download/DownloadService.ts index 10a138642..7a008f203 100644 --- a/fluxer_api/src/api/download/DownloadService.ts +++ b/fluxer_api/src/api/download/DownloadService.ts @@ -51,6 +51,7 @@ function isUnsatisfiableRangeError(error: unknown): boolean { } const DESKTOP_BUCKET_PREFIX = 'desktop'; const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test'; +const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`]; const DEFAULT_API_CLIENT_BASE_URL = 'https://api.fluxer.app'; const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/fluxerapp/fluxer/releases/download'; const GITHUB_RELEASE_MARKER_DIRECTORY = 'github-releases'; @@ -987,7 +988,13 @@ export class DownloadService { return null; } } - return normalized.length > 0 ? normalized : null; + if (normalized.length === 0) { + return null; + } + if (!DOWNLOAD_KEY_ALLOWED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return null; + } + return normalized; } private normalizePlatformArchKey(key: string): string | null { diff --git a/fluxer_api/src/api/infrastructure/IStorageService.ts b/fluxer_api/src/api/infrastructure/IStorageService.ts index a00b79aaa..56824e67b 100644 --- a/fluxer_api/src/api/infrastructure/IStorageService.ts +++ b/fluxer_api/src/api/infrastructure/IStorageService.ts @@ -91,6 +91,7 @@ export interface IStorageService { key: string; uploadId: string; partNumber: number; + contentLength?: number; expiresIn?: number; }): Promise; purgeBucket(bucket: string): Promise; diff --git a/fluxer_api/src/api/infrastructure/StorageService.ts b/fluxer_api/src/api/infrastructure/StorageService.ts index 686d3ba40..68a7e8ebc 100644 --- a/fluxer_api/src/api/infrastructure/StorageService.ts +++ b/fluxer_api/src/api/infrastructure/StorageService.ts @@ -296,12 +296,14 @@ export class StorageService implements IStorageService { key, uploadId, partNumber, + contentLength, expiresIn = seconds('1 hour'), }: { bucket: string; key: string; uploadId: string; partNumber: number; + contentLength?: number; expiresIn?: number; }): Promise { const command = new UploadPartCommand({ @@ -309,6 +311,7 @@ export class StorageService implements IStorageService { Key: key, UploadId: uploadId, PartNumber: partNumber, + ContentLength: contentLength, }); return getSignedUrl(this.presignClient, command, {expiresIn}); } diff --git a/fluxer_api/src/api/middleware/RateLimitMiddleware.ts b/fluxer_api/src/api/middleware/RateLimitMiddleware.ts index 080035926..86aa2a760 100644 --- a/fluxer_api/src/api/middleware/RateLimitMiddleware.ts +++ b/fluxer_api/src/api/middleware/RateLimitMiddleware.ts @@ -52,7 +52,11 @@ function shouldShowHeadersOnSuccess(accountType: AccountType): boolean { function getClientIdentifier(ctx: Context): string { const user = ctx.get('user'); if (user?.id) { - return `user:${user.id}`; + const tokenType = ctx.get('authTokenType') ?? 'session'; + if (tokenType === 'bearer') { + return `user:${user.id}:bearer:${ctx.get('oauthBearerApplicationId') ?? 'unknown'}`; + } + return `user:${user.id}:${tokenType}`; } const ip = extractClientIp(ctx.req.raw, { trustClientIpHeader: Config.proxy.trust_client_ip_header, diff --git a/fluxer_api/src/api/user/services/UserAccountLookupService.ts b/fluxer_api/src/api/user/services/UserAccountLookupService.ts index 161e968e3..ae25ffc3f 100644 --- a/fluxer_api/src/api/user/services/UserAccountLookupService.ts +++ b/fluxer_api/src/api/user/services/UserAccountLookupService.ts @@ -89,14 +89,17 @@ export class UserAccountLookupService { let guildMember: GuildMemberResponse | null = null; let guildMemberDomain: GuildMember | null = null; if (guildId != null) { - guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId); - if (guildMemberDomain) { - guildMember = await this.deps.guildService.members.getMember({ - userId, - targetId, - guildId, - requestCache, - }); + const viewerMember = await this.deps.guildRepository.getMember(guildId, userId); + if (viewerMember) { + guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId); + if (guildMemberDomain) { + guildMember = await this.deps.guildService.members.getMember({ + userId, + targetId, + guildId, + requestCache, + }); + } } } let premiumType = user.premiumType ?? undefined; diff --git a/fluxer_api/src/api/user/services/UserRelationshipService.ts b/fluxer_api/src/api/user/services/UserRelationshipService.ts index 3b2931487..86f1a2201 100644 --- a/fluxer_api/src/api/user/services/UserRelationshipService.ts +++ b/fluxer_api/src/api/user/services/UserRelationshipService.ts @@ -118,15 +118,17 @@ export class UserRelationshipService { userCacheService: UserCacheService; requestCache: RequestCache; }): Promise { - if (!staffForceAccept && (await getInstanceConfigRepository().getInstancePolicyConfig()).direct_messages_disabled) { + const requesterUser = await this.userRepository.findUnique(userId); + const requesterIsStaff = + requesterUser != null && (requesterUser.flags & UserFlags.STAFF) === UserFlags.STAFF; + if ( + !requesterIsStaff && + (await getInstanceConfigRepository().getInstancePolicyConfig()).direct_messages_disabled + ) { throw new DirectMessagesDisabledError(); } - const requesterUser = await this.userRepository.findUnique(userId); - if (staffForceAccept) { - const requesterIsStaff = requesterUser != null && (requesterUser.flags & UserFlags.STAFF) === UserFlags.STAFF; - if (requesterIsStaff) { - return await this.forceCreateFriendship({userId, targetId, userCacheService, requestCache}); - } + if (staffForceAccept && requesterIsStaff) { + return await this.forceCreateFriendship({userId, targetId, userCacheService, requestCache}); } if (!requesterUser) { throw new UnknownUserError();