mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(api): harden ratelimit, SSRF, uploads and DM guards (#2074)
This commit is contained in:
1 parent
bbd93df239
commit
f2ea10f951
9 files changed
+121
-41
No files matched your search
@@ -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<number> | 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<string>;
|
||||
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<number>): 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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user