fix: add content length validation and timeout handling for icon fetching

This commit is contained in:
shuaiplus
2026-05-23 03:17:24 +08:00
parent 8ff60aed24
commit f56d7f01ca
2 changed files with 65 additions and 10 deletions
+3
View File
@@ -44,6 +44,9 @@
// Public read-only request budget per IP per minute. // Public read-only request budget per IP per minute.
// 公开只读接口每 IP 每分钟请求配额。 // 公开只读接口每 IP 每分钟请求配额。
publicReadRequestsPerMinute: 120, publicReadRequestsPerMinute: 120,
// Public website icon proxy budget per IP per minute.
// 公开网站图标代理每 IP 每分钟请求配额。
publicIconRequestsPerMinute: 500,
// Sensitive public/auth request budget per IP per minute. // Sensitive public/auth request budget per IP per minute.
// 敏感公开/认证接口每 IP 每分钟请求配额。 // 敏感公开/认证接口每 IP 每分钟请求配额。
sensitivePublicRequestsPerMinute: 30, sensitivePublicRequestsPerMinute: 30,
+62 -10
View File
@@ -144,6 +144,7 @@ function normalizeIconHost(rawHost: string): string | null {
} }
const ICON_UPSTREAM_TIMEOUT_MS = 2500; const ICON_UPSTREAM_TIMEOUT_MS = 2500;
const ICON_MAX_BUFFER_BYTES = 256 * 1024;
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500; const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783'; const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
@@ -179,6 +180,55 @@ async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
} }
function getPositiveContentLength(headers: Headers): number | null {
const raw = headers.get('Content-Length');
if (!raw) return null;
const value = Number(raw);
return Number.isFinite(value) && value > 0 ? value : null;
}
async function readIconBytes(response: Response, maxBytes: number): Promise<ArrayBuffer | null> {
if (!response.body) return null;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
void reader.cancel().catch(() => undefined);
}, ICON_UPSTREAM_TIMEOUT_MS);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel().catch(() => undefined);
return null;
}
chunks.push(value);
}
} catch {
return null;
} finally {
clearTimeout(timeout);
}
if (timedOut || totalBytes === 0) return null;
const output = new ArrayBuffer(totalBytes);
const bytes = new Uint8Array(output);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
function iconResponse(body: BodyInit | null, contentType: string | null): Response { function iconResponse(body: BodyInit | null, contentType: string | null): Response {
return new Response(body, { return new Response(body, {
status: 200, status: 200,
@@ -218,19 +268,19 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue; if (!contentType.startsWith('image/')) continue;
if (!source.rejectImage) { const contentLength = getPositiveContentLength(resp.headers);
return iconResponse(resp.body, resp.headers.get('Content-Type')); if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
}
const contentLength = Number(resp.headers.get('Content-Length') || ''); const bytes = await readIconBytes(resp, ICON_MAX_BUFFER_BYTES);
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength !== source.rejectImage.byteLength) { if (!bytes) continue;
return iconResponse(resp.body, resp.headers.get('Content-Type')); if (
source.rejectImage &&
bytes.byteLength === source.rejectImage.byteLength &&
(await sha256Hex(bytes)) === source.rejectImage.sha256
) {
continue;
} }
const bytes = await resp.arrayBuffer();
if (bytes.byteLength === 0) continue;
if (bytes.byteLength === source.rejectImage.byteLength && (await sha256Hex(bytes)) === source.rejectImage.sha256) continue;
return iconResponse(bytes, resp.headers.get('Content-Type')); return iconResponse(bytes, resp.headers.get('Content-Type'));
} catch { } catch {
continue; continue;
@@ -286,6 +336,8 @@ export async function handlePublicRoute(
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i); const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch && method === 'GET') { if (iconMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
if (blocked) return blocked;
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default'; const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
return handleWebsiteIcon(iconMatch[1], fallbackMode); return handleWebsiteIcon(iconMatch[1], fallbackMode);
} }