diff --git a/.env.example b/.env.example index 9fcf0dd..1d629c5 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,17 @@ # Discord bot DISCORD_TOKEN=YOUR_DISCORD_TOKEN -# GitHub issue routing (webserver) +# GitHub issue routing (webserver): both data and website reports use REPO_DEV. +# The production token must have issue-write access to this public repository. GITHUB_TOKEN=YOUR_GITHUB_TOKEN -REPO_DEV=orga/repo -REPO_DATA_REPORT=orga/repo +REPO_DEV=tarkovtracker-org/TarkovTracker +# Legacy REPO_DATA_REPORT settings are ignored while the overlay is private. # Web server PORT=3000 # Comma-separated list of allowed origins for the report forms. # Leave blank to disable origin checking (not recommended for production). -ALLOWED_ORIGINS=https://tarkovtracker.org,https://www.tarkovtracker.org +ALLOWED_ORIGINS=https://tarkovtracker.org,https://www.tarkovtracker.org,https://trackerbot.nivmizz7.dev,https://www.trackerbot.nivmizz7.dev # Discord channel IDs for automated posts WELCOME_CHANNEL_ID=WELCOME_CHANNEL_ID diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a7c56e..367b34c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,22 +32,11 @@ jobs: - name: Install dependencies run: npm ci + - name: Audit production dependencies + run: npm run audit:prod + - name: Syntax check all JS files - run: | - set -e - fail=0 - while IFS= read -r f; do - if ! node --check "$f" 2>/dev/null; then - echo "::error file=$f::Syntax error in $f" - node --check "$f" || true - fail=1 - fi - done < <(find . -name "*.js" -not -path "./node_modules/*") - if [ "$fail" -ne 0 ]; then - echo "::error::One or more JS files failed syntax check" - exit 1 - fi - echo "All JS files passed syntax check." + run: npm run build - name: Run tests run: npm test diff --git a/README.md b/README.md index 39301f6..f07bf81 100755 --- a/README.md +++ b/README.md @@ -22,6 +22,92 @@ 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 + +Both report forms currently create issues in `REPO_DEV`, configured as +`tarkovtracker-org/TarkovTracker`. The data overlay repository is private, so +`REPO_DATA_REPORT` is no longer required and any existing value is ignored. +Data reports retain their category-prefixed title, category, reference and details. +Both forms link to the public TarkovTracker issue list for delivery checks and +explicitly warn that Discord handles, reference links and report details are public. + +`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`/`415`/`403`. Proven provider +rejections/connectivity failures use `502`/`503`; unconfirmed delivery uses `504`. +Provider throttling honors full `Retry-After` and exhausted-primary +`x-ratelimit-reset` waits, taking the longer when both apply. A shared process-local +provider hold prevents every client on both routes, including staged uploads, from +calling GitHub again before that reset. Local denials include any longer provider +wait, while known uncertain-report denials keep their check-before-repeating warning. +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 +shared across workers/hosts.** A restart clears allowances, cooldowns, +uncertain-report holds and the shared provider throttle. 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, expiry cleanup runs +at most once per second. New clients +receive a safe busy response when the remaining entries still hold active limits; +active attempt budgets are not evicted to make room. Otherwise rotating keys could +bypass the five-attempt allowance. Sustained traffic from 10,000 distinct active +client keys can still exhaust this bounded table; operator-side abuse protection +and the trusted-proxy boundary remain necessary. 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 @@ -39,20 +125,49 @@ Requires **Node.js 18.17+**. ### 1. Install dependencies ```bash -npm install +npm ci +npm run build ``` +This project has no bundle or generated build output. `npm run build` syntax-checks +all JavaScript files without starting either service. CI runs the same command, +the tests, and `npm run audit:prod`; production dependency advisories fail CI. + ### 2. Configure environment variables Copy `.env.example` to `.env` and fill in every value: - **Discord bot basics:** `DISCORD_TOKEN`, `ADMIN_ROLE_ID` (comma-separated Discord role IDs granting admin-only commands such as `/wipe`) -- **GitHub issue routing:** `GITHUB_TOKEN`, `REPO_DEV`, `REPO_DATA_REPORT` +- **GitHub issue routing:** `GITHUB_TOKEN`, `REPO_DEV=tarkovtracker-org/TarkovTracker` for both forms. Legacy `REPO_DATA_REPORT` is ignored. - **Channel IDs for automated posts:** `WELCOME_CHANNEL_ID`; optional `HONEYPOT_CHANNEL_ID` (a hidden channel — anyone who posts in it is banned on sight, with a kick fallback; messages older than 16h are swept hourly) - **Role IDs:** `PANEL_ADMIN_ROLE_ID`, `AUTO_ROLE_ID_1`–`AUTO_ROLE_ID_4` (auto-roles for new members); optional `ALLOWED_COMMAND_ROLE_IDS` (comma-separated role IDs allowed to use member commands, falls back to a built-in default) - **Web server:** `PORT`, `ALLOWED_ORIGINS` (comma-separated origins, exact match) -> `GITHUB_TOKEN` needs `repo` scope (or equivalent granular access) to create issues in the configured repositories. For production, prefer a fine-grained PAT scoped to `issues:write` on the two target repos only. +> The deployed `GITHUB_TOKEN` needs issue-write access to `tarkovtracker-org/TarkovTracker`. Prefer a fine-grained PAT with **Issues: write** on that repository only. Redirecting data reports removes the private-overlay dependency, but does not grant permissions to a token that cannot create website issues. + +### Production rollout checklist + +- Use `npm ci`, `npm run build`, and `npm start` in the hosting configuration. + `npm start` launches one bot and one web process per invocation; its launcher is + a runtime dependency, so `npm ci --omit=dev` is also supported. Verify the actual + replica count and deployment overlap rather than inferring it from the command. +- Set `REPO_DEV=tarkovtracker-org/TarkovTracker`. Verify the deployed token's + issue-write access without sharing credentials. Both report routes use that destination. +- Correct the operator's origin-list typos (`https//` is not `https://`). A valid + list for the reported domains is: + `https://tarkovtracker.org,https://www.tarkovtracker.org,https://trackerbot.nivmizz7.dev,https://www.trackerbot.nivmizz7.dev`. + A preview hostname must be added separately if its forms are tested. Do not + disable origin checking just to make a preview pass. +- Verify that Node is not directly Internet-accessible, that the trusted proxy + overwrites forwarding headers, and that distinct visitors retain distinct limiter keys. + Leave `trust proxy = 1` unchanged until the real proxy path is established. +- Confirm a single serving web process, including during deployments, and explicitly + accept that restarts clear limits, uncertain-delivery holds and provider throttles. + If strict limits across replicas or restarts are required, approve a shared/persistent + store before rollout. +- A successful `/health` response only proves the HTTP process is running, not + GitHub delivery or correct proxy isolation. Coolify configuration and production + credentials cannot be verified from repository tests. ### 3. Run the services locally diff --git a/package-lock.json b/package-lock.json index 0118c0f..d81d2bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,14 +10,12 @@ "license": "MIT", "dependencies": { "axios": "^1.20.0", + "concurrently": "9.2.4", "discord.js": "^14.26.4", "dotenv": "^17.2.3", "express": "^5.1.0", "express-rate-limit": "^8.5.2" }, - "devDependencies": { - "concurrently": "^9.2.4" - }, "engines": { "node": ">=18.17" } @@ -242,7 +240,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -252,7 +249,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -283,18 +279,18 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -307,9 +303,9 @@ } }, "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "engines": { "node": ">=18" @@ -361,7 +357,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -378,7 +373,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -391,7 +385,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -406,7 +399,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -419,7 +411,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -438,7 +429,6 @@ "version": "9.2.4", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", - "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", @@ -597,7 +587,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -657,7 +646,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -758,9 +746,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", @@ -843,7 +831,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -902,7 +889,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -981,9 +967,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -1002,9 +988,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ip-address": { - "version": "10.7.2", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", - "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "license": "MIT", "engines": { "node": ">= 12" @@ -1022,7 +1008,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1186,9 +1171,9 @@ } }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -1228,7 +1213,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1253,7 +1237,6 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -1328,7 +1311,6 @@ "version": "1.12.0", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1421,7 +1403,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -1436,7 +1417,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -1449,7 +1429,6 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -1473,7 +1452,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, "license": "MIT", "bin": { "tree-kill": "cli.js" @@ -1522,9 +1500,9 @@ } }, "node_modules/undici": { - "version": "6.28.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", - "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -1556,7 +1534,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -1600,7 +1577,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -1610,7 +1586,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -1629,7 +1604,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/package.json b/package.json index d54ba6b..1c9e7a5 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "main": "bot.js", "scripts": { "test": "node --test", + "build": "node scripts/check-syntax.js", + "audit:prod": "npm audit --omit=dev --audit-level=low", "start:bot": "node bot.js", "start:web": "node webserver.js", "start": "concurrently -n bot,web \"npm:start:bot\" \"npm:start:web\"" @@ -24,17 +26,18 @@ }, "dependencies": { "axios": "^1.20.0", + "concurrently": "9.2.4", "discord.js": "^14.26.4", "dotenv": "^17.2.3", "express": "^5.1.0", "express-rate-limit": "^8.5.2" }, "overrides": { - "undici": "^6.28.0", + "undici": "6.29.1", "ws": "^8.21.0", - "shell-quote": "1.12.0" - }, - "devDependencies": { - "concurrently": "^9.2.4" + "shell-quote": "1.12.0", + "follow-redirects": "1.16.1", + "ip-address": "10.7.4", + "body-parser": "2.4.0" } } diff --git a/scripts/check-syntax.js b/scripts/check-syntax.js new file mode 100644 index 0000000..a9110af --- /dev/null +++ b/scripts/check-syntax.js @@ -0,0 +1,33 @@ +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const excludedDirectories = new Set(["node_modules", ".git"]); + +async function javaScriptFiles(directory) { + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const filename = path.join(directory, entry.name); + if (entry.isDirectory() && !excludedDirectories.has(entry.name)) { + files.push(...await javaScriptFiles(filename)); + } else if (entry.isFile() && entry.name.endsWith(".js")) { + files.push(filename); + } + } + return files; +} + +const files = (await javaScriptFiles(root)).sort(); +if (!files.length) throw new Error("No JavaScript files found for syntax checking."); +for (const filename of files) { + const result = spawnSync(process.execPath, ["--check", filename], { encoding: "utf8" }); + if (result.error) throw result.error; + if (result.status !== 0) { + console.error(`Syntax check failed: ${path.relative(root, filename)}`); + console.error(result.stderr); + process.exitCode = 1; + } +} +if (!process.exitCode) console.log(`Syntax checked ${files.length} JavaScript files.`); diff --git a/src/config/env.js b/src/config/env.js index bc4d633..fec088e 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -21,4 +21,13 @@ export function ensureEnvVars(vars = requiredEnvVars) { vars.forEach(getRequiredEnv); } -export { requiredEnvVars }; +const requiredWebEnvVars = ["GITHUB_TOKEN", "REPO_DEV"]; + +export function ensureWebEnvVars(env = process.env) { + const missing = requiredWebEnvVars.filter(name => !env[name]?.trim()); + if (missing.length) { + throw new Error(`Missing required environment variables for webserver: ${missing.join(", ")}`); + } +} + +export { requiredEnvVars, requiredWebEnvVars }; diff --git a/src/web/report-app.js b/src/web/report-app.js new file mode 100644 index 0000000..d486ed1 --- /dev/null +++ b/src/web/report-app.js @@ -0,0 +1,182 @@ +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 }; +const providerRateMessage = "GitHub is temporarily limiting reports. Your report was not created. Keep your draft and wait before retrying."; +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. + 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 != null) 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 : ["report_capacity", "provider_rate_limit"].includes(admission.code) ? 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 safeWait(seconds, time) { + const wait = Math.ceil(seconds); + return Number.isSafeInteger(wait) && wait >= 0 && Number.isFinite(new Date(time + wait * 1000).getTime()) ? wait : null; +} + +function providerWait(headers, time) { + const retryHeader = headers["retry-after"]; + const retry = Number(retryHeader); + const retrySeconds = retryHeader == null || String(retryHeader).trim() === "" ? null + : safeWait(Number.isFinite(retry) ? retry : Math.max(0, (Date.parse(retryHeader) - time) / 1000), time); + const resetHeader = headers["x-ratelimit-reset"]; + const resetSeconds = String(headers["x-ratelimit-remaining"]) === "0" && resetHeader != null && String(resetHeader).trim() !== "" + ? safeWait(Math.max(0, (Number(resetHeader) * 1000 - time) / 1000), time) : null; + const waits = [retrySeconds, resetSeconds].filter(Number.isFinite); + return waits.length ? Math.max(...waits) : 60; +} + +function deliveryError(err, time) { + const status = err?.response?.status; + const headers = err?.response?.headers ?? {}; + 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: providerRateMessage, 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 }; + } + 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" }; + // One shared token serves both routes and every client in this process. + let providerBlockedUntil = 0; + function withProviderWait(admission, time) { + if (time >= providerBlockedUntil) return admission; + const wait = { retryAfterSeconds: Math.ceil((providerBlockedUntil - time) / 1000), resetAt: new Date(providerBlockedUntil).toISOString() }; + if (admission.ok === false && Date.parse(admission.resetAt) >= providerBlockedUntil) return admission; + if (admission.code === "delivery_uncertain") return { ...admission, ...wait }; + return { ok: false, code: "provider_rate_limit", message: providerRateMessage, ...wait }; + } + // 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, withProviderWait(admission, now())); + 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."); + } + const company = req.body?.company; + if (company != null && typeof company !== "string") { + return sendError(res, 400, "invalid_report", "company must be text."); + } + if (company?.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 reservation = policy.reserve(clientKey(req.ip), fingerprint); + const admission = withProviderWait(reservation, now()); + if (!admission.ok) { + // No asynchronous work occurs before releasing this temporary reservation. + // Inspecting the local denial first preserves known uncertain-report warnings. + if (reservation.ok) reservation.settle("failed"); + return sendAdmissionError(res, admission); + } + try { + // Temporary shared destination: the overlay is private. Ignore any + // legacy REPO_DATA_REPORT setting, but retain data-specific formatting. + const repo = 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, now()); + if (failure.code === "provider_rate_limit") { + providerBlockedUntil = Math.max(providerBlockedUntil, Date.parse(failure.resetAt)); + } + 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 != null ? { retryAfterSeconds: failure.retryAfterSeconds } : {}; + if (failure.resetAt) details.resetAt = failure.resetAt; + 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); + 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."); + }); + return app; +} diff --git a/src/web/report-policy.js b/src/web/report-policy.js new file mode 100644 index 0000000..3f43ee9 --- /dev/null +++ b/src/web/report-policy.js @@ -0,0 +1,110 @@ +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, interval) { + if (time - lastSweep < interval) 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) { + // Reclaim expired entries promptly at capacity, but never evict an active + // attempt budget, delivered allowance, pending reservation or uncertain hold. + sweep(time, clients.size >= maxClients ? 1000 : 60_000); + 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) { + 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 pendingEnd = state.pending ? time + 10_000 : time; + const reset = Math.max(cooldownEnd, windowEnd, pendingEnd); + 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, reset), time); + } + if (state.events.length >= REPORT_LIMIT) { + 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 (state.pending) { + return denied("report_in_progress", "A report is already being submitted from this connection. Wait for its result.", 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.", reset, 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); + 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. + return withAttemptWait(reportingBlock ?? (exhausted ? attemptBlock(state, time) : null), state, time) ?? { ok: true }; + } + function attemptBlock(state, time) { + if (state.attempts.length < ATTEMPT_LIMIT) return null; + return denied("attempt_limit", "Too many submission attempts. Keep your draft and wait one minute.", state.attempts[0] + ATTEMPT_WINDOW_MS, time); + } + // Use the same calculation before and after body parsing. Never charge an + // additional attempt here or turn an otherwise eligible reservation away. + function withAttemptWait(rejection, state, time) { + if (!rejection) return null; + const attempts = attemptBlock(state, time); + if (!attempts || Date.parse(attempts.resetAt) <= Date.parse(rejection.resetAt)) return rejection; + // Keep the check-before-repeating warning even when another wait is longer. + if (rejection.code === "delivery_uncertain") { + return { ...rejection, retryAfterSeconds: attempts.retryAfterSeconds, resetAt: attempts.resetAt }; + } + return attempts; + } + 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 withAttemptWait(rejection, state, time); + 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/build.test.js b/test/build.test.js new file mode 100644 index 0000000..5a44ce7 --- /dev/null +++ b/test/build.test.js @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +async function fixture(t) { + const directory = await mkdtemp(path.join(tmpdir(), "trackerbot-build-test-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")); + await writeFile(path.join(directory, "package.json"), JSON.stringify({ type: "module", scripts: { build: manifest.scripts.build } })); + await mkdir(path.join(directory, "scripts")); + await writeFile(path.join(directory, "scripts/check-syntax.js"), await readFile(new URL("../scripts/check-syntax.js", import.meta.url), "utf8")); + await writeFile(path.join(directory, "bot.js"), "export const bot = true;\n"); + await writeFile(path.join(directory, "webserver.js"), "export const web = true;\n"); + return { directory, build() { + return spawnSync("npm", ["run", "build", "--silent"], { cwd: directory, encoding: "utf8", timeout: 10000 }); + } }; +} + +test("the hosting build command checks valid source without starting services", async t => { + const api = await fixture(t); + await mkdir(path.join(api.directory, "src/web"), { recursive: true }); + await writeFile(path.join(api.directory, "src/web/example.js"), "throw new Error('must not execute');\n"); + const result = api.build(); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Syntax checked 4 JavaScript files/); +}); + +test("the build fails on syntax errors in nested source files", async t => { + const api = await fixture(t); + await mkdir(path.join(api.directory, "src/web"), { recursive: true }); + await writeFile(path.join(api.directory, "src/web/broken.js"), "export const = ;\n"); + const result = api.build(); + assert.ifError(result.error); + assert.equal(result.status, 1); + assert.match(result.stderr, /Syntax check failed: src\/web\/broken.js/); +}); + +test("the build ignores installed dependencies and Git metadata", async t => { + const api = await fixture(t); + for (const ignored of ["node_modules", ".git"]) { + await mkdir(path.join(api.directory, ignored)); + await writeFile(path.join(api.directory, ignored, "broken.js"), "export const = ;\n"); + } + const result = api.build(); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Syntax checked 3 JavaScript files/); +}); + +test("the service launcher remains available in production-only installs", async () => { + const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")); + assert.match(manifest.scripts.start, /^concurrently\s/); + assert.ok(manifest.dependencies?.concurrently, "npm start must not rely on a dev-only launcher"); +}); diff --git a/test/env.test.js b/test/env.test.js index e295928..a0c2761 100644 --- a/test/env.test.js +++ b/test/env.test.js @@ -53,6 +53,21 @@ test("ensureEnvVars passes when all vars are set", async () => { assert.doesNotThrow(() => ensureEnvVars()); }); +test("web intake requires only the shared website destination and token", async () => { + const { ensureWebEnvVars, requiredWebEnvVars } = await importEnv(); + assert.deepEqual(requiredWebEnvVars, ["GITHUB_TOKEN", "REPO_DEV"]); + assert.doesNotThrow(() => ensureWebEnvVars({ GITHUB_TOKEN: "test-only", REPO_DEV: "tarkovtracker-org/TarkovTracker" })); +}); + +for (const name of ["GITHUB_TOKEN", "REPO_DEV"]) { + for (const value of [undefined, "", " "]) { + test(`web intake rejects missing or blank ${name}: ${JSON.stringify(value)}`, async () => { + const { ensureWebEnvVars } = await importEnv(); + assert.throws(() => ensureWebEnvVars({ GITHUB_TOKEN: "test-only", REPO_DEV: "example/dev", [name]: value }), new RegExp(`Missing required environment variables for webserver: ${name}`)); + }); + } +} + test("ensureEnvVars throws when a required var is missing", async () => { delete process.env.DISCORD_TOKEN; const { ensureEnvVars } = await importEnv(); diff --git a/test/ip-security.test.js b/test/ip-security.test.js new file mode 100644 index 0000000..46ab29d --- /dev/null +++ b/test/ip-security.test.js @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +import { ipKeyGenerator } from "express-rate-limit"; + +// Exercise the limiter's actual dependency, including a nested installation. +const limiterRequire = createRequire(createRequire(import.meta.url).resolve("express-rate-limit")); +const { Address6 } = limiterRequire("ip-address"); + +// Run potentially blocking validation outside the test runner. The vulnerable +// version takes many seconds on this input; the patch rejects it in milliseconds. +test("oversized IPv6 zones with line terminators are rejected without stalling", () => { + const result = spawnSync(process.execPath, ["-e", ` + const assert = require('node:assert/strict'); + const { Address6 } = require(${JSON.stringify(limiterRequire.resolve("ip-address"))}); + for (const terminator of ['\\n', '\\r', '\\u2028', '\\u2029']) { + assert.equal(Address6.isValid('%'.repeat(200000) + terminator), false); + } + console.log('rejected all oversized zones'); + `], { cwd: fileURLToPath(new URL("../", import.meta.url)), encoding: "utf8", timeout: 2000 }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.signal, null); + assert.match(result.stdout, /rejected all oversized zones/); +}); + +test("IPv6 zone parsing and existing limiter address grouping remain compatible", () => { + assert.equal(new Address6("fe80::1%eth0").zone, "%eth0"); + assert.equal(ipKeyGenerator("::ffff:192.0.2.1"), "192.0.2.1"); + assert.equal(ipKeyGenerator("2001:db8:abcd:1200::1"), ipKeyGenerator("2001:db8:abcd:12ff::2")); +}); diff --git a/test/redirect-security.test.js b/test/redirect-security.test.js new file mode 100644 index 0000000..c9bc64d --- /dev/null +++ b/test/redirect-security.test.js @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { once } from "node:events"; +import { createRequire } from "node:module"; + +// Exercise Axios's actual dependency, even when it is nested rather than hoisted. +const axiosRequire = createRequire(createRequire(import.meta.url).resolve("axios")); +const followRedirects = axiosRequire("follow-redirects"); + +async function redirectedHeaders(t, headers, sensitiveHeaders = []) { + let received; + const target = http.createServer((req, res) => { + received = req.headers; + res.end("ok"); + }); + target.listen(0, "127.0.0.1"); + await once(target, "listening"); + const origin = http.createServer((req, res) => { + req.resume(); + res.writeHead(302, { Location: `http://127.0.0.1:${target.address().port}/` }); + res.end(); + }); + origin.listen(0, "127.0.0.1"); + await once(origin, "listening"); + t.after(async () => { + for (const server of [origin, target]) { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + } + }); + if (Array.isArray(headers)) { + headers = ["Host", `127.0.0.1:${origin.address().port}`, ...headers]; + } + await new Promise((resolve, reject) => { + const request = followRedirects.http.get({ + protocol: "http:", hostname: "127.0.0.1", port: origin.address().port, + path: "/", headers, sensitiveHeaders + }, response => { + response.resume(); + response.on("end", resolve); + response.on("error", reject); + }); + request.on("error", reject); + }); + assert.ok(received, "the redirected target must receive a request"); + return received; +} + +test("redirects strip array-form credentials across origins", { timeout: 5000 }, async t => { + const headers = await redirectedHeaders(t, ["Authorization", "test-marker", "Cookie", "test-cookie", "Proxy-Authorization", "test-proxy"]); + for (const name of ["authorization", "cookie", "proxy-authorization"]) { + assert.equal(headers[name], undefined, `${name} must not cross origins`); + } +}); + +test("redirects strip sensitive header names containing regex metacharacters", { timeout: 5000 }, async t => { + const headers = await redirectedHeaders(t, { + "X-Api^Key": "test-marker", "X-Token|Secret": "test-marker", "X-Control": "preserved" + }, ["X-Api^Key", "X-Token|Secret"]); + assert.equal(headers["x-api^key"], undefined); + assert.equal(headers["x-token|secret"], undefined); + assert.equal(headers["x-control"], "preserved"); +}); diff --git a/test/report-app.test.js b/test/report-app.test.js new file mode 100644 index 0000000..7123796 --- /dev/null +++ b/test/report-app.test.js @@ -0,0 +1,446 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { request } from "node:http"; +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 }), env = {}) { + let time = 0; + const calls = []; + const logs = []; + const app = createReportApp({ + env: { GITHUB_TOKEN: "test-only-key", REPO_DEV: "example/dev", REPO_DATA_REPORT: "example/private-overlay", ALLOWED_ORIGINS: "https://example.test", ...env }, + 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, server, base, 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() }; + } + }; +} + +function stagedUpload(api, report = fields) { + const body = JSON.stringify(report); + let upload; + const result = new Promise((resolve, reject) => { + upload = request(`${api.base}/data`, { method: "POST", headers: { + "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), Origin: "https://example.test" + } }, response => { + let text = ""; + response.setEncoding("utf8"); + response.on("data", chunk => { text += chunk; }); + response.on("end", () => { + try { resolve({ status: response.statusCode, headers: response.headers, body: JSON.parse(text) }); } catch (error) { reject(error); } + }); + }); + upload.on("error", reject); + upload.flushHeaders(); + }); + result.catch(() => {}); // Teardown may close an upload after an assertion fails. + return { result, complete() { upload.end(body); } }; +} + +test("both routes share delivered-report policy and route to the website repository", 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/dev/issues"); + assert.equal(api.calls[1][0], "https://api.github.com/repos/example/dev/issues"); + assert.equal(api.calls[0][1].title, "[Other] Test report"); + assert.match(api.calls[0][1].body, /\*\*Category:\*\* Other/); +}); + +for (const route of ["data", "issue", "DATA/", "ISSUE/"]) { + test(`${route} delivers without a private-overlay setting`, async t => { + const api = await fixture(t, undefined, { REPO_DATA_REPORT: undefined }); + assert.equal((await api.post(route)).status, 200); + assert.equal(api.calls[0][0], "https://api.github.com/repos/example/dev/issues"); + }); +} + +for (const route of ["data", "issue"]) { + for (const company of [{ toString: null }, {}, [], 0, false]) { + test(`${route}: non-text honeypot ${JSON.stringify(company)} is rejected before delivery`, async t => { + const api = await fixture(t); + const result = await api.post(route, { ...fields, company }); + assert.equal(result.status, 400); + assert.equal(result.body.error.code, "invalid_report"); + assert.equal(result.body.error.message, "company must be text."); + assert.equal(api.calls.length, 0); + assert.equal(api.logs.length, 0); + assert.equal((await api.post(route)).status, 200); + }); + } +} + +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; + if (status === 429 || status === 403) api.setTime(120000); + 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 data formatting and the shared 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/dev/issues"); + assert.equal(api.calls[0][1].title, "[Other] Test report"); + 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); +}); + +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: "explicit zero Retry-After", status: 429, headers: { "retry-after": "0" }, seconds: 0 }, + { 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/); + }); +} + +test("provider throttling blocks every client and both routes until the exact reset", async t => { + let fail = true; + const api = await fixture(t, async () => { + if (fail) throw { response: { status: 429, headers: { "retry-after": "120" } } }; + return { status: 201 }; + }); + assert.equal((await api.post()).body.error.code, "provider_rate_limit"); + fail = false; + for (const route of ["data", "issue", "DATA/", "ISSUE/"]) { + const result = await api.post(route, fields, { "X-Forwarded-For": `192.0.2.${route.length}` }); + assert.equal(result.status, 503); + assert.equal(result.body.error.code, "provider_rate_limit"); + assert.equal(result.headers.get("Retry-After"), "120"); + } + assert.equal(api.calls.length, 1); + api.setTime(119999); + assert.equal((await api.post()).headers.get("Retry-After"), "1"); + assert.equal(api.calls.length, 1); + api.setTime(120000); + assert.equal((await api.post()).status, 200); + assert.equal(api.calls.length, 2); +}); + +test("zero provider wait permits an immediate retry without consuming report allowance", async t => { + let fail = true; + const api = await fixture(t, async () => { + if (fail) throw { response: { status: 429, headers: { "retry-after": "0" } } }; + return { status: 201 }; + }); + assert.equal((await api.post()).body.retryAfterSeconds, 0); + fail = false; + assert.equal((await api.post()).status, 200); +}); + +test("provider waits combine with local admission waits and retain uncertainty warnings", async t => { + const api = await fixture(t, async (_url, issue) => { + if (issue.title.includes("uncertain")) throw { code: "ECONNABORTED" }; + throw { response: { status: 429, headers: { "retry-after": "50000" } } }; + }); + const key = { "X-Forwarded-For": "192.0.2.1" }; + await api.post("data", { ...fields, title: "uncertain" }, key); + await api.post("issue", fields, { "X-Forwarded-For": "192.0.2.2" }); + const cooldown = await api.post("data", fields, key); + assert.equal(cooldown.body.error.code, "provider_rate_limit"); + assert.equal(cooldown.body.retryAfterSeconds, 50000); + api.setTime(600000); + const repeated = await api.post("data", { ...fields, title: "uncertain" }, key); + assert.equal(repeated.body.error.code, "delivery_uncertain"); + assert.equal(repeated.body.retryAfterSeconds, 49400); + assert.match(repeated.body.error.message, /could not be confirmed/); + assert.equal(api.calls.length, 2); +}); + +test("a staged body admitted before provider throttling does not deliver during its hold", { timeout: 10000 }, async t => { + const api = await fixture(t, async () => { + throw { response: { status: 429, headers: { "retry-after": "120" } } }; + }); + const admitted = once(api.server, "request"); + const upload = stagedUpload(api); + await admitted; + await api.post("issue", fields, { "X-Forwarded-For": "192.0.2.3" }); + upload.complete(); + const result = await upload.result; + assert.equal(result.status, 503); + assert.equal(result.body.error.code, "provider_rate_limit"); + assert.equal(result.headers["retry-after"], "120"); + assert.equal(api.calls.length, 1); +}); + +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); +}); + +test("staged uploads combine reservation and attempt waits after body parsing", { timeout: 10000 }, async t => { + let rejectFirst; + let started; + const providerStarted = new Promise(resolve => { started = resolve; }); + let deliveries = 0; + const api = await fixture(t, () => { + if (++deliveries > 1) return Promise.resolve({ status: 201 }); + started(); + return new Promise((resolve, reject) => { rejectFirst = reject; }); + }); + const admitted = new Promise(resolve => { + let headersSeen = 0; + // Express handles each request synchronously through admission, then waits + // for its body. No body completes before all five request events arrive. + api.server.on("request", () => { if (++headersSeen === 5) resolve(); }); + }); + const uploads = Array.from({ length: 5 }, () => stagedUpload(api)); + await admitted; + assert.equal(api.calls.length, 0); + uploads[0].complete(); + await providerStarted; + for (const upload of uploads.slice(1)) upload.complete(); + const denials = await Promise.all(uploads.slice(1).map(upload => upload.result)); + for (const denial of denials) { + assert.equal(denial.headers["retry-after"], "60"); + assert.equal(Date.parse(denial.body.resetAt), 60000); + } + assert.equal(api.calls.length, 1); + rejectFirst({ response: { status: 422 } }); + assert.equal((await uploads[0].result).status, 502); + api.setTime(10000); + assert.equal((await api.post()).headers.get("Retry-After"), "50"); + api.setTime(60000); + assert.equal((await api.post()).status, 200); +}); + +test("pending different delivery cannot hide a known uncertain-report hold", { timeout: 10000 }, async t => { + let rejectPending; + let started; + const pendingStarted = new Promise(resolve => { started = resolve; }); + let deliveries = 0; + const api = await fixture(t, () => { + if (++deliveries === 1) return Promise.reject({ code: "ECONNABORTED" }); + started(); + return new Promise((resolve, reject) => { rejectPending = reject; }); + }); + assert.equal((await api.post()).body.error.code, "delivery_uncertain"); + api.setTime(600000); + const admitted = new Promise(resolve => { + let headersSeen = 0; + api.server.on("request", () => { if (++headersSeen === 2) resolve(); }); + }); + const different = stagedUpload(api, { ...fields, title: "Different report" }); + const repeated = stagedUpload(api); + await admitted; + different.complete(); + await pendingStarted; + repeated.complete(); + const denied = await repeated.result; + assert.equal(denied.body.error.code, "delivery_uncertain"); + assert.equal(denied.headers["retry-after"], "42600"); + assert.equal(api.calls.length, 2); + rejectPending({ response: { status: 422 } }); + assert.equal((await different.result).status, 502); + api.setTime(610000); + assert.equal((await api.post()).headers.get("Retry-After"), "42590"); + assert.equal(api.calls.length, 2); +}); + +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 new file mode 100644 index 0000000..5b53f15 --- /dev/null +++ b/test/report-form.test.js @@ -0,0 +1,188 @@ +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 = "", 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 = { + 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 html.includes(`id="${id}"`) ? status : null; + } + 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+src="([^"]+)")?[^>]*>([\s\S]*?)<\/script\s*>/gi)) { + 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); + assert.equal(ui.status.className, "status pending"); + assert.equal(ui.status.textContent, "Submitting report..."); + const css = await readFile(new URL("../web/styles.css", import.meta.url), "utf8"); + const pendingStyle = css.match(/\.status\.pending\s*\{([^}]+)\}/)?.[1]; + assert.ok(pendingStyle, "pending has an explicit CSS rule"); + assert.match(pendingStyle, /display:\s*block/); + 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"]) { + 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"/); + assert.match(ui.html, /per client IP/); + assert.match(ui.html, /Your report, including your Discord handle, reference links and details, is published as a public GitHub issue\./); + }); +} + +test("data reports link to the public website issue list, not the private overlay", async () => { + const ui = await loadForm("data", async () => {}); + assert.match(ui.html, /href="https:\/\/github\.com\/tarkovtracker-org\/TarkovTracker\/issues"/); + assert.doesNotMatch(ui.html, /href="[^"]*tarkov-data-overlay/); +}); + +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}`); + 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"); +}); + +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("", "")); + await ui.submit(); + assert.match(ui.status.textContent, /Keep the draft/); + assert.equal(ui.form.resetCount, 0); +}); + +test("fixture ignores script-like tag names in HTML and JavaScript strings", 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 + .replace(" diff --git a/web/issue/index.html b/web/issue/index.html index d1f76cf..9ba7c6a 100644 --- a/web/issue/index.html +++ b/web/issue/index.html @@ -22,15 +22,18 @@

Report TarkovTracker App Issues

with steps to reproduce, expected result, and actual result.

+

Your report, including your Discord handle, reference links and details, is published as a public GitHub issue. Do not include passwords, tokens or other sensitive information.

+
- + - + - + +

Limits: title 200 characters, Discord handle 64, details 8,000. Both forms share five submission attempts per rolling minute, 20 reports per client IP in a rolling 12 hours, and at least 10 minutes between reports. Invalid submissions count as attempts. People sharing an IP address or IPv6 prefix share these limits.

-
+
+

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

@@ -55,37 +59,12 @@

Report TarkovTracker App Issues

+ diff --git a/web/report-form.js b/web/report-form.js new file mode 100644 index 0000000..ca728fc --- /dev/null +++ b/web/report-form.js @@ -0,0 +1,83 @@ +// 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", "unsupported_request", "report_too_large", + "forbidden_origin", "provider_rate_limit", "delivery_rejected", + "delivery_unavailable", "delivery_uncertain", "internal_error" + ]); + function showStatus(message, isError = false, pending = false) { + statusBox.textContent = message; + statusBox.className = pending ? "status pending" : 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")); + 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); + } + 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...", false, true); + 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/web/styles.css b/web/styles.css index c5999f7..e0b82e1 100644 --- a/web/styles.css +++ b/web/styles.css @@ -202,6 +202,13 @@ button.btn-solid:hover { display: none; } +.status.pending { + display: block; + background: var(--panel-2); + border: 1px solid var(--border-strong); + color: var(--muted); +} + .status.success { display: block; background: rgba(141, 168, 86, 0.12); diff --git a/webserver.js b/webserver.js index 8eda171..ed9029f 100755 --- a/webserver.js +++ b/webserver.js @@ -1,221 +1,8 @@ -// -// 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"; +import { ensureWebEnvVars } from "./src/config/env.js"; -dotenv.config(); +ensureWebEnvVars(); -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()); -} - -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}`));