Skip to content

/api/user-data trusts an x-wallet-address header and persists to one race-prone JSON file #4

Description

@P3az3

Labels / Complexity: bug, security · High — 500 points

Problem

app/api/user-data/route.ts identifies the user purely from a client-supplied header and performs no authentication:

function getWalletAddress(request: NextRequest): string | null {
  const walletAddress = request.headers.get('x-wallet-address');
  return walletAddress?.trim() || null;
}

GET, PUT, and DELETE all accept that header as the identity, so anyone who knows (or guesses) a wallet address can read, overwrite, or delete that user's bookmarks, drafts, and preferences. lib/api/userData.ts makes this trivially callable by setting x-wallet-address to any string.

The backing store compounds the problem. lib/server/userDataStore.ts keeps every user's snapshot in a single file and read-modify-writes it with no locking:

const filePath = path.join(dataDirectory, 'user-data-store.json');
...
export async function saveUserData(snapshot: UserDataSnapshot): Promise<UserDataSnapshot> {
  const store = await readStore();
  store[snapshot.walletAddress] = snapshot;
  await writeStore(store);   // ← whole file rewritten on every save
}

Consequence: (a) an IDOR — any client can read/write/delete any wallet's data by setting the header; (b) concurrent saves read the same file and the last writer wins, silently dropping updates; and (c) the store is a local JSON file that is lost on ephemeral/serverless redeploys and is not durable across instances. Preferences, bookmarks, and drafts are therefore neither private nor reliable.

Root cause

// app/api/user-data/route.ts
const walletAddress = request.headers.get('x-wallet-address');  // ← client-trusted identity
// lib/server/userDataStore.ts
const store = await readStore();
store[snapshot.walletAddress] = snapshot;
await writeStore(store);   // ← no locking, whole-file rewrite

Why this is architecturally hard

  1. The shortcut — adding an auth check — requires a real identity source. The app has no server-side session established for the Stellar wallet flow (lib/auth/authOptions.ts wires OAuth providers, while the backend authenticates by wallet signature). The fix must decide which identity to trust: a verified JWT from the backend, a NextAuth session, or a wallet-signature challenge, then derive the wallet address from that instead of a header.
  2. The storage layer is synchronous and file-based, which cannot safely scale beyond one process. A durable fix must move to a database (e.g. Postgres, already in DATABASE_URL in .env.example) or Redis (already a declared REDIS_URL env var) and decide on a per-user keying scheme.
  3. useUserDataSync (hooks/useUserDataSync.ts) and the drafts flow assume the current snapshot shape; a migration to a real store must preserve the UserDataSnapshot type (types/userData.ts) or update every consumer.

Proposed design

Authenticate the route (reject requests without a valid token and derive the wallet address from the token's walletAddress claim, which the backend puts into the JWT at POST /auth/verify), and replace userDataStore.ts with a database- or Redis-backed store keyed by wallet address. Keep the UserDataSnapshot shape stable so useUserDataSync and the drafts UI keep working.

Downstream impact

This is a frontend-internal change but it must trust the backend's JWT claims (walletAddress in the accessToken payload), so it depends on the backend auth contract. No ABI change to the backend. Repo-local otherwise.

Acceptance criteria

Security

  • GET/PUT/DELETE /api/user-data reject requests without a verified token.
  • The wallet address is derived from the authenticated identity, never from the x-wallet-address header alone.

Durability

  • User data survives a restart/redeploy and concurrent saves do not silently overwrite each other.

Tests

  • A test proves one client cannot read another wallet's data by setting the header.
  • A test proves a forged header value is ignored when a real identity is present.

Out of scope

Replicating bookmarks/drafts into the backend's Postgres schema is a larger cross-repo task; this issue can use a local durable store (Postgres/Redis) without changing the backend.

Getting started

Files in scope: app/api/user-data/route.ts, lib/server/userDataStore.ts, lib/api/userData.ts, hooks/useUserDataSync.ts.

npm run type-check
npm run lint

Good first files to read: app/api/user-data/route.ts, lib/server/userDataStore.ts, types/userData.ts.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions