fix: harden share bot uploads

This commit is contained in:
2026-08-16 01:13:47 +07:00 Unverified
parent c2af37efaa
commit a9f91d6a13
4 changed files with 104 additions and 8 deletions
+58
View File
@@ -24,6 +24,18 @@ export interface DiscordMemberProfile {
roles: DiscordRole[];
}
export type DiscordSenderProfile = Pick<
DiscordMemberProfile,
"id" | "username" | "globalName" | "avatarUrl"
>;
export class DiscordProfileLookupError extends Error {
constructor(message = "Discord profile lookup is unavailable") {
super(message);
this.name = "DiscordProfileLookupError";
}
}
interface CacheEntry<T> {
data: T;
expiresAt: number;
@@ -213,3 +225,49 @@ export async function getDiscordMemberProfile(discordId: string): Promise<Discor
return null;
}
}
export async function getDiscordSenderProfile(
discordId: string,
): Promise<DiscordSenderProfile | null> {
try {
const { token, guildId } = getDiscordServerConfig();
const response = await fetch(
`https://discord.com/api/v10/guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(discordId)}`,
{
headers: { Authorization: `Bot ${token}` },
cache: "no-store",
signal: AbortSignal.timeout(5000),
},
);
if (response.status === 404) return null;
if (!response.ok) throw new DiscordProfileLookupError();
const member = (await response.json()) as {
user?: {
id?: string;
username?: string;
global_name?: string | null;
avatar?: string | null;
};
};
const user = member.user;
if (!user?.id || !user.username || user.id !== discordId) {
throw new DiscordProfileLookupError("Discord returned an invalid sender profile");
}
const avatarUrl = user.avatar
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${user.avatar.startsWith("a_") ? "gif" : "png"}?size=128`
: null;
return {
id: user.id,
username: user.username,
globalName: user.global_name ?? null,
avatarUrl,
};
} catch (error) {
if (error instanceof DiscordProfileLookupError) throw error;
throw new DiscordProfileLookupError();
}
}
+8 -2
View File
@@ -24,8 +24,9 @@ describe("share validation", () => {
throw new Error("Expected blank text to fail");
} catch (error) {
expect(error).toBeInstanceOf(ShareHttpError);
expect((error as ShareHttpError).status).toBe(422);
expect((error as ShareHttpError).status).toBe(400);
}
expect(() => validateShareText("before\0after")).toThrow("NUL");
});
test("measures the text limit in UTF-8 bytes", () => {
@@ -65,7 +66,12 @@ describe("share validation", () => {
type: "text/plain",
})
)
).rejects.toMatchObject({ status: 422 });
).rejects.toMatchObject({ status: 400 });
await expect(
validateTextFile(
new File(["before\0after"], "nul.txt", { type: "text/plain" })
)
).rejects.toMatchObject({ status: 400 });
});
test("validates image contents instead of trusting the MIME type", async () => {
+6 -3
View File
@@ -18,7 +18,10 @@ export function validateShareText(content: unknown) {
throw new ShareHttpError("Text is required", 400);
}
if (!content.trim()) {
throw new ShareHttpError("Text cannot be blank", 422);
throw new ShareHttpError("Text cannot be blank", 400);
}
if (content.includes("\0")) {
throw new ShareHttpError("Text cannot contain NUL bytes", 400);
}
if (Buffer.byteLength(content, "utf8") > MAX_SHARE_TEXT_BYTES) {
throw new ShareHttpError("Text must be 5 MiB or smaller", 413);
@@ -55,7 +58,7 @@ export async function validateTextFile(file: File) {
throw new ShareHttpError("Text file must be 5 MiB or smaller", 413);
}
if (file.size === 0) {
throw new ShareHttpError("Text file cannot be empty", 422);
throw new ShareHttpError("Text file cannot be empty", 400);
}
try {
@@ -64,7 +67,7 @@ export async function validateTextFile(file: File) {
return validateShareText(content);
} catch (error) {
if (error instanceof ShareHttpError) throw error;
throw new ShareHttpError("Text file must contain valid UTF-8", 422);
throw new ShareHttpError("Text file must contain valid UTF-8", 400);
}
}