2 Commits
Author SHA1 Message Date
openhands fe5a7a6185 fix(imaging): keep avatars rendering, cacheable and reliably timed
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m40s
CI / tests-unit (push) Successful in 1m51s
CI / tests-ui (push) Successful in 2m43s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m10s
Effect renders need a little over 4s, which the 4s primary timeout cut off,
so every avatar with the default effect fell through to an unreachable
public fallback and rendered as a placeholder. Raise the primary budget
above the observed render cost and shorten the fallback budget.

Also stop the proxy from stamping no-store over the avatar and media
responses, so browsers keep the long-lived Cache-Control the route already
sends, and recreate the imaging cache directories with the container user
on every deploy, since root ownership made those cache writes fail
silently.
2026-09-24 23:22:28 +02:00
openhands 8486ac4053 feat(security): add darklist.de source and raise the blocklist cap to 1M 2026-09-24 23:22:27 +02:00
9 changed files with 71 additions and 11 deletions

No files matched your search

+1 -1
View File
@@ -135,7 +135,7 @@ CROWDSEC_LAPI_API_KEY=
# Expiration for each blocklist decision (re-synced keeps them fresh).
#CROWDSEC_BLOCKLIST_DURATION=24h
# Combined cap per sync (safety valve against excessive decisions).
#CROWDSEC_BLOCKLIST_MAX_DECISIONS=250000
#CROWDSEC_BLOCKLIST_MAX_DECISIONS=1000000
# Comma-separated IPs/CIDRs that a sync must always skip (allowlist).
#CROWDSEC_BLOCKLIST_ALLOW=1.2.3.4,10.0.0.0/8
+2 -2
View File
@@ -848,8 +848,8 @@ bash cms security blocklists
Defaults: Spamhaus DROP/EDROP, DShield, CINS, Greensnow, StopForumSpam,
Binary Defense, blocklist.de, Emerging Threats, BruteForceBlocker, abuse.ch
Feodo/SSLBL, Botvrij, IPsum, Firehol ipsets and Tor exit nodes
(25 sources). URLhaus was removed because its `text_online` feed lists URLs,
Feodo/SSLBL, Darklist, Botvrij, IPsum, Firehol ipsets and Tor exit nodes
(26 sources). URLhaus was removed because its `text_online` feed lists URLs,
not IPs; a malformed token in it could otherwise expand into a bogus
huge CIDR. The validator only accepts whole-line bare IPs or proper CIDRs,
enforces sane prefix bounds and drops reserved/private/loopback space, so a
+2
View File
@@ -27,6 +27,8 @@ DEFAULT_SOURCES=(
"https://feodotracker.abuse.ch/downloads/ipblocklist.txt"
"https://sslbl.abuse.ch/blacklist/sslipblacklist.txt"
"https://www.botvrij.eu/data/ioclist.ip-dst.raw"
# Live SSH/spam attackers (last 48h), bare IPs only
"https://www.darklist.de/raw.php"
# Aggregated threat intel
"https://raw.githubusercontent.com/stamparm/ipsum/master/levels/3.txt"
"https://raw.githubusercontent.com/stamparm/ipsum/master/levels/2.txt"
+9
View File
@@ -146,6 +146,15 @@ if [ "$previous_name" != epicnext-cms-app ] && [ "$secondary_name" != epicnext-c
fi
cutover_started=1
# The avatar/badge disk cache lives on the host bind and is written by uid 33
# inside the container. Root-owned directories make every cache write fail
# silently, which turns each avatar into a fresh live render.
for cache_dir in avatars badges; do
if ! install -d -o 33 -g 33 -m 0750 "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null; then
mkdir -p "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null || true
fi
done
chown -R 33:33 "$deploy_dir/storage/imaging" 2>/dev/null || true
# Both legacy Compose and CI containers can exist after earlier failed updates.
# Preserve each before releasing the shared host port; never kill an arbitrary PID.
if [ -n "$secondary_name" ]; then
+7 -2
View File
@@ -14,8 +14,13 @@ export const FIGURE_MAX_PARTS = 24;
const HABBO_PUBLIC_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage";
const PRIMARY_TIMEOUT_MS = 4_000;
const FALLBACK_TIMEOUT_MS = 4_000;
// Effect renders (the client asks for effect=14 by default) are encoded as
// APNG animations and consistently need a little over 4s, so the primary
// budget has to stay well above that or every effected avatar times out. The
// public Habbo renderer is a best-effort safety net only: it is slow to fail,
// so it gets a short budget and the disk cache absorbs the difference.
export const PRIMARY_TIMEOUT_MS = 8_000;
export const FALLBACK_TIMEOUT_MS = 2_000;
export type ImagerSource =
| "primary"
+11
View File
@@ -1,6 +1,7 @@
// @ts-nocheck
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAvatarUrl } from "./imager";
import { FALLBACK_TIMEOUT_MS, PRIMARY_TIMEOUT_MS } from "./imager-upstream";
afterEach(() => {
vi.unstubAllEnvs();
@@ -62,3 +63,13 @@ describe("getAvatarUrl", () => {
expect(url).toContain("effect=14");
});
});
describe("upstream render budgets", () => {
it("keeps enough headroom for APNG effect renders", () => {
expect(PRIMARY_TIMEOUT_MS).toBeGreaterThanOrEqual(8_000);
});
it("does not stack a long fallback wait on a slow primary", () => {
expect(FALLBACK_TIMEOUT_MS).toBeLessThan(PRIMARY_TIMEOUT_MS);
});
});
+21 -1
View File
@@ -1,6 +1,9 @@
// @ts-nocheck
import { describe, expect, it } from "vitest";
import { shouldRedirectAdminRequest } from "./proxy-access";
import {
isCacheableAssetPath,
shouldRedirectAdminRequest,
} from "./proxy-access";
describe("shouldRedirectAdminRequest", () => {
it("redirects anonymous admin requests before rendering", () => {
@@ -17,3 +20,20 @@ describe("shouldRedirectAdminRequest", () => {
expect(shouldRedirectAdminRequest("/news", null)).toBe(false);
});
});
describe("isCacheableAssetPath", () => {
it("keeps rendered avatars and uploaded media cacheable", () => {
expect(isCacheableAssetPath("/api/imaging/avatar")).toBe(true);
expect(isCacheableAssetPath("/api/imaging/badge")).toBe(true);
expect(isCacheableAssetPath("/api/media/1730000000000-abc.png")).toBe(true);
});
it("never lets a document or API route reuse the asset cache policy", () => {
expect(isCacheableAssetPath("/")).toBe(false);
expect(isCacheableAssetPath("/news")).toBe(false);
expect(isCacheableAssetPath("/api/news")).toBe(false);
expect(isCacheableAssetPath("/api/media-library")).toBe(false);
expect(isCacheableAssetPath("/api/imaging")).toBe(false);
expect(isCacheableAssetPath("/admin/media")).toBe(false);
});
});
+6
View File
@@ -10,3 +10,9 @@ export function shouldRedirectAdminRequest(
if (pathname !== "/admin" && !pathname.startsWith("/admin/")) return false;
return token === null;
}
const CACHEABLE_ASSET_PREFIXES = ["/api/imaging/", "/api/media/"];
export function isCacheableAssetPath(pathname: string): boolean {
return CACHEABLE_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
+12 -5
View File
@@ -3,7 +3,10 @@ import { getToken } from "next-auth/jwt";
import { env } from "@/env";
import { buildContentSecurityPolicy, createCspNonce } from "@/lib/csp";
import { ddosReject, enforceDdosRateLimit } from "@/lib/ddos-guard";
import { shouldRedirectAdminRequest } from "@/lib/proxy-access";
import {
isCacheableAssetPath,
shouldRedirectAdminRequest,
} from "@/lib/proxy-access";
const SECURITY_HEADERS: Record<string, string> = {
"X-Content-Type-Options": "nosniff",
@@ -56,10 +59,14 @@ export const proxy = async (req: import("next/server").NextRequest) => {
// always references the current build's chunks. Static assets are
// content-hashed + immutable and can be cached aggressively; a stale HTML
// document would reference chunk URLs that no longer exist after a rebuild.
response.headers.set(
"Cache-Control",
"private, no-cache, no-store, max-age=0, must-revalidate",
);
// Rendered avatars and uploaded media are immutable per key and already
// carry their own long-lived Cache-Control, so they keep it here.
if (!isCacheableAssetPath(pathname)) {
response.headers.set(
"Cache-Control",
"private, no-cache, no-store, max-age=0, must-revalidate",
);
}
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
response.headers.set(key, value);