Critical - Upstream: zotan - Don't treat HTTP 429 errors as non-retryable, Apply rate limits to proxyServer and fileServer

This commit is contained in:
Crimekillz 2024-11-20 21:48:48 +01:00
parent 24d380d37e
commit b09148840f
9 changed files with 68 additions and 9 deletions

View File

@ -195,6 +195,7 @@ export class StatusError extends Error {
public statusCode: number; public statusCode: number;
public statusMessage?: string; public statusMessage?: string;
public isClientError: boolean; public isClientError: boolean;
public isRetryable: boolean;
constructor(message: string, statusCode: number, statusMessage?: string) { constructor(message: string, statusCode: number, statusMessage?: string) {
super(message); super(message);
@ -205,5 +206,6 @@ export class StatusError extends Error {
typeof this.statusCode === "number" && typeof this.statusCode === "number" &&
this.statusCode >= 400 && this.statusCode >= 400 &&
this.statusCode < 500; this.statusCode < 500;
this.isRetryable = this.isClientError && this.statusCode != 429;
} }
} }

View File

@ -65,7 +65,7 @@ export default async (job: Bull.Job<DeliverJobData>) => {
if (res instanceof StatusError) { if (res instanceof StatusError) {
// 4xx // 4xx
if (res.isClientError) { if (!res.isRetryable) {
// HTTPステータスコード4xxはクライアントエラーであり、それはつまり // HTTPステータスコード4xxはクライアントエラーであり、それはつまり
// 何回再送しても成功することはないということなのでエラーにはしないでおく // 何回再送しても成功することはないということなのでエラーにはしないでおく
return `${res.statusCode} ${res.statusMessage}`; return `${res.statusCode} ${res.statusMessage}`;

View File

@ -75,7 +75,7 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
} catch (e) { } catch (e) {
// Skip if target is 4xx // Skip if target is 4xx
if (e instanceof StatusError) { if (e instanceof StatusError) {
if (e.isClientError) { if (!e.isRetryable) {
return `skip: Ignored deleted actors on both ends ${activity.actor} - ${e.statusCode}`; return `skip: Ignored deleted actors on both ends ${activity.actor} - ${e.statusCode}`;
} }
throw new Error( throw new Error(

View File

@ -52,7 +52,7 @@ export default async (job: Bull.Job<WebhookDeliverJobData>) => {
if (res instanceof StatusError) { if (res instanceof StatusError) {
// 4xx // 4xx
if (res.isClientError) { if (!res.isRetryable) {
return `${res.statusCode} ${res.statusMessage}`; return `${res.statusCode} ${res.statusMessage}`;
} }

View File

@ -48,7 +48,7 @@ export default async function (
} catch (e) { } catch (e) {
// Skip if target is 4xx // Skip if target is 4xx
if (e instanceof StatusError) { if (e instanceof StatusError) {
if (e.isClientError) { if (!e.isRetryable) {
logger.warn(`Ignored announce target ${targetUri} - ${e.statusCode}`); logger.warn(`Ignored announce target ${targetUri} - ${e.statusCode}`);
return; return;
} }

View File

@ -40,7 +40,7 @@ export default async function (
await createNote(note, resolver, silent); await createNote(note, resolver, silent);
return "ok"; return "ok";
} catch (e) { } catch (e) {
if (e instanceof StatusError && e.isClientError) { if (e instanceof StatusError && !e.isRetryable) {
return `skip ${e.statusCode}`; return `skip ${e.statusCode}`;
} else { } else {
throw e; throw e;

View File

@ -290,7 +290,7 @@ export async function createNote(
} catch (e) { } catch (e) {
return { return {
status: status:
e instanceof StatusError && e.isClientError e instanceof StatusError && !e.isRetryable
? "permerror" ? "permerror"
: "temperror", : "temperror",
}; };

View File

@ -14,7 +14,11 @@ import { detectType } from "@/misc/get-file-info.js";
import { convertToWebp } from "@/services/drive/image-processor.js"; import { convertToWebp } from "@/services/drive/image-processor.js";
import { GenerateVideoThumbnail } from "@/services/drive/generate-video-thumbnail.js"; import { GenerateVideoThumbnail } from "@/services/drive/generate-video-thumbnail.js";
import { StatusError } from "@/misc/fetch.js"; import { StatusError } from "@/misc/fetch.js";
import { FILE_TYPE_BROWSERSAFE } from "@/const.js"; import { FILE_TYPE_BROWSERSAFE, MINUTE } from "@/const.js";
import { IEndpointMeta } from "@/server/api/endpoints.js";
import { getIpHash } from "@/misc/get-ip-hash.js";
import { limiter } from "@/server/api/limiter.js";
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
@ -31,6 +35,30 @@ const commonReadableHandlerGenerator =
export default async function (ctx: Koa.Context) { export default async function (ctx: Koa.Context) {
const key = ctx.params.key; const key = ctx.params.key;
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
const limitActor = getIpHash(ctx.ip);
const limit: IEndpointMeta["limit"] = {
key: `drive-file:${key}`,
duration: MINUTE * 10,
max: 10
}
// Rate limit
await limiter(
limit as IEndpointMeta["limit"] & { key: NonNullable<string> },
limitActor,
).catch((e) => {
const remainingTime = e.remainingTime
? `Please try again in ${e.remainingTime}.`
: "Please try again later.";
ctx.status = 429;
ctx.body = `Rate limit exceeded. ${remainingTime}`;
});
if (ctx.status === 429) return;
// Fetch drive file // Fetch drive file
const file = await DriveFiles.createQueryBuilder("file") const file = await DriveFiles.createQueryBuilder("file")
.where("file.accessKey = :accessKey", { accessKey: key }) .where("file.accessKey = :accessKey", { accessKey: key })
@ -106,7 +134,7 @@ export default async function (ctx: Koa.Context) {
} catch (e) { } catch (e) {
serverLogger.error(`${e}`); serverLogger.error(`${e}`);
if (e instanceof StatusError && e.isClientError) { if (e instanceof StatusError && !e.isRetryable) {
ctx.status = e.statusCode; ctx.status = e.statusCode;
ctx.set("Cache-Control", "max-age=86400"); ctx.set("Cache-Control", "max-age=86400");
} else { } else {

View File

@ -9,9 +9,12 @@ import { createTemp } from "@/misc/create-temp.js";
import { downloadUrl } from "@/misc/download-url.js"; import { downloadUrl } from "@/misc/download-url.js";
import { detectType } from "@/misc/get-file-info.js"; import { detectType } from "@/misc/get-file-info.js";
import { StatusError } from "@/misc/fetch.js"; import { StatusError } from "@/misc/fetch.js";
import { FILE_TYPE_BROWSERSAFE } from "@/const.js"; import { FILE_TYPE_BROWSERSAFE, MINUTE } from "@/const.js";
import { serverLogger } from "../index.js"; import { serverLogger } from "../index.js";
import { isMimeImage } from "@/misc/is-mime-image.js"; import { isMimeImage } from "@/misc/is-mime-image.js";
import { getIpHash } from "@/misc/get-ip-hash.js";
import { limiter } from "@/server/api/limiter.js";
import { IEndpointMeta } from "@/server/api/endpoints.js";
export async function proxyMedia(ctx: Koa.Context) { export async function proxyMedia(ctx: Koa.Context) {
const url = "url" in ctx.query ? ctx.query.url : `https://${ctx.params.url}`; const url = "url" in ctx.query ? ctx.query.url : `https://${ctx.params.url}`;
@ -21,6 +24,32 @@ export async function proxyMedia(ctx: Koa.Context) {
return; return;
} }
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
const limitActor = getIpHash(ctx.ip);
const parsedUrl = new URL(url);
const limit: IEndpointMeta["limit"] = {
key: `media-proxy:${parsedUrl.host}:${parsedUrl.pathname}`,
duration: MINUTE * 10,
max: 10
}
// Rate limit
await limiter(
limit as IEndpointMeta["limit"] & { key: NonNullable<string> },
limitActor,
).catch((e) => {
const remainingTime = e.remainingTime
? `Please try again in ${e.remainingTime}.`
: "Please try again later.";
ctx.status = 429;
ctx.body = `Rate limit exceeded. ${remainingTime}`;
});
if (ctx.status === 429) return;
const { hostname } = new URL(url); const { hostname } = new URL(url);
let resolvedIps; let resolvedIps;
try { try {