fuzzy find my records ken.waow.tech
embeddings pds search
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

replace DID-as-cookie with random session token

the session cookie was literally the user's DID (a public identifier).
anyone who knew the DID could set the cookie and impersonate the user,
gaining the ability to save/delete packs on their PDS. DIDs are public
by design (they're in every AT-URI, in plc.directory, on profiles), so
this was a real vulnerability, not a theoretical one.

fix: generate 32 cryptographically random bytes (hex-encoded, 64 chars)
as the session token. store a token → DID mapping in state.zig. the
cookie contains only the opaque token; getSessionDid resolves it back
to the DID via the map. logout deletes both the token mapping and the
session data.

follows the same pattern as plyr.fm's session handling (secrets.token_
urlsafe(32) → DB mapping → cookie). pollz has the same bug and should
be fixed separately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

zzstoatzz 921fefe6 e8339e4e

+71 -4
+24 -4
backend/src/oauth.zig
··· 846 846 var redirect_url: std.ArrayList(u8) = .empty; 847 847 try redirect_url.print(alloc, "{s}/?logged_in={s}", .{ cfg.frontend_origin, auth_req.handle }); 848 848 849 + // generate a cryptographically random session token for the cookie. 850 + // the cookie MUST NOT contain the DID itself — DIDs are public and 851 + // using one as a credential lets anyone impersonate the user. 852 + const session_token = store.createSessionToken(auth_req.did) catch { 853 + try sendError(request, .internal_server_error, "could not create session token"); 854 + return; 855 + }; 856 + 849 857 var cookie_buf: [512]u8 = undefined; 850 858 const cookie = std.fmt.bufPrint( 851 859 &cookie_buf, 852 860 "embed_session={s}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000", 853 - .{auth_req.did}, 861 + .{session_token}, 854 862 ) catch { 855 863 try sendError(request, .internal_server_error, "cookie error"); 856 864 return; ··· 866 874 } 867 875 868 876 pub fn handleLogout(request: *http.Server.Request) !void { 869 - if (getSessionDid(request)) |did| { 870 - store.deleteSession(did); 877 + // delete both the token→DID mapping and the DID→session data. 878 + // order matters: resolve the token first, then delete both. 879 + var it = request.iterateHeaders(); 880 + while (it.next()) |h| { 881 + if (std.ascii.eqlIgnoreCase(h.name, "cookie")) { 882 + if (parseCookieValue(h.value, "embed_session")) |token| { 883 + if (store.resolveSessionToken(token)) |did| { 884 + store.deleteSession(did); 885 + } 886 + store.deleteSessionToken(token); 887 + } 888 + break; 889 + } 871 890 } 872 891 try request.respond("{\"ok\":true}", .{ 873 892 .status = .ok, ··· 886 905 var it = request.iterateHeaders(); 887 906 while (it.next()) |h| { 888 907 if (std.ascii.eqlIgnoreCase(h.name, "cookie")) { 889 - return parseCookieValue(h.value, "embed_session"); 908 + const token = parseCookieValue(h.value, "embed_session") orelse continue; 909 + return store.resolveSessionToken(token); 890 910 } 891 911 } 892 912 return null;
+47
backend/src/state.zig
··· 82 82 var auth_requests: std.StringHashMap(StoredAuthRequest) = undefined; 83 83 var sessions: std.StringHashMap(StoredSession) = undefined; 84 84 var exchange_tokens: std.StringHashMap(StoredExchange) = undefined; 85 + /// maps opaque session cookie tokens (hex-encoded 32 random bytes) back to 86 + /// DIDs. the cookie never contains the DID itself — DIDs are public 87 + /// identifiers and using one as a session credential lets anyone who knows 88 + /// the DID impersonate the user. this map is the indirection that prevents 89 + /// that. 90 + var session_tokens: std.StringHashMap([]u8) = undefined; 85 91 86 92 fn timestamp() i64 { 87 93 return @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_s)); ··· 93 99 auth_requests = std.StringHashMap(StoredAuthRequest).init(gpa); 94 100 sessions = std.StringHashMap(StoredSession).init(gpa); 95 101 exchange_tokens = std.StringHashMap(StoredExchange).init(gpa); 102 + session_tokens = std.StringHashMap([]u8).init(gpa); 96 103 std.log.info("state: in-memory (oauth sessions reset on restart)", .{}); 97 104 } 98 105 ··· 306 313 gpa.free(kv.value.did); 307 314 } 308 315 try exchange_tokens.put(key, stored); 316 + } 317 + 318 + // --------------------------------------------------------------------------- 319 + // session_tokens — opaque cookie token ↔ DID mapping 320 + // --------------------------------------------------------------------------- 321 + 322 + /// generate a random session token (32 bytes → 64 hex chars), store the 323 + /// mapping to `did`, and return the token. caller must use the returned 324 + /// slice as the cookie value. the token is heap-owned by the map. 325 + pub fn createSessionToken(did: []const u8) ![]const u8 { 326 + mutex.lockUncancelable(io); 327 + defer mutex.unlock(io); 328 + 329 + var rand_bytes: [32]u8 = undefined; 330 + io.random(&rand_bytes); 331 + 332 + const token = std.fmt.bytesToHex(rand_bytes, .lower); 333 + 334 + const key = try gpa.dupe(u8, &token); 335 + const val = try gpa.dupe(u8, did); 336 + try session_tokens.put(key, val); 337 + return key; 338 + } 339 + 340 + /// look up the DID for a session token. returns null if the token is 341 + /// unknown (expired, never existed, already deleted). 342 + pub fn resolveSessionToken(token: []const u8) ?[]const u8 { 343 + mutex.lockUncancelable(io); 344 + defer mutex.unlock(io); 345 + return session_tokens.get(token); 346 + } 347 + 348 + /// delete a session token mapping. called on logout. 349 + pub fn deleteSessionToken(token: []const u8) void { 350 + mutex.lockUncancelable(io); 351 + defer mutex.unlock(io); 352 + if (session_tokens.fetchRemove(token)) |kv| { 353 + gpa.free(kv.key); 354 + gpa.free(kv.value); 355 + } 309 356 } 310 357 311 358 /// consume (delete) and return the associated DID. caller owns the returned