amuleweb hardening: address #869 #870 #871 #872 #873 #874 - #875
Conversation
…t#874) WebServer.h's `SESSION_TIMEOUT_SECS 300` macro has been defined for years but never referenced -- CScriptWebServer::CheckLoggedin tests the idle window against a hardcoded `7200` (2 hours) instead. The macro's "5 minutes" documentation has been wrong about live behaviour for as long as it has existed. Keep the live behaviour (2 hours -- changing it silently would be a session-lifetime regression for every existing user) and route the hardcoded `7200` through the macro so the timeout can be edited in one place. Closes amule-project#874
…le-project#873) The OnReceive() loop reads up to `m_dwBufSize` bytes into `m_pBuf` and unconditionally writes a NUL at `m_pBuf[m_dwRecv]` to terminate the buffer for the request-parsing strstr() calls below. The grow loop is gated on three conditions: while (m_dwRecv == m_dwBufSize && read != 0 && !LastError()) If a Read() exactly fills the buffer AND LastError() is set immediately afterwards (e.g. peer sent 4096 bytes then reset the connection), the loop exits without growing -- but m_dwRecv == m_dwBufSize, so the terminator write at line 85 lands one byte past the allocation. Heap corruption, narrow trigger window, reachable from network input. Fix is the smallest possible thing: allocate `m_dwBufSize + 1` in both the constructor's initial allocation and in the grow path's `new char[newsize + 1]`. The usable-span tracking via m_dwBufSize is unchanged (still the value passed to Read()), so the loop's `m_dwBufSize - m_dwRecv` arithmetic stays correct and the terminator slot at index m_dwBufSize is always within the allocation. Closes amule-project#873
…roject#869) amuleweb-main-search.php:184 echoed the user-supplied `sort` query parameter directly into an `<a href=...?search_sort=$X>` attribute with no escaping. An attacker who can get a user to click a crafted URL or load a malicious form action gets reflected XSS in the authenticated amuleweb session -- which, given that amuleweb is the remote-control surface for amuled, means session theft + remote daemon control as the logged-in admin. This template is the only direct-echo XSS site in the webserver templates (`grep -r 'echo(\$HTTP_GET_VARS' src/webserver/`); the rest of the user-supplied surface either lives in C++ where it goes through `_SpecialChars()` first, or is consumed only by amuleweb's PHP-side handlers rather than reflected to the browser. Whitelist `sort` against the three column keys `my_cmp()` actually understands (line 234-236 of this same file: "size", "name", "sources"). Anything else falls through to empty -- which produces the "use the session's last sort" branch that the rest of the template already handles for missing-param case. Belt-and-braces: `htmlspecialchars()` the surviving value with `ENT_QUOTES` in case the whitelist later grows a key that needs HTML escaping. No PHP linter available locally; verified by reviewing the diff against `my_cmp()`'s switch arms and confirming the three valid values exactly match. Closes amule-project#869
) CScriptWebServer::CheckLoggedin was generating session IDs with C `rand()` into an `int`, looping on collision. `rand()` is not a CSPRNG, isn't seeded with anything an attacker can't observe, and yields at most RAND_MAX (~2^31) candidate values -- making session IDs guessable in modest time without ever stealing the cookie. The pattern is the classic "fake session-cookie until you hit a live one and inherit that user's authenticated context" attack on an admin surface. Replace with `CryptoPP::AutoSeededRandomPool::GenerateBlock` into a `uint64_t`: - 64 bits of entropy is enough that brute-force is no longer a realistic attack at amuleweb scale (web request rate, server CPU cost per attempt). Going wider (128-bit opaque hex) would also work but pulls in cookie-format and storage-type changes for marginal improvement. - AutoSeededRandomPool is the same primitive the EC stack already uses for DH key agreement (see EncryptedStreamSocket.cpp), so this doesn't drag in a new crypto dependency. - Reuse the pool across calls via a function-local static so the OS-RNG seeding cost is paid once per process, not once per login. Type ripple (int -> uint64_t): - `ThreadData::SessionID` (WebServer.h) - `std::map<int, CSession> m_sessions` -> `std::map<uint64_t, CSession>` - `CWebSocket::SendHttpHeaders` signature + Set-Cookie format (`%d` -> `%llu` with explicit unsigned-long-long cast for the windows builds where uint64_t != unsigned long long) - `int sessid = 0; ... sessid = atoi(...)` -> `uint64_t sessid = 0; ... sessid = strtoull(..., 10)` in WebSocket cookie-parse Closes amule-project#870
…-project#871) CWebSocket::SendHttpHeaders emitted the session cookie with no security attributes whatsoever: Set-Cookie: amuleweb_session_id=<NN> Two consequences with practical attack value: - Any cross-origin script that gets loaded into a page the user is visiting can read `document.cookie` and exfiltrate the session token. Combined with the reflected XSS in amule-project#869 (or any future one), this is "click a malicious link -> attacker has your amuleweb session". `HttpOnly` removes that read path entirely -- the cookie still rides on requests the browser sends to amuleweb, it just stops being visible to JavaScript running in the page. - The browser attaches this cookie to any cross-site request hitting amuleweb. An attacker page that fires a fetch / form-submit to `http://localhost:4711/login.php?...` rides on the victim's authenticated session. `SameSite=Strict` tells the browser to refuse the cookie attachment for cross-site requests, closing the CSRF lane. `Secure` is NOT set. amuleweb has no native TLS and doesn't know whether it's behind a TLS-terminating proxy; setting it unconditionally would lock out every direct-HTTP user (the browser refuses to send the cookie, login becomes impossible). Wiring it to a preference (e.g. `[WebServer] CookieSecure=1`) is a clean follow-up but out of scope for this hardening pass. Layered on top of amule-project#870's `uint64_t` session ID so the cookie format string is the only thing that changes here. Closes amule-project#871
…t#872) amuleweb's login handler reads the password via `Data.parsedURL.Param("pass")`. The wrinkle is the POST-body merge in CWebSocket::OnRequestReceived: for `POST` requests the body is concatenated onto the URL string and then handed to CParsedUrl, which makes the parsed-URL map agnostic about which params came from the URL query vs which came from the body (the comment at WebSocket.cpp: 197-208 calls out the deliberate behaviour, and links back to amule-project#724 where the fix was originally introduced). The side-effect: a `pass` query parameter in the original URL gets into the parsed-URL map and is accepted as the login password. Two concrete attacks that uses: - GET-with-pass click: attacker sends the victim `https://amule-host/login.php?pass=XYZ`. Browser sends a GET, the URL query is the only source, `pass=XYZ` reaches the login handler. - POST-with-pass-in-URL form: attacker hosts a page with `<form action="https://amule-host/login.php?pass=XYZ" method="POST">`. Victim clicks, browser POSTs, the POST body might be empty but the URL still carries `?pass=XYZ` -> same outcome. Passwords in URLs also leak into proxy logs, browser history, and `Referer` headers, so this isn't only an attacker-controlled-link problem. Fix is the Option B from the design discussion: capture the *pre-merge* URL on the wire and stash it alongside the merged one in `ThreadData::getOnlyParsedURL`. In the login handler, before reading `Data.parsedURL.Param("pass")`, check whether `pass` is reachable from the pre-merge URL -- if it is, refuse to consume it. The merged map stays the source of truth for every non-credential parameter, so the amule-project#724 POST-on-query-URL fix isn't disturbed. Side-effects across the rest of the code: - `ThreadData` gains a `CParsedUrl getOnlyParsedURL` field; the one brace-init call site in WebSocket.cpp:231 grows from 4 fields to 5. No allocation cost on the hot path: CParsedUrl is just two wxStrings + a map<wxString,wxString>, identical to the existing parsedURL field. - For GET requests `getOnlyParsedURL` parses the same string as `parsedURL`, so the only `pass` check fires identically in either case. For POST requests with `pass` only in the body, the pre-merge URL has no `pass` entry and the password is read normally -- the intended login flow keeps working. Closes amule-project#872
Caught while executing PR amule-project#875's test plan on the Ubuntu VM. My original amule-project#869 fix at 5030691 used three constructs the bundled amuleweb PHP interpreter doesn't actually support: - `[...]` short array syntax (the interpreter only accepts `array(...)`). - `in_array(...)` -- not a registered native function. Grep of `src/webserver/src/php_core_lib.cpp` shows the entire builtin set is `var_dump / strlen / count / isset / usort / split` plus the gettext family. Calling an unregistered function path-crashes `php_execute` at php_syntree.cpp:1819 on the first request that reaches the search template. - `htmlspecialchars(...)` -- same: not registered, same crash. Reproduction: log in, render any template -- amuleweb segfaults inside php_execute. Backtrace points at the search template's PHP block. Captured in the test session under PR amule-project#875. Rewrite the whitelist as a plain `==` chain. The three valid sort keys (`size`, `name`, `sources`) are static alphanumeric column names; no escaping is needed because non-whitelist values simply don't get echoed at all -- the only escape-sensitive scenario would have been a whitelisted value containing HTML-special characters, which is impossible by construction. Net effect on the XSS surface is identical: attacker-supplied `sort` payloads never reach the rendered HTML. Re-verified after this change: the crash is gone, sort=size / sort=name / sort=sources all set the correct anchor href, payload sort values fall through to empty. Refs amule-project#869 amule-project#875
Do you mean the External Connections (EC) protocol uses Diffie-Hellman key agreement? Last time I checked, the packets were sent as plaintext and could be easily read with wireshark, but maybe I'm missing something. |
|
You're right — sloppy phrasing on my side. EC is plaintext (MD5 challenge-response auth, no DH); |
|
Thanks, i just wanted to make sure i was not missing that feature. |
…ule-project#912) Extends existing categories (preferring extensions over new lines): - Performance/Upload: amule-project#898 SlotAllocation default raised. - Networking & Discovery: wire-parser hardening list extended with amule-project#879/amule-project#882/amule-project#890/amule-project#886; new amuleweb security hardening bullet consolidating ngosang's amule-project#869-amule-project#874 triage (all landed in amule-project#875); amulegui list extended with amule-project#857; shared-folder watcher extended with amule-project#858. - Packaging: Windows installer i18n line extended with amule-project#899. - Internals & Refactoring: new docs-polish + code-quality bullets covering amule-project#851/amule-project#855/amule-project#862/amule-project#888/amule-project#900/amule-project#866/amule-project#867/amule-project#895 and amule-project#909/amule-project#910/amule-project#912. - Translations: new pre-release final-wave bullet covering amule-project#847/amule-project#856/ amule-project#891/amule-project#908/amule-project#860/amule-project#904/amule-project#859/amule-project#863/amule-project#861/amule-project#880/amule-project#911/amule-project#901/amule-project#902/amule-project#889/amule-project#868/amule-project#853. - Bug Fixes & Stability: amule-project#850/amule-project#854/amule-project#878/amule-project#906. - CI: ccache wiring (amule-project#892, amule-project#903) + CodeQL binutils-dev (amule-project#907). Contributors footer gains mifritscher and nguyenhoangminhhieu2004-gif (both first-time contributors). PR index extended through amule-project#912.
Extends existing categories (preferring extensions over new lines): - Performance/Upload: #898 SlotAllocation default raised. - Networking & Discovery: wire-parser hardening list extended with #879/#882/#890/#886; new amuleweb security hardening bullet consolidating ngosang's #869-#874 triage (all landed in #875); amulegui list extended with #857; shared-folder watcher extended with #858. - Packaging: Windows installer i18n line extended with #899. - Internals & Refactoring: new docs-polish + code-quality bullets covering #851/#855/#862/#888/#900/#866/#867/#895 and #909/#910/#912. - Translations: new pre-release final-wave bullet covering #847/#856/ #891/#908/#860/#904/#859/#863/#861/#880/#911/#901/#902/#889/#868/#853. - Bug Fixes & Stability: #850/#854/#878/#906. - CI: ccache wiring (#892, #903) + CodeQL binutils-dev (#907). Contributors footer gains mifritscher and nguyenhoangminhhieu2004-gif (both first-time contributors). PR index extended through #912.
Summary
Address ngosang's six-issue triage of
amuleweb's security + memory-safety surface (#869 #870 #871 #872 #873 #874) in one PR, one commit per issue, in dependency-respecting order. Total ~70 LOC across 4 files.Verified before commit that all six issues filed today by @ngosang are within scope (
gh issue list --author ngosang --search "created:>=2026-06-05"returns exactly #869-#874). No additional security issues filed on the repo are open.Commits
67279daa3SESSION_TIMEOUT_SECSmacro to the live 2-hour timeout that the code uses (was a hardcoded7200next to an unused300). Keeps the live behaviour.WebServer.{h,cpp}d08d14f28m_dwBufSize + 1bytes for the read buffer so the unconditional NUL terminator at the end ofOnReceive()never falls past the allocation. The narrow trigger window — firstRead()exactly fills the buffer ANDLastError()is set, skipping the grow loop — is reachable from network input.WebSocket.cpp503069128sortquery parameter against the column keysmy_cmp()actually understands (size/name/sources); falls through to empty otherwise. Defence-in-depthhtmlspecialcharsfor the surviving value. Only direct-echo XSS site in the templates.amuleweb-main-search.phpa6b5f1fecrand()-into-intsession ID generator with a 64-bit token sourced fromCryptoPP::AutoSeededRandomPool(same CSPRNG the EC stack uses for DH key agreement). Type ripples frominttouint64_tthroughThreadData::SessionID, them_sessionsmap, the cookie format, and the cookie parse. Pool reused across calls so OS-RNG seeding cost is paid once per process.WebServer.{h,cpp},WebSocket.{h,cpp}bb8cca0afHttpOnly; SameSite=Strictto theSet-Cookieline.Securedeliberately not set — amuleweb has no native TLS and no signal about a fronting proxy; setting it unconditionally would lock out direct-HTTP users. Wiring it to a preference is a clean follow-up.WebSocket.cpp6908ee9f2CParsedUrlof the pre-POST-body-merge URL inThreadData::getOnlyParsedURL; the login handler refuses to consumepasswhen it's reachable via that pre-merge URL. Closes both the GET-with-pass click vector and the POST-with-pass-in-action-URL form vector while preserving #724's POST-on-query-URL fix.WebServer.{h,cpp},WebSocket.{h,cpp}807456035[...]short array syntax +in_array()+htmlspecialchars(), none of which are registered native functions insrc/webserver/src/php_core_lib.cpp; caught during test-plan execution on the Ubuntu VM by an amuleweb crash insidephp_execute. New impl is a plain==chain over the three whitelisted values; no escaping needed because non-whitelist values aren't echoed.amuleweb-main-search.phpBuilt clean against
amulewebtarget on macOS at every commit (cmake --build build --target amulewebafter each edit). No new warnings introduced.Test plan
Manual smoke testing per fix, plus a regression pass across the parts of amuleweb that don't touch the modified surface but share request-parsing infrastructure. Executed on the Ubuntu ARM64 VM (wxBase 3.2.9 / Boost 1.90,
-DCMAKE_BUILD_TYPE=Debug) with amuled + amuleweb on the same host, fresh~/.aMule/(no carried-over session state).#874 —
SESSION_TIMEOUT_SECSwiringSESSION_TIMEOUT_SECSto60, rebuild, log in, wait 65 s — verify the same expiry behaviour fires on the shortened timeout. Revert before push. — Confirmed: same session cookie returned the login form (response body containsname="pass") after the 65 s wait, while an immediate refresh before the wait returned the index. Reverted to7200and rebuilt before resuming the rest of the test plan.7200no longer appears insrc/webserver/src/WebServer.cppandSESSION_TIMEOUT_SECSis referenced from exactly one call site.#873 — Off-by-one heap write in
OnReceiveamulewebwith-DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer" -DCMAKE_EXE_LINKER_FLAGS=-fsanitize=addressin a dedicatedbuild-asantree. Send a normal request (curl http://localhost:4711/). ASAN clean. — Verified: HTTP 200, no ASAN report.?q=...query). Verify ASAN does not flag a heap-buffer-overflow at them_pBuf[m_dwRecv] = '\0'site. — Verified: HTTP 200, no ASAN report.Cookie:line). Verify the grown buffer's terminator slot is also within the allocation (m_dwBufSize+1allocation check passes). — Verified with 5 KB and 8 KB cookies; HTTP 200, amuleweb alive (also re-verified under ASAN).new char[X]sites all carry the+1(ctor + grow path). No third allocation site missed.#869 — XSS in
sortquery parameterhttp://localhost:4711/amuleweb-main-search.php?sort=<script>alert('xss')</script>— verify no alert, view-source showssearch_sort=followed by empty (the whitelist rejected it). — Verified via curl + grep, payload dropped to empty.?sort="><img src=x onerror=alert(1)>— verify same: no alert, no attribute breakout. — Verified via curl + grep, payload dropped to empty.?sort=size,?sort=name,?sort=sources— verify each renders the link assearch_sort=size(etc.) and clicking it sorts the search results by that column (existing behaviour). — All three round-trip correctly.?sort=— verify the "use the session's last sort" branch still works (no PHP warning, no XSS). —search_sort=empty, amuleweb alive.src/webserver/default/for any otherecho($HTTP_GET_VARSpattern — verify the audit's "this is the only one" claim still holds after the fix.807456035, then re-verified all five scenarios above against the patched amuleweb.#870 — CSPRNG session IDs
amuleweb_session_idis a large value (e.g. a 17-19-digit decimal, ≥ 10^16) — not the small int rangerand()produced. — Verified viacurl -i: cookie value e.g.13779537612446685881(20 digits) and3358389562839079814(19 digits).5217712917746154254,905360975747546453,1142240790361661942,2867645926327101357,182291738831146652. No shared prefixes, no monotonic pattern.m_sessionsmap is empty after restart — same behaviour as master tip, just being explicit it didn't regress). — Verified: logged in, killed amuleweb, respawned, sent the same cookie → response body containsname="pass"(login form), no auto-auth.static, so it's seeded once peramulewebprocess, not per request.#871 —
HttpOnly+SameSite=Stricton the session cookieHttpOnlyflag column is ticked andSameSitereadsStrict. — Confirmed viacurl -iraw response: everySet-Cookiecarriesamuleweb_session_id=<NN>; HttpOnly; SameSite=Strict. Also confirmed manually in the browser cookie inspector.document.cookiedoes not includeamuleweb_session_id—HttpOnlyconfirmed. — Verified in browser.http://localhost:8001/csrf-mac.htmlon the Mac (distinct hostname from amuleweb's192.168.1.139:4711), with a logged-in browser session for amuleweb. Both (a) a top-level cross-site link click toamuleweb-main-dload.phpand (b) a cross-site form POST tologin.phplanded on the login form — the existing session cookie was withheld by the browser, exactly asSameSite=Strictmandates. (Side-note: an initial attempt used a second port on the same hostname as the test origin; that's same-site for cookie purposes — SameSite is computed on registrable hostname, not on origin/port — so it didn't exercise the rule. Re-running with distinct hostnames gave the expected result.)--cookie-jarround-trip through all 8 main panels.#872 — Refuse
passvia URL querycurl 'http://localhost:4711/login.php?pass=<correct-admin-password>'(GET) → verify the response is the login.php form (no session created with logged_in=true). The amuled stderr should showRefusing to read pass from URL query string. — Confirmed: HTTP 200 response is the login form; log lineRefusing to read \pass` from URL query stringthenYou did not enter any password. Blank password is not allowed.`curl -X POST 'http://localhost:4711/login.php?pass=<correct-admin-password>'with empty body → same result: login refused, log line fires. — Empty-body case hits the pre-existingClose()at WebSocket.cpp:135 (Content-Length:0 short-circuit unrelated to this PR), so the request is dropped beforeProcessURLruns. With a non-empty body (e.g.--data dummy=1), theRefusing to readpath fires as expected.?pass=on the URL → verify login succeeds as before. — Confirmed:Checking password→Password ok→ redirected to index.passquery param (regression test for Can't login in the webui after latest changes #724): verify the mergedparsedURLstill picks up both the URL query and the body fields. — Verified:POST /login.php?irrelevant=foowith bodypass=webtestsucceeds end-to-end (cookie issued, GET ofamuleweb-main-dload.phpwith that cookie returns the control panel, not the login form). Demonstrates that the Can't login in the webui after latest changes #724?-vs-&merge logic is preserved alongside the new Credentials accepted via GET and merged into the request URL #872getOnlyParsedURLcapture.?pass=produces exactly one. — Confirmed against amuleweb stdout.Regression sweep (across all six fixes together)
dload,shared,search,servers,stats,log,kad,prefs); HTTP 200 across the board, body sizes 2.1–3.5 KB, amuleweb alive throughout.mulecommon/mulesocketlink dependencies). — amuled stayed up and served amuleweb's EC requests for the duration of testing.m_sessionsmap is empty on amuleweb restart, same as today — calling this out so it doesn't get reported as a regression).Refs
Closes #869, #870, #871, #872, #873, #874.