fix(api): harden ratelimit, SSRF, uploads and DM guards (#2074)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-29 14:55:09 +02:00
1 parent bbd93df239
commit f2ea10f951
9 files changed
+121 -41

No files matched your search

@@ -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({
@@ -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;
}
@@ -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 {
@@ -91,6 +91,7 @@ export interface IStorageService {
key: string;
uploadId: string;
partNumber: number;
contentLength?: number;
expiresIn?: number;
}): Promise<string>;
purgeBucket(bucket: string): Promise<void>;
@@ -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<string> {
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});
}
@@ -52,7 +52,11 @@ function shouldShowHeadersOnSuccess(accountType: AccountType): boolean {
function getClientIdentifier(ctx: Context<HonoEnv>): 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,
@@ -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;
@@ -118,15 +118,17 @@ export class UserRelationshipService {
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
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();