Skip to content

fix(gameserver): percent-encode ServerDB URI query values (#41) - #109

Merged
thesprockee merged 4 commits into
mainfrom
fix/41-unescaped-serverdb-uri
Oct 6, 2026
Merged

thesprockee merged 4 commits into
mainfrom
fix/41-unescaped-serverdb-uri

Conversation

@thesprockee

@thesprockee thesprockee commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Closes gameserver: legacy ServerDB URI embeds discord_id and password unescaped #41: TWO raw-concatenation sites, not one — the legacy ServerDB URI (gameserver.cpp) and the websocket bridge's URL-credential path (ws_bridge.cpp:997-1004, same target endpoint, same nevr_discord_id/nevr_password config values, found by the Opus review of the first commit). Both built ?discord_id=%s&password=%s-style query strings via snprintf/string concatenation with no escaping.
  • A password containing a&guilds=999#tail injects its own guilds parameter and truncates the real one into the URL fragment (which ixwebsocket drops). Reproduced exactly against both sites with a hostile mutant value.
  • Fix: new src/runtime/server/serverdb_uri.{h,cpp} with pure functions (BuildLegacyUri, BuildTokenRouteUri, BuildBridgeCredentialUri) that RFC-3986-percent-encode every value via libcurl's curl_easy_escape. guilds/regions encoded element-by-element with literal commas preserved (server splits on comma). Bridge path keeps its existing all-or-nothing behavior (both discord_id and password, or neither).
  • New just-verify sensor forbids the old raw-concatenation patterns reappearing in either file, and requires both builder functions to actually be called at their respective sites.
  • Password is never logged on either path.

COMPATIBILITY RISK — needs your decision, not addressed in this PR

Nakama sets an account's stored password from this URL parameter on the FIRST login that supplies one (evr_pipeline_login.go:472-475, only when the profile has no password set yet). Before this PR, both paths sent the password unencoded. Of the special characters, only +, a valid %XX escape, and &/# truncation that still leaves 8+ bytes can produce a MANGLED value actually stored server-side (+ becomes a space, %XX is decoded, everything after &/# is cut off). A bare ; does NOT produce a stored mismatch — Go's parseQuery drops the whole pair, so the password was never set via this path at all (confirmed by independent re-review); same for an invalid % escape, or truncation short enough that nakama's own LinkEmail minimum-length check rejects it outright — those accounts never got a password stored this way and this PR lets them set one for the first time. Nakama also keeps only the first 32 bytes of the decoded password (session_ws.go:181), so mangling past byte 32 never caused a mismatch either. After this PR the client sends the correct, fully-encoded password, which won't match a genuinely-mangled stored value from the +/%XX/&/# cases above — those accounts would fail to authenticate. Passwords using only letters/digits/-._~ are unaffected. Can't tell how many accounts are affected without the production database.

Test plan

  • 15 tests in test_serverdb_uri.cpp (11 original + 4 for the bridge path): exact encoding, round-trip via libcurl's parser, password-can't-inject-a-parameter, bridge-and-ServerDB-paths-decode-identically for the same hostile value, server-accepted values unchanged, all-or-nothing credential behavior preserved
  • Red/green: reverting the encoder to a no-op fails the injection tests on both sites, reproducing the exact corrupted/truncated query string
  • just verify green (capped CMAKE_BUILD_PARALLEL_LEVEL=4), including after merging current main (5393834)
  • Independent Opus re-review of the bridge-path fix: PASS

Found, not fixed here

Not done

  • No live server run — covered by unit tests + just verify only, not an end-to-end registration/bridge-connect against the real game service.

🤖 Generated with Claude Code

RequestRegistration built the legacy ServerDB URI with
snprintf("%s?discord_id=%s&password=%s", ...), so a password containing
'&', '=', '#', '%', '+' or whitespace rewrote the query: '&guilds=999'
injected a parameter, '#' cut the rest of the query off as a fragment
(ixwebsocket drops it), '+' decoded to a space in Go's ParseQuery, and
values were silently truncated at a fixed 1024-byte buffer.

ServerDbUri::BuildLegacyUri / BuildTokenRouteUri now build both URIs.
Every value goes through libcurl's curl_easy_escape (RFC 3986: all bytes
but ALPHA DIGIT - . _ ~ become %XX). guilds/regions are encoded per
element with literal commas, because the server splits the decoded
value on ','. Values the server accepts (session_ws.go discordIDPattern,
regionPattern, guildPattern) are all unreserved, so the wire bytes of a
working config are unchanged. A base that already has a query gets '&'.
The fixed buffer and its four C-style casts are gone. The legacy debug
line logs password presence, never the value.

test_serverdb_uri (11 tests, wired into test-auth-unit) pins the exact
encoding and parses the result back with curl_url + split + unescape.
Mutation check: making EncodeQueryValue return its input (the pre-fix
behaviour) failed 7 of the first 10 tests and the added injection test,
which parsed as {password:"a", guilds:"999"} and lost the real guilds
to a fragment.

Co-Authored-By: nevr-runtime <agents@sprock.io>
compat/ws_bridge.cpp appended nevr_discord_id and nevr_password to the
bridge's remote URL raw ("discordid=" + id + "&password=" + password) for
the config and login connections, on clients and servers. It hits the
same Nakama handler as the ServerDB URI fixed in f138193
(session_ws.go:173-174 discordid/discord_id, :181 password), so after
that commit the binary sent two different passwords for one config value.

The call site now uses ServerDbUri::BuildBridgeCredentialUri, which runs
both values through the same EncodeQueryValue. Both-or-neither and the
"discordid" key are unchanged; the key literal is what the Bearer
selection at ws_bridge.cpp detects. If encoding fails (allocation), the
connection proceeds without URL credentials and logs at Error instead of
sending an unencoded secret.

Tests: four more in test_serverdb_uri (15 total) - exact encoding, a
hostile-value round trip, both-or-neither, and a check that the bridge
and ServerDB paths deliver identical password bytes. Mutation check:
restoring the old concatenation inside the builder fails three of them;
the old path decoded "a+b%41;c&d#e" as "a+bA;c" and left "#e" as a
fragment. A new just-verify sensor fails on any raw "&password="/"+=
cfgPassword"/"[?&]password=%s" in either file and requires both call
sites to use the encoder; falsified against 323352b (hits
gameserver.cpp:1433, ws_bridge.cpp:1002-1003).

COMPATIBILITY RISK (owner decision, not addressed here): Nakama sets an
account's stored password from this URL parameter on the first login
that supplies one (evr_pipeline_login.go:472-475, LinkEmail with
params.authPassword). An account whose password was first set through
the old raw URL and contains '+', '%', '&', '#' or ';' has the mangled
form stored ('+' -> space, %XX decoded, truncated at '&'/'#'; a ';'
drops the parameter entirely in Go's parseQuery). After this change
the client sends the real password, which will not match.

Co-Authored-By: nevr-runtime <agents@sprock.io>
thesprockee and others added 2 commits October 5, 2026 20:10
…erdb-uri

Resolve justfile and src/runtime/CMakeLists.txt list conflicts from
#111/#104/#103 landing on main: keep both test_serverdb_uri (this
branch) and test_winhttp_stub/test_server_context (main) in every
test-target list.

Co-Authored-By: nevr-runtime <agents@sprock.io>
…erdb-uri

Resolve justfile test-target list conflict from #113 landing on main:
keep both test_serverdb_uri (this branch) and test_websocket_client_auth
(main) in both test-auth-unit lists.

Co-Authored-By: nevr-runtime <agents@sprock.io>
@thesprockee
thesprockee merged commit f32fda3 into main Oct 6, 2026
1 check passed
@thesprockee
thesprockee deleted the fix/41-unescaped-serverdb-uri branch October 6, 2026 01:17
thesprockee added a commit that referenced this pull request Oct 6, 2026
…d-registry-violation

Resolve src/runtime/CMakeLists.txt list conflicts from #109 landing on
main: keep both main_thread_handoff and serverdb_uri in the source/header
lists, SKIP_PRECOMPILE_HEADERS list, and the test_serverdb_uri target.

Co-Authored-By: nevr-runtime <agents@sprock.io>
thesprockee added a commit that referenced this pull request Oct 6, 2026
gameserver.cpp:1310 cited docs/guides/token-auth-migration.md directly,
but 1d75593 deleted that file during the 2026-07-26 doc cleanup. Point
the comment at the historical sha (verified against git history, same
pattern already used at the nearby line 1501 citation added by #41/
PR #109) and note where the as-built facts now live.

Co-Authored-By: nevr-runtime <agents@sprock.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gameserver: legacy ServerDB URI embeds discord_id and password unescaped

1 participant