From 673f63ebba4ed3532183ed7177f4d3c37bc82d4f Mon Sep 17 00:00:00 2001 From: DysektAI <33468668+DysektAI@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:14:56 -0400 Subject: [PATCH 01/17] fix(web): share report limits and surface submission errors --- README.md | 62 ++++++++++ src/web/report-app.js | 126 +++++++++++++++++++++ src/web/report-policy.js | 92 +++++++++++++++ test/report-app.test.js | 207 +++++++++++++++++++++++++++++++++ test/report-form.test.js | 135 ++++++++++++++++++++++ test/report-policy.test.js | 123 ++++++++++++++++++++ web/data/index.html | 38 ++----- web/issue/index.html | 39 ++----- web/report-form.js | 82 ++++++++++++++ webserver.js | 226 ++----------------------------------- 10 files changed, 852 insertions(+), 278 deletions(-) create mode 100644 src/web/report-app.js create mode 100644 src/web/report-policy.js create mode 100644 test/report-app.test.js create mode 100644 test/report-form.test.js create mode 100644 test/report-policy.test.js create mode 100644 web/report-form.js diff --git a/README.md b/README.md index 39301f6..2f9048f 100755 --- a/README.md +++ b/README.md @@ -22,6 +22,68 @@ TrackerBot runs two services side by side: | **Discord bot** | `bot.js` | Slash commands, reaction roles, welcome automation, and ticket creation. | | **Bug intake web server** | `webserver.js` | Serves the public issue forms and forwards submissions to GitHub. | +### Reporting limits and errors + +`POST /data` and `POST /issue` share one reporting policy for each client IP +(with the existing IPv6 `/56` grouping). The allowance is **20 delivered reports +in a rolling 12 hours**, with **at least 10 minutes between deliveries**. A +delivery exactly 12 hours old leaves the allowance; a new report is admitted +exactly at the 10-minute boundary. IPv4-mapped addresses use the same key as IPv4, +including dotted and hexadecimal IPv6 representations. Switching forms does not +reset either limit. Users sharing an IP or IPv6 prefix share these limits. + +The server reserves a slot before awaiting GitHub, so concurrent requests cannot +both pass. Confirmed GitHub issue creation (`201`) consumes the allowance and +starts the cooldown at delivery completion. Validation, origin/parser errors, +honeypot submissions and proven delivery rejections do not count as delivered +reports. A separate shared **five-attempt rolling minute** budget runs before +body parsing and includes rejected/invalid requests. Denials return the longest +active attempt/window/cooldown wait rather than an earlier partial reset. + +Timeouts, connection resets, unexpected provider responses and GitHub `5xx` +responses are **unconfirmed delivery**, not proof that no issue was created. +They conservatively reserve allowance and cooldown. A hash of the same report is +held against automatic repetition for 12 hours, bounded by the same retention +window; this is not a durable idempotency guarantee. Check the published issue +list or contact support before repeating it. Different reports may be sent after +the ordinary cooldown if allowance remains. No automatic GitHub retry occurs. + +Both forms preserve their entered fields on failure, disable double submission, +and display safe typed server errors. Unknown network/proxy/success responses +keep the draft and stop automatic repetition in that page. The displayed wait is +advisory; every new request is checked by the server. Length limits are visible +and enforced without silently truncating evidence: title 200, Discord handle 64, +category 64, reference 500, details 8,000 characters; request bodies are capped at +32 KiB. Successful submission resets the form. Drafts remain in the open page, +not persistent browser storage; copy them before reloading. + +Errors use `{ error: { code, message }, retryAfterSeconds?, resetAt? }`. +Admission errors use HTTP `429` (`report_cooldown`, `report_window_limit`, +`attempt_limit`, `report_in_progress`), capacity uses `503`, and uncertain repeat +delivery uses `409`. Wait errors include numeric `Retry-After` seconds and an ISO +`resetAt`. Validation/parser/origin errors use `400`/`413`/`403`. Proven provider +rejections/connectivity failures use `502`/`503`; unconfirmed delivery uses `504`. +Provider throttling supplies safe retry guidance. Provider bodies, tokens, IPs +and report descriptions are never included in error output or delivery logs. + +**Rollout limitation: these controls are process-local memory, not durable or +shared across workers/hosts.** A restart clears allowances, cooldowns and +uncertain-report holds. There is no existing persistent limiter store or hosting +manifest in this repository. State is capped at 10,000 clients and stale entries +are removed; at capacity, new clients receive a safe busy response instead of +evicting active limits. Strict enforcement across restarts or multiple processes +needs an operator-approved persistent/shared store after hosting is established. +This change adds no infrastructure or production settings and is not evidence +that the deployed topology is ready for strict enforcement. + +`trust proxy = 1` remains unchanged. Before rollout, operators must verify that +the origin is reachable only through the intended trusted proxy and that it +overwrites client forwarding headers, and confirm the actual process topology. +Otherwise clients may spoof the limiter key or unrelated users may share one. +Do not fix that by trusting arbitrary forwarded headers. The deployed form origin +must also match the existing `ALLOWED_ORIGINS` setting. Credential, proxy-trust and +production configuration changes require separate approval. + Requires **Node.js 18.17+**. ## Features diff --git a/src/web/report-app.js b/src/web/report-app.js new file mode 100644 index 0000000..30ab0ab --- /dev/null +++ b/src/web/report-app.js @@ -0,0 +1,126 @@ +import express from "express"; +import { ipKeyGenerator } from "express-rate-limit"; +import axios from "axios"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { createReportPolicy } from "./report-policy.js"; + +const webDirectory = fileURLToPath(new URL("../../web", import.meta.url)); +export const FIELD_LIMITS = { title: 200, discord: 64, category: 64, reference: 500, description: 8000 }; + +function clientKey(ip) { + // Normalize hexadecimal mapped IPv4 before the existing IPv6 /56 grouping. + const mapped = ipKeyGenerator(ip, 128).match(/^::ffff:([\da-f]+):([\da-f]+)\/128$/i); + if (!mapped) return ipKeyGenerator(ip); + const high = Number.parseInt(mapped[1], 16); + const low = Number.parseInt(mapped[2], 16); + return [high >> 8, high & 255, low >> 8, low & 255].join("."); +} + +function sendError(res, status, code, message, details = {}) { + if (details.retryAfterSeconds) res.set("Retry-After", String(details.retryAfterSeconds)); + return res.status(status).json({ error: { code, message }, ...details }); +} + +function sendAdmissionError(res, admission) { + const status = admission.code === "delivery_uncertain" ? 409 : admission.code === "report_capacity" ? 503 : 429; + return sendError(res, status, admission.code, admission.message, { retryAfterSeconds: admission.retryAfterSeconds, resetAt: admission.resetAt }); +} + +function validateFields(body, kind) { + const required = kind === "data" ? ["title", "discord", "category", "description"] : ["title", "discord", "description"]; + const fields = {}; + for (const [name, max] of Object.entries(FIELD_LIMITS)) { + const value = body?.[name]; + if (value != null && typeof value !== "string") return { error: `${name} must be text.` }; + fields[name] = (value ?? "").trim(); + if (fields[name].length > max) return { error: `${name} must be ${max} characters or fewer.` }; + if (required.includes(name) && !fields[name]) return { error: `Please provide ${name}.` }; + } + return { fields }; +} + +function buildIssue(kind, fields) { + if (kind === "issue") { + return { title: fields.title, body: `**Discord handle:** ${fields.discord}\n\n**Description:**\n${fields.description}` }; + } + const lines = [`**Discord handle:** ${fields.discord}`, `**Category:** ${fields.category}`]; + if (fields.reference) lines.push(`**Reference:** ${fields.reference}`); + lines.push("", "**Details:**", fields.description); + return { title: `[${fields.category}] ${fields.title}`, body: lines.join("\n") }; +} + +function deliveryError(err) { + const status = err?.response?.status; + const headers = err?.response?.headers ?? {}; + if (status === 429 || (status === 403 && (headers["retry-after"] || headers["x-ratelimit-remaining"] === "0"))) { + const seconds = Number(headers["retry-after"]); + return { status: 503, code: "provider_rate_limit", message: "GitHub is temporarily limiting reports. Your report was not created. Keep your draft and wait before retrying.", retryAfterSeconds: Number.isFinite(seconds) && seconds > 0 ? Math.min(3600, Math.ceil(seconds)) : 60, failed: true }; + } + if ([400, 401, 403, 404, 405, 410, 422].includes(status)) { + return { status: 502, code: "delivery_rejected", message: "The report service could not deliver this report. It was not created. Keep your draft and contact support.", failed: true }; + } + if (["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"].includes(err?.code)) { + return { status: 503, code: "delivery_unavailable", message: "The report service could not connect to GitHub. Your report was not created. Keep your draft and wait one minute before retrying.", retryAfterSeconds: 60, failed: true }; + } + return { status: 504, code: "delivery_uncertain", message: "Delivery could not be confirmed. Check the issue list or contact support before repeating this report; it may already have been created.", failed: false }; +} + +export function createReportApp({ env = process.env, githubPost = axios.post, now = Date.now, logger = console } = {}) { + const app = express(); + // Preserve existing trust. Operators must verify a single trusted proxy and + // no direct origin access; do not trust a client-supplied IP header here. + app.set("trust proxy", 1); + const policy = createReportPolicy({ now }); + const origins = new Set((env.ALLOWED_ORIGINS || "").split(",").map(origin => origin.trim().replace(/\/+$/, "")).filter(Boolean)); + const githubHeaders = { Authorization: `token ${env.GITHUB_TOKEN}`, Accept: "application/vnd.github+json", "User-Agent": "tarkovtracker-webserver" }; + // This separate budget counts attempts, including invalid/rejected requests. + // It is shared across routes and runs before decoding the body. + app.post(["/data", "/issue"], (req, res, next) => { + const admission = policy.attempt(clientKey(req.ip)); + if (!admission.ok) return sendAdmissionError(res, admission); + next(); + }); + app.use(express.urlencoded({ extended: true, limit: "32kb" })); + app.use(express.json({ limit: "32kb" })); + app.use(express.static(path.join(webDirectory))); + app.get("/health", (req, res) => res.json({ status: "ok" })); + + async function submit(req, res, kind) { + const origin = req.headers.origin || req.headers.referer || ""; + if (origins.size && ![...origins].some(allowed => origin === allowed || origin.startsWith(`${allowed}/`))) { + return sendError(res, 403, "forbidden_origin", "This report form is not allowed to submit from this address. Keep your draft and contact support."); + } + if ((req.body?.company ?? "").toString().trim()) return res.json({ ok: true }); + const validated = validateFields(req.body, kind); + if (validated.error) return sendError(res, 400, "invalid_report", validated.error); + const issue = buildIssue(kind, validated.fields); + const fingerprint = createHash("sha256").update(JSON.stringify([kind, issue])).digest("hex"); + const admission = policy.reserve(clientKey(req.ip), fingerprint); + if (!admission.ok) return sendAdmissionError(res, admission); + try { + const repo = kind === "data" ? env.REPO_DATA_REPORT : env.REPO_DEV; + const response = await githubPost(`https://api.github.com/repos/${repo}/issues`, issue, { headers: githubHeaders, timeout: 10_000 }); + if (response?.status !== 201) throw new Error("Unexpected issue creation status"); + admission.settle("accepted"); + return res.json({ ok: true }); + } catch (err) { + const failure = deliveryError(err); + admission.settle(failure.failed ? "failed" : "uncertain"); + logger.error("Report delivery failed", { route: kind, status: Number.isInteger(err?.response?.status) ? err.response.status : "no-response", code: failure.code }); + const details = failure.retryAfterSeconds ? { retryAfterSeconds: failure.retryAfterSeconds } : {}; + return sendError(res, failure.status, failure.code, failure.message, details); + } + } + app.post("/data", (req, res) => submit(req, res, "data")); + app.post("/issue", (req, res) => submit(req, res, "issue")); + app.use((err, req, res, next) => { + if (res.headersSent) return next(err); + if (err.type === "entity.too.large") return sendError(res, 413, "report_too_large", "This report exceeds the 32 KiB request limit. Shorten the details while keeping the key evidence."); + if (err.type === "entity.parse.failed") return sendError(res, 400, "invalid_request", "The report request could not be read. Keep your draft and reload the form before retrying."); + logger.error("Report request failed", { type: "internal_error" }); + return sendError(res, 500, "internal_error", "The report service encountered an error. Keep your draft and contact support before repeating a submitted report."); + }); + return app; +} diff --git a/src/web/report-policy.js b/src/web/report-policy.js new file mode 100644 index 0000000..57b5ff4 --- /dev/null +++ b/src/web/report-policy.js @@ -0,0 +1,92 @@ +export const REPORT_WINDOW_MS = 12 * 60 * 60 * 1000; +export const REPORT_INTERVAL_MS = 10 * 60 * 1000; +export const REPORT_LIMIT = 20; +const ATTEMPT_WINDOW_MS = 60_000; +const ATTEMPT_LIMIT = 5; + +function denied(code, message, resetAt, now) { + return { + ok: false, code, message, + retryAfterSeconds: Math.max(1, Math.ceil((resetAt - now) / 1000)), + resetAt: new Date(resetAt).toISOString() + }; +} + +// One Node process only. Reservation and settlement are synchronous, including +// reservation before asynchronous delivery; there is no persistent store. +export function createReportPolicy({ now = Date.now, maxClients = 10_000 } = {}) { + const clients = new Map(); + let lastSweep = -Infinity; + function prune(state, time) { + state.events = state.events.filter(event => event.time > time - REPORT_WINDOW_MS); + state.attempts = state.attempts.filter(attempt => attempt > time - ATTEMPT_WINDOW_MS); + } + function sweep(time) { + if (time - lastSweep < 60_000) return; + lastSweep = time; + for (const [key, state] of clients) { + prune(state, time); + if (!state.pending && !state.events.length && !state.attempts.length) clients.delete(key); + } + } + function stateFor(key, time) { + sweep(time); + let state = clients.get(key); + if (!state) { + if (clients.size >= maxClients) { + return denied("report_capacity", "The report service is busy. Keep your draft and wait one minute.", time + 60_000, time); + } + state = { events: [], attempts: [], pending: null }; + clients.set(key, state); + } + prune(state, time); + return state; + } + function blocked(state, time, fingerprint) { + if (state.pending) { + return denied("report_in_progress", "A report is already being submitted from this connection. Wait for its result.", time + 10_000, time); + } + const previous = state.events.at(-1); + const cooldownEnd = previous ? previous.time + REPORT_INTERVAL_MS : time; + const windowEnd = state.events.length >= REPORT_LIMIT ? state.events[0].time + REPORT_WINDOW_MS : time; + const uncertain = fingerprint && state.events.find(event => event.outcome === "uncertain" && event.fingerprint === fingerprint); + if (uncertain) { + return denied("delivery_uncertain", "Delivery of this report could not be confirmed. Check the issue list or contact support before repeating it; automatic retries could create duplicates.", Math.max(uncertain.time + REPORT_WINDOW_MS, cooldownEnd, windowEnd), time); + } + if (state.events.length >= REPORT_LIMIT) { + const reset = Math.max(windowEnd, cooldownEnd); + return denied("report_window_limit", "The shared limit of 20 reports in a rolling 12-hour window has been reached. The displayed wait also includes any remaining 10-minute cooldown.", reset, time); + } + if (previous && time < previous.time + REPORT_INTERVAL_MS) { + return denied("report_cooldown", "Wait 10 minutes between reports. Both report forms share this cooldown.", previous.time + REPORT_INTERVAL_MS, time); + } + return null; + } + function attempt(key) { + const time = now(); + const state = stateFor(key, time); + if (state.ok === false) return state; + const reportingBlock = blocked(state, time); + if (state.attempts.length >= ATTEMPT_LIMIT) { + const attemptBlock = denied("attempt_limit", "Too many submission attempts. Keep your draft and wait one minute.", state.attempts[0] + ATTEMPT_WINDOW_MS, time); + return reportingBlock && Date.parse(reportingBlock.resetAt) > Date.parse(attemptBlock.resetAt) ? reportingBlock : attemptBlock; + } + state.attempts.push(time); + return reportingBlock ?? { ok: true }; + } + function reserve(key, fingerprint) { + const time = now(); + const state = stateFor(key, time); + if (state.ok === false) return state; + const rejection = blocked(state, time, fingerprint); + if (rejection) return rejection; + const reservation = { time, fingerprint }; + state.pending = reservation; + return { ok: true, settle(outcome) { + if (state.pending !== reservation) return; + state.pending = null; + if (outcome !== "failed") state.events.push({ ...reservation, time: now(), outcome }); + } }; + } + return { attempt, reserve }; +} diff --git a/test/report-app.test.js b/test/report-app.test.js new file mode 100644 index 0000000..a1d7cf3 --- /dev/null +++ b/test/report-app.test.js @@ -0,0 +1,207 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createReportApp } from "../src/web/report-app.js"; + +const fields = { title: "Test report", discord: "Test reporter", category: "Other", description: "Test observation", reference: "" }; +async function fixture(t, githubPost = async () => ({ status: 201 })) { + let time = 0; + const calls = []; + const logs = []; + const app = createReportApp({ + env: { GITHUB_TOKEN: "test-only-key", REPO_DEV: "example/dev", REPO_DATA_REPORT: "example/data", ALLOWED_ORIGINS: "https://example.test" }, + now: () => time, + logger: { error(...args) { logs.push(args); } }, + githubPost(...args) { calls.push(args); return githubPost(...args); } + }); + const server = app.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + return { + app, calls, logs, setTime(value) { time = value; }, + async post(route = "data", body = fields, extra = {}) { + const response = await fetch(`${base}/${route}`, { + method: "POST", headers: { "Content-Type": "application/json", Origin: "https://example.test", ...extra }, + body: typeof body === "string" ? body : JSON.stringify(body) + }); + return { status: response.status, headers: response.headers, body: await response.json() }; + } + }; +} + +test("both routes share delivered-report policy and keep destination routing", async t => { + const api = await fixture(t); + assert.equal((await api.post()).status, 200); + const blocked = await api.post("issue"); + assert.equal(blocked.body.error.code, "report_cooldown"); + assert.equal(blocked.headers.get("Retry-After"), "600"); + api.setTime(600000); + assert.equal((await api.post("issue")).status, 200); + assert.equal(api.calls[0][0], "https://api.github.com/repos/example/data/issues"); + assert.equal(api.calls[1][0], "https://api.github.com/repos/example/dev/issues"); +}); + +test("HTTP 20th/21st and rolling boundary across mixed routes", async t => { + const api = await fixture(t); + for (let index = 0; index < 20; index++) { + api.setTime(index * 600000); + assert.equal((await api.post(index % 2 ? "issue" : "data")).status, 200); + } + api.setTime(12000000); + const rejected = await api.post(); + assert.equal(rejected.status, 429); + assert.equal(rejected.body.error.code, "report_window_limit"); + assert.equal(rejected.headers.get("Retry-After"), "31200"); + assert.equal(api.calls.length, 20); + api.setTime(43200000); + assert.equal((await api.post()).status, 200); +}); + +test("all three budgets overlap: header and reset use the longest wait", async t => { + const api = await fixture(t); + for (let index = 0; index < 19; index++) { + api.setTime(index * 600000); + await api.post(); + } + api.setTime(42900000); + await api.post(); + for (let index = 0; index < 6; index++) { + const result = await api.post("issue"); + assert.equal(result.body.error.code, "report_window_limit"); + assert.equal(result.headers.get("Retry-After"), "600"); + assert.equal(Date.parse(result.body.resetAt), 43500000); + } +}); + +test("a pending GitHub call admits only one concurrent report", async t => { + let release; + let started; + const waiting = new Promise(resolve => { started = resolve; }); + const api = await fixture(t, () => { started(); return new Promise(resolve => { release = resolve; }); }); + const first = api.post(); + await waiting; + const second = await api.post("issue"); + assert.equal(second.body.error.code, "report_in_progress"); + assert.equal(api.calls.length, 1); + release({ status: 201 }); + assert.equal((await first).status, 200); +}); + +test("validation, parser and honeypot rejections consume no report allowance", async t => { + const api = await fixture(t); + assert.equal((await api.post("data", { ...fields, title: "" })).body.error.code, "invalid_report"); + assert.equal((await api.post("issue", { ...fields, title: { unsafe: true } })).status, 400); + assert.equal((await api.post("issue", "{invalid")).body.error.code, "invalid_request"); + assert.equal((await api.post("data", { ...fields, company: "bot" })).status, 200); + assert.equal((await api.post()).status, 200); + assert.equal(api.calls.length, 1); +}); + +test("invalid attempts are bounded across both routes", async t => { + const api = await fixture(t); + for (let index = 0; index < 5; index++) { + assert.equal((await api.post(index % 2 ? "issue" : "data", {})).status, 400); + } + const blocked = await api.post(); + assert.equal(blocked.body.error.code, "attempt_limit"); + assert.equal(blocked.headers.get("Retry-After"), "60"); + api.setTime(60000); + assert.equal((await api.post()).status, 200); +}); + +test("length errors replace truncation and oversized parser errors are safe", async t => { + const api = await fixture(t); + const long = await api.post("data", { ...fields, description: "x".repeat(8001) }); + assert.equal(long.body.error.code, "invalid_report"); + assert.match(long.body.error.message, /8000/); + assert.equal((await api.post("data", { ...fields, description: "x".repeat(33000) })).body.error.code, "report_too_large"); + assert.equal((await api.post("data", { ...fields, description: "x".repeat(8000) })).status, 200); +}); + +test("origin checks and single-hop trust remain unchanged", async t => { + const api = await fixture(t); + assert.equal(api.app.get("trust proxy"), 1); + assert.equal((await api.post("data", fields, { Origin: "https://example.test.evil.invalid" })).body.error.code, "forbidden_origin"); + assert.equal(api.calls.length, 0); + assert.equal((await api.post()).status, 200); +}); + +test("IP normalization covers mapped IPv4, IPv6 notation and existing /56 grouping", async t => { + const api = await fixture(t); + assert.equal((await api.post("data", fields, { "X-Forwarded-For": "192.0.2.1" })).status, 200); + assert.equal((await api.post("issue", fields, { "X-Forwarded-For": "::ffff:c000:201" })).body.error.code, "report_cooldown"); + assert.equal((await api.post("data", fields, { "X-Forwarded-For": "2001:db8:abcd:1200::1" })).status, 200); + assert.equal((await api.post("issue", fields, { "X-Forwarded-For": "2001:0db8:abcd:12ff:0:0:0:2" })).body.error.code, "report_cooldown"); +}); + +for (const status of [401, 403, 404, 422, 429]) { + test(`confirmed GitHub ${status} rejection releases report allowance`, async t => { + let reject = true; + const api = await fixture(t, async () => { + if (reject) throw { response: { status, headers: { "retry-after": "120" }, data: "PRIVATE_PROVIDER_BODY" }, message: "PRIVATE_PROVIDER_MESSAGE" }; + return { status: 201 }; + }); + const failure = await api.post(); + assert.notEqual(failure.body.error.code, "delivery_uncertain"); + if (status === 429 || status === 403) assert.equal(failure.headers.get("Retry-After"), "120"); + reject = false; + assert.equal((await api.post()).status, 200); + assert.doesNotMatch(JSON.stringify([failure, api.logs]), /PRIVATE_PROVIDER/); + }); +} + +for (const failure of [{ code: "ECONNABORTED" }, { code: "ECONNRESET" }, ...[500, 502, 503, 504].map(status => ({ response: { status } }))]) { + test(`ambiguous ${failure.code ?? failure.response.status} cannot automatically repeat a report`, async t => { + const api = await fixture(t, async () => { throw { ...failure, message: "PRIVATE_DESCRIPTION", config: { headers: { Authorization: "PRIVATE_SECRET" } } }; }); + const result = await api.post(); + assert.equal(result.body.error.code, "delivery_uncertain"); + api.setTime(600000); + assert.equal((await api.post()).status, 409); + assert.equal(api.calls.length, 1); + assert.doesNotMatch(JSON.stringify([result, api.logs]), /PRIVATE_DESCRIPTION|PRIVATE_SECRET|Test observation|127\.0\.0\.1/); + }); +} + +test("proven connection failure releases quota and supplies safe retry guidance", async t => { + let fail = true; + const api = await fixture(t, async () => { + if (fail) throw { code: "ENOTFOUND" }; + return { status: 201 }; + }); + assert.equal((await api.post()).headers.get("Retry-After"), "60"); + fail = false; + assert.equal((await api.post()).status, 200); +}); + +test("unexpected successful provider status is not acknowledged as issue creation", async t => { + const api = await fixture(t, async () => ({ status: 200 })); + assert.equal((await api.post()).body.error.code, "delivery_uncertain"); +}); + +test("null provider errors release pending state into uncertainty, not a permanent lock", async t => { + const api = await fixture(t, async () => { throw null; }); + assert.equal((await api.post()).body.error.code, "delivery_uncertain"); + api.setTime(600000); + assert.equal((await api.post()).status, 409); +}); + +test("case and trailing slash aliases preserve their matched route destination", async t => { + const api = await fixture(t); + assert.equal((await api.post("DATA/")).status, 200); + assert.equal(api.calls[0][0], "https://api.github.com/repos/example/data/issues"); + api.setTime(600000); + assert.equal((await api.post("ISSUE/")).status, 200); + assert.equal(api.calls[1][0], "https://api.github.com/repos/example/dev/issues"); +}); + +test("uncertain dedupe also covers equivalent route spellings", async t => { + const api = await fixture(t, async () => { throw { code: "ECONNABORTED" }; }); + await api.post(); + api.setTime(600000); + assert.equal((await api.post("DATA/")).status, 409); + assert.equal(api.calls.length, 1); +}); diff --git a/test/report-form.test.js b/test/report-form.test.js new file mode 100644 index 0000000..287e5fd --- /dev/null +++ b/test/report-form.test.js @@ -0,0 +1,135 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import vm from "node:vm"; + +async function loadForm(kind, fetch, search = "") { + const html = await readFile(new URL(`../web/${kind}/index.html`, import.meta.url), "utf8"); + const button = { disabled: false }; + const status = { textContent: "", className: "" }; + const form = { + resetCount: 0, + addEventListener(name, callback) { this.submit = callback; }, + reset() { this.resetCount++; }, + querySelector() { return button; }, + querySelectorAll() { return Array.from(fields.values()).filter(field => field.maxLength); }, + dataset: { endpoint: `/${kind}` } + }; + const fields = new Map(); + let time = 0; + class Clock extends Date { static now() { return time; } } + const context = vm.createContext({ + document: { getElementById(id) { + if (id.endsWith("Form")) return form; + if (id.endsWith("Status")) return status; + if (!fields.has(id)) fields.set(id, { value: "", options: [] }); + return fields.get(id); + } }, + window: { location: { search } }, + URLSearchParams, Date: Clock, console: { error() {} }, + FormData: class { entries() { return [["title", "Retained draft"]]; } }, + fetch + }); + for (const script of html.matchAll(/]*>([\s\S]*?)<\/script>/g)) { + const code = script[1] + ? await readFile(new URL(`../web${script[1]}`, import.meta.url), "utf8") + : script[2]; + vm.runInContext(code, context); + } + return { form, status, button, fields, html, setTime(value) { time = value; }, submit: () => form.submit({ preventDefault() {} }) }; +} + +for (const kind of ["data", "issue"]) { + test(`${kind}: displays the server's cooldown and retains fields`, async () => { + const ui = await loadForm(kind, async () => ({ + ok: false, status: 429, + headers: { get() { return "600"; } }, + async json() { return { error: { code: "report_cooldown", message: "Wait 10 minutes before submitting another report." }, retryAfterSeconds: 600 }; } + })); + await ui.submit(); + assert.match(ui.status.textContent, /10 minutes/); + assert.equal(ui.form.resetCount, 0); + }); + test(`${kind}: prevents double submission while delivery is pending`, async () => { + let release; + let calls = 0; + const ui = await loadForm(kind, () => { calls++; return new Promise(resolve => { release = resolve; }); }); + const first = ui.submit(); + assert.equal(ui.button.disabled, true); + await ui.submit(); + assert.equal(calls, 1); + release({ ok: true, async json() { return { ok: true }; } }); + await first; + assert.equal(ui.form.resetCount, 1); + }); + + for (const code of ["invalid_report", "invalid_request", "report_too_large", "forbidden_origin", "provider_rate_limit", "delivery_rejected", "delivery_unavailable", "report_window_limit"]) { + test(`${kind}: preserves draft and presents typed ${code} error`, async () => { + const message = `Safe error for ${code}`; + const ui = await loadForm(kind, async () => ({ ok: false, status: 400, headers: { get() { return null; } }, async json() { return { error: { code, message } }; } })); + await ui.submit(); + assert.match(ui.status.textContent, new RegExp(message)); + assert.equal(ui.form.resetCount, 0); + assert.equal(ui.button.disabled, false); + }); + } + + for (const type of ["network", "non-json", "malformed-success", "delivery_uncertain"]) { + test(`${kind}: ${type} keeps draft and blocks automatic repeat`, async () => { + let calls = 0; + const ui = await loadForm(kind, async () => { + calls++; + if (type === "network") throw new Error("PRIVATE_CONNECTION_DETAIL"); + return { ok: type !== "delivery_uncertain", status: 504, headers: { get() { return null; } }, async json() { + if (type === "non-json") throw new Error("PRIVATE_PROXY_HTML"); + return type === "delivery_uncertain" ? { error: { code: type, message: "Delivery could not be confirmed. Check before repeating." } } : {}; + } }; + }); + await ui.submit(); + ui.setTime(43200000); + await ui.submit(); + assert.equal(calls, 1); + assert.equal(ui.form.resetCount, 0); + assert.equal(ui.button.disabled, true); + assert.match(ui.status.textContent, /could not be confirmed/); + assert.doesNotMatch(ui.status.textContent, /PRIVATE_/); + }); + } + + test(`${kind}: expired client wait still requires fresh server authorization`, async () => { + let calls = 0; + const ui = await loadForm(kind, async () => { + calls++; + return { ok: false, status: 429, headers: { get() { return "600"; } }, async json() { return { error: { code: "report_cooldown", message: "Wait 10 minutes." }, retryAfterSeconds: 600 }; } }; + }); + await ui.submit(); + ui.setTime(599999); + await ui.submit(); + assert.equal(calls, 1); + ui.setTime(600000); + await ui.submit(); + assert.equal(calls, 2); + assert.equal(ui.form.resetCount, 0); + assert.match(ui.status.textContent, /server will check the limits again/); + }); + + test(`${kind}: length validation catches programmatically filled overlong values`, async () => { + let calls = 0; + const ui = await loadForm(kind, async () => { calls++; }); + ui.fields.set("description", { name: "description", value: "x".repeat(8001), maxLength: 8000 }); + await ui.submit(); + assert.equal(calls, 0); + assert.equal(ui.form.resetCount, 0); + assert.match(ui.status.textContent, /8000/); + assert.match(ui.html, /maxlength="8000"/); + assert.match(ui.html, /aria-live="polite"/); + }); +} + +test("data prefills remain intact, including reference and leading newlines", async () => { + const search = new URLSearchParams({ title: "Prefilled task", reference: "https://example.test/proof", description: "\n\nObserved details" }).toString(); + const ui = await loadForm("data", async () => {}, `?${search}`); + assert.equal(ui.fields.get("title").value, "Prefilled task"); + assert.equal(ui.fields.get("reference").value, "https://example.test/proof"); + assert.equal(ui.fields.get("description").value, "\n\nObserved details"); +}); diff --git a/test/report-policy.test.js b/test/report-policy.test.js new file mode 100644 index 0000000..8db7f98 --- /dev/null +++ b/test/report-policy.test.js @@ -0,0 +1,123 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createReportPolicy } from "../src/web/report-policy.js"; + +function fixture(options = {}) { + let time = 0; + const policy = createReportPolicy({ now: () => time, ...options }); + return { policy, setTime(value) { time = value; } }; +} +function accept(policy, fingerprint = "report") { + const admission = policy.reserve("client", fingerprint); + assert.equal(admission.ok, true); + admission.settle("accepted"); +} + +test("10-minute boundary is exclusive before, inclusive at the boundary", () => { + const { policy, setTime } = fixture(); + accept(policy); + setTime(599999); + const denied = policy.reserve("client", "next"); + assert.equal(denied.code, "report_cooldown"); + assert.equal(denied.retryAfterSeconds, 1); + setTime(600000); + accept(policy, "next"); +}); + +test("20th accepted; 21st blocked until the oldest rolling event expires", () => { + const { policy, setTime } = fixture(); + for (let index = 0; index < 20; index++) { + setTime(index * 600000); + accept(policy, `report-${index}`); + } + setTime(12000000); + const rejected = policy.reserve("client", "21st"); + assert.equal(rejected.code, "report_window_limit"); + assert.equal(rejected.retryAfterSeconds, 31200); + setTime(43199999); + assert.equal(policy.reserve("client", "21st").retryAfterSeconds, 1); + setTime(43200000); + accept(policy, "21st"); + assert.equal(policy.reserve("client", "22nd").code, "report_window_limit"); +}); + +test("overlapping window and cooldown returns the longest wait", () => { + const { policy, setTime } = fixture(); + for (let index = 0; index < 19; index++) { + setTime(index * 600000); + accept(policy, `report-${index}`); + } + setTime(42900000); // 11h55: oldest expires in 5m; latest cooldown in 10m. + accept(policy, "20th"); + const denied = policy.reserve("client", "21st"); + assert.equal(denied.code, "report_window_limit"); + assert.equal(denied.retryAfterSeconds, 600); + setTime(43200000); + assert.equal(policy.reserve("client", "21st").retryAfterSeconds, 300); + setTime(43500000); + accept(policy, "21st"); +}); + +test("attempt allowance has an exact rolling minute boundary", () => { + const { policy, setTime } = fixture(); + for (let index = 0; index < 5; index++) assert.equal(policy.attempt("client").ok, true); + assert.equal(policy.attempt("client").code, "attempt_limit"); + setTime(59999); + assert.equal(policy.attempt("client").retryAfterSeconds, 1); + setTime(60000); + assert.equal(policy.attempt("client").ok, true); +}); + +test("attempt and cooldown overlap chooses either longest constraint", () => { + const { policy, setTime } = fixture(); + accept(policy); + for (let index = 0; index < 6; index++) assert.equal(policy.attempt("client").retryAfterSeconds, 600); + setTime(599000); + for (let index = 0; index < 5; index++) assert.equal(policy.attempt("client").code, "report_cooldown"); + const attempts = policy.attempt("client"); + assert.equal(attempts.code, "attempt_limit"); + assert.equal(attempts.retryAfterSeconds, 60); +}); + +test("pending reservation blocks concurrent calls; failure releases allowance", () => { + const { policy } = fixture(); + const first = policy.reserve("client", "first"); + assert.equal(policy.reserve("client", "second").code, "report_in_progress"); + first.settle("failed"); + accept(policy, "second"); + first.settle("accepted"); // stale/double settlement cannot mutate the successor. + assert.equal(policy.reserve("client", "third").code, "report_cooldown"); +}); + +test("uncertain outcome reserves capacity and bounded same-report protection", () => { + const { policy, setTime } = fixture(); + const pending = policy.reserve("client", "uncertain"); + pending.settle("uncertain"); + assert.equal(policy.reserve("client", "different").code, "report_cooldown"); + setTime(600000); + assert.equal(policy.reserve("client", "uncertain").code, "delivery_uncertain"); + accept(policy, "different"); + setTime(43200000); + accept(policy, "uncertain"); +}); + +test("client state is bounded, expires, and is explicitly process-local", () => { + const { policy, setTime } = fixture({ maxClients: 1 }); + policy.reserve("first", "report").settle("accepted"); + assert.equal(policy.reserve("other", "report").code, "report_capacity"); + setTime(43200000); + assert.equal(policy.reserve("other", "report").ok, true); + const restarted = createReportPolicy(); + assert.equal(restarted.reserve("first", "report").ok, true); +}); + +test("cooldown starts at delivery completion rather than reservation", () => { + const { policy, setTime } = fixture(); + const admission = policy.reserve("client", "first"); + setTime(10000); + admission.settle("accepted"); + setTime(609999); + assert.equal(policy.reserve("client", "next").retryAfterSeconds, 1); + setTime(610000); + accept(policy, "next"); +}); diff --git a/web/data/index.html b/web/data/index.html index 0c5d949..18d7c36 100644 --- a/web/data/index.html +++ b/web/data/index.html @@ -23,10 +23,10 @@

Report TarkovTracker Data Issues

- + - + - + - + +

Limits: title 200 characters, Discord handle 64, reference 500, details 8,000. Both forms share 20 reports per connection in a rolling 12 hours, with at least 10 minutes between reports.

-
+
+

Check published data reports. If delivery is unconfirmed, keep this draft open and check before sending it again.

@@ -68,6 +70,7 @@

Report TarkovTracker Data Issues

+ diff --git a/web/issue/index.html b/web/issue/index.html index d1f76cf..4a24f7f 100644 --- a/web/issue/index.html +++ b/web/issue/index.html @@ -24,13 +24,14 @@

Report TarkovTracker App Issues

- + - + - + +

Limits: title 200 characters, Discord handle 64, details 8,000. Both forms share 20 reports per connection in a rolling 12 hours, with at least 10 minutes between reports.

-
+
+

Check published website reports. If delivery is unconfirmed, keep this draft open and check before sending it again.

@@ -55,37 +57,12 @@

Report TarkovTracker App Issues

+ diff --git a/web/report-form.js b/web/report-form.js new file mode 100644 index 0000000..bb85ea8 --- /dev/null +++ b/web/report-form.js @@ -0,0 +1,82 @@ +// Both forms use the same response contract. No automatic retries: a lost +// response may hide a successfully created GitHub issue. +window.setupReportForm = function setupReportForm(form, statusBox, endpoint, successMessage) { + const button = form.querySelector('button[type="submit"]'); + let submitting = false; + let uncertain = false; + let waitUntil = 0; + const knownCodes = new Set([ + "report_cooldown", "report_window_limit", "report_in_progress", "report_capacity", + "attempt_limit", "invalid_report", "invalid_request", "report_too_large", + "forbidden_origin", "provider_rate_limit", "delivery_rejected", + "delivery_unavailable", "delivery_uncertain", "internal_error" + ]); + function showStatus(message, isError = false) { + statusBox.textContent = message; + statusBox.className = isError ? "status error" : "status success"; + } + function unknownDelivery() { + uncertain = true; + showStatus("Delivery could not be confirmed. Keep your draft and check the issue list or contact support before sending it again; it may already have been created.", true); + } + function retrySeconds(response, data) { + const value = Number(data?.retryAfterSeconds ?? response.headers.get("Retry-After")); + return Number.isFinite(value) && value > 0 ? Math.min(value, 43200) : 0; + } + function invalidField() { + return Array.from(form.querySelectorAll("[maxlength]")).find(field => field.value.trim().length > field.maxLength); + } + form.addEventListener("submit", async event => { + event.preventDefault(); + if (submitting || uncertain) return; + if (Date.now() < waitUntil) { + showStatus(`Please wait until ${new Date(waitUntil).toLocaleString()} before trying again. Your draft is unchanged.`, true); + return; + } + const field = invalidField(); + if (field) { + showStatus(`${field.name} must be ${field.maxLength} characters or fewer. Your draft is unchanged.`, true); + return; + } + submitting = true; + button.disabled = true; + showStatus("Submitting report..."); + try { + const payload = Object.fromEntries(new FormData(form).entries()); + const response = await fetch(endpoint, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + let data; + try { data = await response.json(); } catch { /* A proxy may return HTML. */ } + if (response.ok && data?.ok === true) { + form.reset(); + waitUntil = Date.now() + 600000; + showStatus(`${successMessage} Wait 10 minutes before submitting another report; both forms share the same limit.`); + return; + } + const error = data?.error; + if (knownCodes.has(error?.code) && typeof error.message === "string" && error.message.length <= 600) { + uncertain = error.code === "delivery_uncertain" || error.code === "internal_error"; + const seconds = retrySeconds(response, data); + waitUntil = Date.now() + seconds * 1000; + const reset = data.resetAt ? new Date(data.resetAt) : new Date(waitUntil); + const resetText = seconds && !uncertain && Number.isFinite(reset.getTime()) ? ` Wait until ${reset.toLocaleString()} before trying again; the server will check the limits again.` : ""; + showStatus(`${error.message}${resetText} Your draft is unchanged.`, true); + return; + } + if (response.status === 429) { + const seconds = retrySeconds(response, data) || 60; + waitUntil = Date.now() + seconds * 1000; + showStatus(`Too many submission attempts. Try again after ${new Date(waitUntil).toLocaleString()}. Your draft is unchanged.`, true); + return; + } + unknownDelivery(); + } catch { + unknownDelivery(); + } finally { + submitting = false; + button.disabled = uncertain; + } + }); +}; diff --git a/webserver.js b/webserver.js index 8eda171..3323412 100755 --- a/webserver.js +++ b/webserver.js @@ -1,221 +1,11 @@ -// -// webserver.js -// -import express from "express"; -import rateLimit from "express-rate-limit"; -import path from "path"; -import { fileURLToPath } from "url"; -import dotenv from "dotenv"; -import axios from "axios"; +import "dotenv/config"; +import { createReportApp } from "./src/web/report-app.js"; -dotenv.config(); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// --- Environment validation ------------------------------------------------- - -const REQUIRED_WEB_ENV = ["GITHUB_TOKEN", "REPO_DEV", "REPO_DATA_REPORT"]; - -function validateWebEnv() { - const missing = REQUIRED_WEB_ENV.filter(name => !process.env[name]?.trim()); - if (missing.length) { - throw new Error( - `Missing required environment variables for webserver: ${missing.join(", ")}` - ); - } -} - -validateWebEnv(); - -// --- App setup -------------------------------------------------------------- - -const app = express(); -const PORT = process.env.PORT || 3000; - -// Trust the first proxy so express-rate-limit sees real client IPs behind a -// reverse proxy (nginx, Cloudflare, etc.). Without this every request appears -// to come from the proxy IP and rate limiting throttles everyone collectively. -app.set("trust proxy", 1); - -const DATA_REPO = process.env.REPO_DATA_REPORT; -const DEV_REPO = process.env.REPO_DEV; - -const GITHUB_HEADERS = { - Authorization: `token ${process.env.GITHUB_TOKEN}`, - Accept: "application/vnd.github+json", - "User-Agent": "tarkovtracker-webserver" -}; - -// --- Field length caps ------------------------------------------------------ - -const MAX_TITLE = 200; -const MAX_DISCORD = 64; -const MAX_CATEGORY = 64; -const MAX_REFERENCE = 500; -const MAX_DESCRIPTION = 8000; - -function truncate(value, max) { - const v = (value ?? "").toString().trim(); - return v.length > max ? v.slice(0, max) : v; -} - -// --- Rate limiting ---------------------------------------------------------- -// Separate limiter instances so /data and /issue have independent per-IP quotas. - -const rateLimitConfig = { - windowMs: 60_000, - max: 5, - standardHeaders: true, - legacyHeaders: false, - message: { error: "Too many submissions. Please wait a minute and try again." } -}; - -const dataLimiter = rateLimit(rateLimitConfig); -const issueLimiter = rateLimit(rateLimitConfig); - -// --- Origin allowlist ------------------------------------------------------- - -// Trailing slashes are stripped so that "https://example.com/" and -// "https://example.com" are treated identically. -const ALLOWED_ORIGINS = new Set( - (process.env.ALLOWED_ORIGINS || "") - .split(",") - .map(o => o.trim().replace(/\/+$/, "")) - .filter(Boolean) -); - -function checkOrigin(req, res, next) { - if (ALLOWED_ORIGINS.size === 0) return next(); - const origin = req.headers.origin || req.headers.referer || ""; - // Exact match or the allowed origin followed by a "/" boundary. - // This blocks suffix attacks like "https://tarkovtracker.org.evil.com". - const allowed = [...ALLOWED_ORIGINS].some( - o => origin === o || origin.startsWith(`${o}/`) - ); - if (!allowed) { - return res.status(403).json({ error: "Forbidden origin." }); - } - next(); -} - -// --- Middleware ------------------------------------------------------------- - -app.use(express.urlencoded({ extended: true, limit: "32kb" })); -app.use(express.json({ limit: "32kb" })); -app.use(express.static(path.join(__dirname, "web"))); - -app.get("/health", (req, res) => res.json({ status: "ok" })); - -// --- Honeypot + validation helpers ----------------------------------------- - -const HONEYPOT_FIELD = "company"; - -function hasHoneypotHit(body) { - return Boolean((body?.[HONEYPOT_FIELD] ?? "").toString().trim()); +const required = ["GITHUB_TOKEN", "REPO_DEV", "REPO_DATA_REPORT"]; +const missing = required.filter(name => !process.env[name]?.trim()); +if (missing.length) { + throw new Error(`Missing required environment variables for webserver: ${missing.join(", ")}`); } -function sanitizeReportFields({ title, discord, category, description, reference }) { - return { - title: truncate(title, MAX_TITLE), - discord: truncate(discord, MAX_DISCORD), - category: truncate(category, MAX_CATEGORY), - description: truncate(description, MAX_DESCRIPTION), - reference: reference ? truncate(reference, MAX_REFERENCE) : "" - }; -} - -// --- Routes ----------------------------------------------------------------- - -/** - * Data bug report - */ -app.post("/data", dataLimiter, checkOrigin, async (req, res) => { - try { - if (hasHoneypotHit(req.body)) { - return res.json({ ok: true }); - } - - const { title, discord, category, description, reference } = sanitizeReportFields(req.body); - - if (![title, discord, category, description].every(v => v?.trim())) { - return res - .status(400) - .json({ error: "All required fields must be provided." }); - } - - const lines = [ - `**Discord handle:** ${discord}`, - `**Category:** ${category}` - ]; - - if (reference) { - lines.push(`**Reference:** ${reference}`); - } - - lines.push("", "**Details:**", description); - - await axios.post( - `https://api.github.com/repos/${DATA_REPO}/issues`, - { - title: `[${category}] ${title}`, - body: lines.join("\n") - }, - { headers: GITHUB_HEADERS, timeout: 10_000 } - ); - - res.json({ ok: true }); - } catch (err) { - logSanitizedError("POST /data", err); - res.status(500).json({ error: "Failed to submit the data report." }); - } -}); - -/** - * Dev-only issue report - */ -app.post("/issue", issueLimiter, checkOrigin, async (req, res) => { - try { - if (hasHoneypotHit(req.body)) { - return res.json({ ok: true }); - } - - const { title, discord, description } = sanitizeReportFields(req.body); - - if (![title, discord, description].every(v => v?.trim())) { - return res.status(400).json({ error: "Fields marked * are required." }); - } - - const body = `**Discord handle:** ${discord} - -**Description:** -${description}`; - - await axios.post( - `https://api.github.com/repos/${DEV_REPO}/issues`, - { title, body }, - { headers: GITHUB_HEADERS, timeout: 10_000 } - ); - - res.json({ ok: true }); - } catch (err) { - logSanitizedError("POST /issue", err); - res.status(500).json({ error: "Error during the bug report." }); - } -}); - -// --- Sanitized error logging ------------------------------------------------ -// Never log the full axios error object — it contains err.config.headers -// which includes the raw Authorization header (the GitHub token). - -function logSanitizedError(route, err) { - const status = err.response?.status ?? "no-response"; - const message = err.message ?? "unknown error"; - console.error(`[${route}] GitHub API error: status=${status} message=${message}`); -} - -// --- Start ------------------------------------------------------------------ - -app.listen(PORT, () => { - console.log(`Webserver running on port ${PORT}`); -}); +const port = process.env.PORT || 3000; +createReportApp().listen(port, () => console.log(`Webserver running on port ${port}`)); From aca3814694dcf5b0f88eb00646af73337533ecaf Mon Sep 17 00:00:00 2001 From: DysektAI <33468668+DysektAI@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:27:16 -0400 Subject: [PATCH 02/17] fix(web): honor provider waits and classify parser errors --- README.md | 12 ++++++++++-- src/web/report-app.js | 41 +++++++++++++++++++++++++++++++++------- test/report-app.test.js | 36 +++++++++++++++++++++++++++++++++++ test/report-form.test.js | 32 +++++++++++++++++++++++++++---- web/report-form.js | 5 +++-- 5 files changed, 111 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2f9048f..01c49b9 100755 --- a/README.md +++ b/README.md @@ -61,9 +61,17 @@ Errors use `{ error: { code, message }, retryAfterSeconds?, resetAt? }`. Admission errors use HTTP `429` (`report_cooldown`, `report_window_limit`, `attempt_limit`, `report_in_progress`), capacity uses `503`, and uncertain repeat delivery uses `409`. Wait errors include numeric `Retry-After` seconds and an ISO -`resetAt`. Validation/parser/origin errors use `400`/`413`/`403`. Proven provider +`resetAt`. Validation/parser/origin errors use `400`/`413`/`415`/`403`. Proven provider rejections/connectivity failures use `502`/`503`; unconfirmed delivery uses `504`. -Provider throttling supplies safe retry guidance. Provider bodies, tokens, IPs +Provider throttling honors full `Retry-After` and exhausted-primary +`x-ratelimit-reset` waits, taking the longer when both apply. Secondary throttles +without timing headers use GitHub's minimum one-minute guidance. Neither server +nor browser truncates a valid provider wait. Known client parser failures, +including parameter overflow, excessive nesting and unsupported formats, return +retryable typed `4xx` errors; they are not uncertain GitHub delivery. +See [GitHub rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#exceeding-the-rate-limit) +and [body-parser errors](https://expressjs.com/en/resources/middleware/body-parser/#errors). +Provider bodies, tokens, IPs and report descriptions are never included in error output or delivery logs. **Rollout limitation: these controls are process-local memory, not durable or diff --git a/src/web/report-app.js b/src/web/report-app.js index 30ab0ab..a9032fb 100644 --- a/src/web/report-app.js +++ b/src/web/report-app.js @@ -8,6 +8,17 @@ import { createReportPolicy } from "./report-policy.js"; const webDirectory = fileURLToPath(new URL("../../web", import.meta.url)); export const FIELD_LIMITS = { title: 200, discord: 64, category: 64, reference: 500, description: 8000 }; +const parserErrors = new Map([ + ["entity.too.large", [413, "report_too_large", "This report exceeds the 32 KiB request limit. Shorten the details while keeping the key evidence."]], + ["parameters.too.many", [413, "report_too_large", "The request contains too many fields. Keep your draft and reload the report form."]], + ["entity.parse.failed", [400, "invalid_request", "The report request could not be read. Keep your draft and reload the form before retrying."]], + ["querystring.parse.rangeError", [400, "invalid_request", "The request contains too many nested fields. Keep your draft and reload the report form."]], + ["request.aborted", [400, "invalid_request", "The report request was interrupted before delivery. Keep your draft and reload the report form."]], + ["request.size.invalid", [400, "invalid_request", "The report request size is invalid. Keep your draft and reload the report form."]], + ["charset.unsupported", [415, "unsupported_request", "The report format is unsupported. Keep your draft and reload the report form."]], + ["encoding.unsupported", [415, "unsupported_request", "The report format is unsupported. Keep your draft and reload the report form."]], + ["entity.verify.failed", [403, "invalid_request", "The report request could not be verified. Keep your draft and reload the report form."]] +]); function clientKey(ip) { // Normalize hexadecimal mapped IPv4 before the existing IPv6 /56 grouping. @@ -51,12 +62,27 @@ function buildIssue(kind, fields) { return { title: `[${fields.category}] ${fields.title}`, body: lines.join("\n") }; } -function deliveryError(err) { +function safeWait(seconds, time) { + const wait = Math.ceil(seconds); + return Number.isSafeInteger(wait) && wait >= 0 && Number.isFinite(new Date(time + wait * 1000).getTime()) ? wait : 0; +} + +function providerWait(headers, time) { + const retry = Number(headers["retry-after"]); + const retrySeconds = safeWait(Number.isFinite(retry) ? retry : (Date.parse(headers["retry-after"]) - time) / 1000, time); + const resetSeconds = String(headers["x-ratelimit-remaining"]) === "0" + ? safeWait((Number(headers["x-ratelimit-reset"]) * 1000 - time) / 1000, time) : 0; + return Math.max(retrySeconds, resetSeconds) || 60; +} + +function deliveryError(err, time) { const status = err?.response?.status; const headers = err?.response?.headers ?? {}; - if (status === 429 || (status === 403 && (headers["retry-after"] || headers["x-ratelimit-remaining"] === "0"))) { - const seconds = Number(headers["retry-after"]); - return { status: 503, code: "provider_rate_limit", message: "GitHub is temporarily limiting reports. Your report was not created. Keep your draft and wait before retrying.", retryAfterSeconds: Number.isFinite(seconds) && seconds > 0 ? Math.min(3600, Math.ceil(seconds)) : 60, failed: true }; + const message = err?.response?.data?.message; + const secondary = typeof message === "string" && /secondary rate limit/i.test(message); + if (status === 429 || (status === 403 && (headers["retry-after"] || String(headers["x-ratelimit-remaining"]) === "0" || secondary))) { + const seconds = providerWait(headers, time); + return { status: 503, code: "provider_rate_limit", message: "GitHub is temporarily limiting reports. Your report was not created. Keep your draft and wait before retrying.", retryAfterSeconds: seconds, resetAt: new Date(time + seconds * 1000).toISOString(), failed: true }; } if ([400, 401, 403, 404, 405, 410, 422].includes(status)) { return { status: 502, code: "delivery_rejected", message: "The report service could not deliver this report. It was not created. Keep your draft and contact support.", failed: true }; @@ -106,10 +132,11 @@ export function createReportApp({ env = process.env, githubPost = axios.post, no admission.settle("accepted"); return res.json({ ok: true }); } catch (err) { - const failure = deliveryError(err); + const failure = deliveryError(err, now()); admission.settle(failure.failed ? "failed" : "uncertain"); logger.error("Report delivery failed", { route: kind, status: Number.isInteger(err?.response?.status) ? err.response.status : "no-response", code: failure.code }); const details = failure.retryAfterSeconds ? { retryAfterSeconds: failure.retryAfterSeconds } : {}; + if (failure.resetAt) details.resetAt = failure.resetAt; return sendError(res, failure.status, failure.code, failure.message, details); } } @@ -117,8 +144,8 @@ export function createReportApp({ env = process.env, githubPost = axios.post, no app.post("/issue", (req, res) => submit(req, res, "issue")); app.use((err, req, res, next) => { if (res.headersSent) return next(err); - if (err.type === "entity.too.large") return sendError(res, 413, "report_too_large", "This report exceeds the 32 KiB request limit. Shorten the details while keeping the key evidence."); - if (err.type === "entity.parse.failed") return sendError(res, 400, "invalid_request", "The report request could not be read. Keep your draft and reload the form before retrying."); + const parserError = parserErrors.get(err.type); + if (parserError) return sendError(res, ...parserError); logger.error("Report request failed", { type: "internal_error" }); return sendError(res, 500, "internal_error", "The report service encountered an error. Keep your draft and contact support before repeating a submitted report."); }); diff --git a/test/report-app.test.js b/test/report-app.test.js index a1d7cf3..3cc6fd2 100644 --- a/test/report-app.test.js +++ b/test/report-app.test.js @@ -205,3 +205,39 @@ test("uncertain dedupe also covers equivalent route spellings", async t => { assert.equal((await api.post("DATA/")).status, 409); assert.equal(api.calls.length, 1); }); + +for (const scenario of [ + { name: "primary reset", status: 403, headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": "3600" }, seconds: 3600 }, + { name: "full Retry-After", status: 429, headers: { "retry-after": "7200" }, seconds: 7200 }, + { name: "longest provider reset", status: 403, headers: { "retry-after": "120", "x-ratelimit-remaining": "0", "x-ratelimit-reset": "3600" }, seconds: 3600 }, + { name: "secondary throttle without header", status: 403, headers: {}, message: "You have exceeded a secondary rate limit. PRIVATE_PROVIDER_DETAIL", seconds: 60 } +]) { + test(`GitHub ${scenario.name} returns full safe provider wait`, async t => { + const api = await fixture(t, async () => { throw { response: { status: scenario.status, headers: scenario.headers, data: { message: scenario.message } } }; }); + const result = await api.post(); + assert.equal(result.body.error.code, "provider_rate_limit"); + assert.equal(result.headers.get("Retry-After"), String(scenario.seconds)); + assert.equal(result.body.retryAfterSeconds, scenario.seconds); + assert.equal(Date.parse(result.body.resetAt), scenario.seconds * 1000); + assert.doesNotMatch(JSON.stringify([result, api.logs]), /PRIVATE_PROVIDER_DETAIL/); + }); +} + +for (const scenario of [ + { name: "parameter overflow", body: Array.from({ length: 1001 }, (_, index) => `p${index}=x`).join("&"), headers: { "Content-Type": "application/x-www-form-urlencoded" }, status: 413, code: "report_too_large" }, + { name: "unsupported JSON charset", body: "{}", headers: { "Content-Type": "application/json; charset=shift_jis" }, status: 415, code: "unsupported_request" }, + { name: "unsupported form charset", body: "title=test", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=shift_jis" }, status: 415, code: "unsupported_request" }, + { name: "unsupported encoding", body: "{}", headers: { "Content-Encoding": "compress" }, status: 415, code: "unsupported_request" }, + { name: "excessive form nesting", body: `a${"[b]".repeat(40)}=x`, headers: { "Content-Type": "application/x-www-form-urlencoded" }, status: 400, code: "invalid_request" } +]) { + test(`parser ${scenario.name} is a retryable client error without delivery`, async t => { + const api = await fixture(t); + assert.ok(Buffer.byteLength(scenario.body) < 32768); + const result = await api.post("data", scenario.body, scenario.headers); + assert.equal(result.status, scenario.status); + assert.equal(result.body.error.code, scenario.code); + assert.equal(api.calls.length, 0); + assert.equal(api.logs.length, 0); + assert.equal((await api.post()).status, 200); + }); +} diff --git a/test/report-form.test.js b/test/report-form.test.js index 287e5fd..ef1360c 100644 --- a/test/report-form.test.js +++ b/test/report-form.test.js @@ -3,8 +3,8 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import vm from "node:vm"; -async function loadForm(kind, fetch, search = "") { - const html = await readFile(new URL(`../web/${kind}/index.html`, import.meta.url), "utf8"); +async function loadForm(kind, fetch, search = "", transformHtml = html => html) { + const html = transformHtml(await readFile(new URL(`../web/${kind}/index.html`, import.meta.url), "utf8")); const button = { disabled: false }; const status = { textContent: "", className: "" }; const form = { @@ -30,7 +30,7 @@ async function loadForm(kind, fetch, search = "") { FormData: class { entries() { return [["title", "Retained draft"]]; } }, fetch }); - for (const script of html.matchAll(/]*>([\s\S]*?)<\/script>/g)) { + for (const script of html.matchAll(/]*>([\s\S]*?)<\/script>/gi)) { const code = script[1] ? await readFile(new URL(`../web${script[1]}`, import.meta.url), "utf8") : script[2]; @@ -63,7 +63,7 @@ for (const kind of ["data", "issue"]) { assert.equal(ui.form.resetCount, 1); }); - for (const code of ["invalid_report", "invalid_request", "report_too_large", "forbidden_origin", "provider_rate_limit", "delivery_rejected", "delivery_unavailable", "report_window_limit"]) { + for (const code of ["invalid_report", "invalid_request", "unsupported_request", "report_too_large", "forbidden_origin", "provider_rate_limit", "delivery_rejected", "delivery_unavailable", "report_window_limit"]) { test(`${kind}: preserves draft and presents typed ${code} error`, async () => { const message = `Safe error for ${code}`; const ui = await loadForm(kind, async () => ({ ok: false, status: 400, headers: { get() { return null; } }, async json() { return { error: { code, message } }; } })); @@ -126,6 +126,20 @@ for (const kind of ["data", "issue"]) { }); } +test("browser retains the full provider wait beyond the reporting window", async () => { + let calls = 0; + const ui = await loadForm("issue", async () => { + calls++; + return { ok: false, status: 503, headers: { get() { return "50000"; } }, async json() { return { error: { code: "provider_rate_limit", message: "Wait for the provider." }, retryAfterSeconds: 50000 }; } }; + }); + await ui.submit(); + ui.setTime(43200000); + await ui.submit(); + assert.equal(calls, 1); + assert.equal(ui.form.resetCount, 0); + assert.equal(ui.button.disabled, false); +}); + test("data prefills remain intact, including reference and leading newlines", async () => { const search = new URLSearchParams({ title: "Prefilled task", reference: "https://example.test/proof", description: "\n\nObserved details" }).toString(); const ui = await loadForm("data", async () => {}, `?${search}`); @@ -133,3 +147,13 @@ test("data prefills remain intact, including reference and leading newlines", as assert.equal(ui.fields.get("reference").value, "https://example.test/proof"); assert.equal(ui.fields.get("description").value, "\n\nObserved details"); }); + +test("fixture loads case-insensitive HTML script tags", async () => { + const ui = await loadForm("issue", async () => ({ + ok: false, status: 400, headers: { get() { return null; } }, + async json() { return { error: { code: "invalid_report", message: "Keep the draft." } }; } + }), "", html => html.replaceAll("", "")); + await ui.submit(); + assert.match(ui.status.textContent, /Keep the draft/); + assert.equal(ui.form.resetCount, 0); +}); diff --git a/web/report-form.js b/web/report-form.js index bb85ea8..77feea9 100644 --- a/web/report-form.js +++ b/web/report-form.js @@ -7,7 +7,7 @@ window.setupReportForm = function setupReportForm(form, statusBox, endpoint, suc let waitUntil = 0; const knownCodes = new Set([ "report_cooldown", "report_window_limit", "report_in_progress", "report_capacity", - "attempt_limit", "invalid_report", "invalid_request", "report_too_large", + "attempt_limit", "invalid_report", "invalid_request", "unsupported_request", "report_too_large", "forbidden_origin", "provider_rate_limit", "delivery_rejected", "delivery_unavailable", "delivery_uncertain", "internal_error" ]); @@ -21,7 +21,8 @@ window.setupReportForm = function setupReportForm(form, statusBox, endpoint, suc } function retrySeconds(response, data) { const value = Number(data?.retryAfterSeconds ?? response.headers.get("Retry-After")); - return Number.isFinite(value) && value > 0 ? Math.min(value, 43200) : 0; + const seconds = Math.ceil(value); + return Number.isSafeInteger(seconds) && seconds > 0 && Number.isFinite(new Date(Date.now() + seconds * 1000).getTime()) ? seconds : 0; } function invalidField() { return Array.from(form.querySelectorAll("[maxlength]")).find(field => field.value.trim().length > field.maxLength); From a9efcc33c17270cb5a82b1847129d250333a24a8 Mon Sep 17 00:00:00 2001 From: DysektAI <33468668+DysektAI@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:35:17 -0400 Subject: [PATCH 03/17] fix(web): include exhausting attempts in retry guidance --- src/web/report-policy.js | 7 +++++-- test/report-app.test.js | 15 +++++++++++++++ test/report-form.test.js | 9 ++++++--- test/report-policy.test.js | 7 +++++-- web/data/index.html | 2 +- web/issue/index.html | 2 +- web/report-form.js | 6 +++--- 7 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/web/report-policy.js b/src/web/report-policy.js index 57b5ff4..735644b 100644 --- a/src/web/report-policy.js +++ b/src/web/report-policy.js @@ -67,11 +67,14 @@ export function createReportPolicy({ now = Date.now, maxClients = 10_000 } = {}) const state = stateFor(key, time); if (state.ok === false) return state; const reportingBlock = blocked(state, time); - if (state.attempts.length >= ATTEMPT_LIMIT) { + const exhausted = state.attempts.length >= ATTEMPT_LIMIT; + if (!exhausted) state.attempts.push(time); + // An eligible fifth attempt still passes. A rejected fifth attempt must + // advertise the wait its newly exhausted attempt budget imposes on retry. + if (exhausted || (reportingBlock && state.attempts.length >= ATTEMPT_LIMIT)) { const attemptBlock = denied("attempt_limit", "Too many submission attempts. Keep your draft and wait one minute.", state.attempts[0] + ATTEMPT_WINDOW_MS, time); return reportingBlock && Date.parse(reportingBlock.resetAt) > Date.parse(attemptBlock.resetAt) ? reportingBlock : attemptBlock; } - state.attempts.push(time); return reportingBlock ?? { ok: true }; } function reserve(key, fingerprint) { diff --git a/test/report-app.test.js b/test/report-app.test.js index 3cc6fd2..bf5a64f 100644 --- a/test/report-app.test.js +++ b/test/report-app.test.js @@ -223,6 +223,21 @@ for (const scenario of [ }); } +test("fifth cooldown rejection advertises the newly exhausted attempt wait", async t => { + const api = await fixture(t); + await api.post(); + api.setTime(599000); + for (let index = 0; index < 4; index++) { + assert.equal((await api.post()).headers.get("Retry-After"), "1"); + } + const fifth = await api.post(); + assert.equal(fifth.body.error.code, "attempt_limit"); + assert.equal(fifth.headers.get("Retry-After"), "60"); + assert.equal(Date.parse(fifth.body.resetAt), 659000); + api.setTime(659000); + assert.equal((await api.post()).status, 200); +}); + for (const scenario of [ { name: "parameter overflow", body: Array.from({ length: 1001 }, (_, index) => `p${index}=x`).join("&"), headers: { "Content-Type": "application/x-www-form-urlencoded" }, status: 413, code: "report_too_large" }, { name: "unsupported JSON charset", body: "{}", headers: { "Content-Type": "application/json; charset=shift_jis" }, status: 415, code: "unsupported_request" }, diff --git a/test/report-form.test.js b/test/report-form.test.js index ef1360c..2536c45 100644 --- a/test/report-form.test.js +++ b/test/report-form.test.js @@ -30,7 +30,7 @@ async function loadForm(kind, fetch, search = "", transformHtml = html => html) FormData: class { entries() { return [["title", "Retained draft"]]; } }, fetch }); - for (const script of html.matchAll(/]*>([\s\S]*?)<\/script>/gi)) { + for (const script of html.matchAll(/]*>([\s\S]*?)<\/script\b[^>]*>/gi)) { const code = script[1] ? await readFile(new URL(`../web${script[1]}`, import.meta.url), "utf8") : script[2]; @@ -56,11 +56,13 @@ for (const kind of ["data", "issue"]) { const ui = await loadForm(kind, () => { calls++; return new Promise(resolve => { release = resolve; }); }); const first = ui.submit(); assert.equal(ui.button.disabled, true); + assert.equal(ui.status.className, "status"); await ui.submit(); assert.equal(calls, 1); release({ ok: true, async json() { return { ok: true }; } }); await first; assert.equal(ui.form.resetCount, 1); + assert.equal(ui.status.className, "status success"); }); for (const code of ["invalid_report", "invalid_request", "unsupported_request", "report_too_large", "forbidden_origin", "provider_rate_limit", "delivery_rejected", "delivery_unavailable", "report_window_limit"]) { @@ -123,6 +125,7 @@ for (const kind of ["data", "issue"]) { assert.match(ui.status.textContent, /8000/); assert.match(ui.html, /maxlength="8000"/); assert.match(ui.html, /aria-live="polite"/); + assert.match(ui.html, /per client IP/); }); } @@ -148,11 +151,11 @@ test("data prefills remain intact, including reference and leading newlines", as assert.equal(ui.fields.get("description").value, "\n\nObserved details"); }); -test("fixture loads case-insensitive HTML script tags", async () => { +test("fixture loads case-insensitive script tags with closing whitespace", async () => { const ui = await loadForm("issue", async () => ({ ok: false, status: 400, headers: { get() { return null; } }, async json() { return { error: { code: "invalid_report", message: "Keep the draft." } }; } - }), "", html => html.replaceAll("", "")); + }), "", html => html.replaceAll("", "")); await ui.submit(); assert.match(ui.status.textContent, /Keep the draft/); assert.equal(ui.form.resetCount, 0); diff --git a/test/report-policy.test.js b/test/report-policy.test.js index 8db7f98..dd75113 100644 --- a/test/report-policy.test.js +++ b/test/report-policy.test.js @@ -73,7 +73,7 @@ test("attempt and cooldown overlap chooses either longest constraint", () => { accept(policy); for (let index = 0; index < 6; index++) assert.equal(policy.attempt("client").retryAfterSeconds, 600); setTime(599000); - for (let index = 0; index < 5; index++) assert.equal(policy.attempt("client").code, "report_cooldown"); + for (let index = 0; index < 4; index++) assert.equal(policy.attempt("client").code, "report_cooldown"); const attempts = policy.attempt("client"); assert.equal(attempts.code, "attempt_limit"); assert.equal(attempts.retryAfterSeconds, 60); @@ -84,8 +84,11 @@ test("pending reservation blocks concurrent calls; failure releases allowance", const first = policy.reserve("client", "first"); assert.equal(policy.reserve("client", "second").code, "report_in_progress"); first.settle("failed"); - accept(policy, "second"); + const second = policy.reserve("client", "second"); + assert.equal(second.ok, true); first.settle("accepted"); // stale/double settlement cannot mutate the successor. + assert.equal(policy.reserve("client", "third").code, "report_in_progress"); + second.settle("accepted"); assert.equal(policy.reserve("client", "third").code, "report_cooldown"); }); diff --git a/web/data/index.html b/web/data/index.html index 18d7c36..833ff9d 100644 --- a/web/data/index.html +++ b/web/data/index.html @@ -44,7 +44,7 @@

Report TarkovTracker Data Issues

-

Limits: title 200 characters, Discord handle 64, reference 500, details 8,000. Both forms share 20 reports per connection in a rolling 12 hours, with at least 10 minutes between reports.

+

Limits: title 200 characters, Discord handle 64, reference 500, details 8,000. Both forms share 20 reports per client IP in a rolling 12 hours, with at least 10 minutes between reports. People sharing an IP address or IPv6 prefix share these limits.