mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
fix: add content length validation and timeout handling for icon fetching
This commit is contained in:
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user