fix(hub): rate limit public code browsing

This commit is contained in:
冰朔 2026-08-06 14:20:06 +08:00
commit f87cd30dab
3 changed files with 108 additions and 1 deletions

View file

@ -14,6 +14,8 @@ Environment=HUB_PORT=8088
Environment=GUANGHU_NODE_ID=JD-FD-PRIMARY
Environment=CODE_CHANNEL_HOST=127.0.0.1
Environment=CODE_CHANNEL_PORT=3340
Environment=CODE_RATE_LIMIT_MAX=600
Environment=CODE_BROWSE_RATE_LIMIT_MAX=120
ExecStart=/usr/bin/node __RELEASE_ROOT__/server-tools/jd-app-hub/server.js
Restart=always
RestartSec=3

View file

@ -2,6 +2,7 @@
const fs = require("node:fs");
const http = require("node:http");
const net = require("node:net");
const path = require("node:path");
const os = require("node:os");
@ -14,6 +15,42 @@ function json(response, status, value) {
response.end(JSON.stringify(value));
}
function clientAddress(request) {
const real = String(request.headers["x-real-ip"] || "").trim();
if (net.isIP(real)) return real;
const forwarded = String(request.headers["x-forwarded-for"] || "")
.split(",")
.map(value => value.trim())
.filter(Boolean);
const last = forwarded.at(-1) || "";
if (net.isIP(last)) return last;
return String(request.socket.remoteAddress || "unknown").slice(0, 64);
}
function createFixedWindowLimiter({ max, windowMs = 60000, now = Date.now }) {
const entries = new Map();
return key => {
const timestamp = now();
let entry = entries.get(key);
if (!entry || timestamp >= entry.resetAt) {
entry = { count: 0, resetAt: timestamp + windowMs };
entries.set(key, entry);
}
entry.count += 1;
if (entries.size > 4096) {
for (const [storedKey, stored] of entries) {
if (timestamp >= stored.resetAt) entries.delete(storedKey);
}
if (entries.size > 4096) entries.delete(entries.keys().next().value);
}
return {
allowed: entry.count <= max,
remaining: Math.max(0, max - entry.count),
retryAfter: Math.max(1, Math.ceil((entry.resetAt - timestamp) / 1000))
};
};
}
function proxyCodeChannel(request, response, options = {}) {
const internalPath = request.url.replace(/^\/code(?=\/|$)/, "") || "/";
const upstream = http.request({
@ -84,13 +121,34 @@ function readCandidateStatus(response, options = {}) {
}
function createApp(options = {}) {
const codeLimiter = createFixedWindowLimiter({
max: Number(options.codeRateLimitMax || process.env.CODE_RATE_LIMIT_MAX || 600),
windowMs: Number(options.codeRateLimitWindowMs || 60000),
now: options.now || Date.now
});
const browseLimiter = createFixedWindowLimiter({
max: Number(options.codeBrowseRateLimitMax || process.env.CODE_BROWSE_RATE_LIMIT_MAX || 120),
windowMs: Number(options.codeRateLimitWindowMs || 60000),
now: options.now || Date.now
});
return http.createServer((request, response) => {
const url = new URL(request.url, "http://localhost");
if (url.pathname === "/code") {
response.writeHead(308, { Location: "/code/", "Cache-Control": "no-store" });
return response.end();
}
if (url.pathname.startsWith("/code/")) return proxyCodeChannel(request, response, options);
if (url.pathname.startsWith("/code/")) {
const address = clientAddress(request);
const general = codeLimiter(address);
const expensive = /\/(?:archive|src\/commit|raw\/commit|blame)(?:\/|$)/.test(url.pathname)
? browseLimiter(address)
: { allowed: true, remaining: general.remaining, retryAfter: general.retryAfter };
if (!general.allowed || !expensive.allowed) {
response.setHeader("Retry-After", String(Math.max(general.retryAfter, expensive.retryAfter)));
return json(response, 429, { ok: false, error: "code_channel_rate_limited" });
}
return proxyCodeChannel(request, response, options);
}
if (request.method !== "GET" && request.method !== "HEAD") return json(response, 405, { ok: false, error: "method_not_allowed" });
if (url.pathname === "/api/code-channel-status") return readCandidateStatus(response, options);
if (url.pathname === "/api/status") {

View file

@ -92,6 +92,53 @@ test("JD hub preserves nested code-channel paths and queries without duplicating
assert.deepEqual(await root.json(), { path: "/" });
});
test("JD hub rate limits code-channel traffic by verified proxy client address", async (t) => {
const upstream = http.createServer((_request, response) => {
response.writeHead(200);
response.end("ok");
});
upstream.listen(0, "127.0.0.1");
t.after(() => upstream.close());
await new Promise((resolve) => upstream.once("listening", resolve));
const server = createApp({
codeChannelPort: upstream.address().port,
codeRateLimitMax: 2
}).listen(0, "127.0.0.1");
t.after(() => server.close());
await new Promise((resolve) => server.once("listening", resolve));
const endpoint = `http://127.0.0.1:${server.address().port}/code/api/v1/version`;
const headers = { "x-real-ip": "203.0.113.10" };
assert.equal((await fetch(endpoint, { headers })).status, 200);
assert.equal((await fetch(endpoint, { headers })).status, 200);
const limited = await fetch(endpoint, { headers });
assert.equal(limited.status, 429);
assert.deepEqual(await limited.json(), { ok: false, error: "code_channel_rate_limited" });
assert.equal((await fetch(endpoint, { headers: { "x-real-ip": "203.0.113.11" } })).status, 200);
});
test("JD hub gives historical source browsing a lower independent ceiling", async (t) => {
const upstream = http.createServer((_request, response) => {
response.writeHead(200);
response.end("ok");
});
upstream.listen(0, "127.0.0.1");
t.after(() => upstream.close());
await new Promise((resolve) => upstream.once("listening", resolve));
const server = createApp({
codeChannelPort: upstream.address().port,
codeRateLimitMax: 10,
codeBrowseRateLimitMax: 1
}).listen(0, "127.0.0.1");
t.after(() => server.close());
await new Promise((resolve) => server.once("listening", resolve));
const endpoint = `http://127.0.0.1:${server.address().port}/code/bingshuo/repo/src/commit/deadbeef/file`;
const headers = { "x-forwarded-for": "198.51.100.2, 203.0.113.20" };
assert.equal((await fetch(endpoint, { headers })).status, 200);
assert.equal((await fetch(endpoint, { headers })).status, 429);
});
test("JD hub exposes only a categorized candidate failure", async (t) => {
const upstream = http.createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });