Skip to content

Fix WhatsApp accountId path traversal in login-status/login - #119

Open
jay79-boop wants to merge 1 commit into
chrysb:mainfrom
jay79-boop:fix/whatsapp-accountid-path-traversal
Open

Fix WhatsApp accountId path traversal in login-status/login#119
jay79-boop wants to merge 1 commit into
chrysb:mainfrom
jay79-boop:fix/whatsapp-accountid-path-traversal

Conversation

@jay79-boop

Copy link
Copy Markdown

Summary

resolveWhatsAppCredentialCandidatePaths() (lib/server/agents/shared.js) joins accountId straight into a credentials file path: OPENCLAW_DIR/credentials/whatsapp/<accountId>/creds.json. normalizeChannelAccountId() — the only normalization applied to it — just trims and defaults to "default"; it never rejects ../.

Three call sites feed it accountId values with no validation:

  • GET /api/channels/accounts/login-status (routes/agents.js): accountId comes straight from req.query.accountId
  • POST /api/channels/accounts/login: same, from req.body.accountId
  • readPairedCountsByAccount (gateway.js, channel status reporting): accountId comes from the keys of the live openclaw.json config's channels.whatsapp.accounts object — which, for an imported workspace, is promoted directly from whatever the imported repo's config contained

hasSavedWhatsAppCredentials() reads whatever file resolves and returns whether it exists and is non-empty — so accountId=../../../../etc/shadow turns the (authenticated) login-status endpoint into a boolean exists-and-non-empty oracle for arbitrary host files, reaching well outside the OPENCLAW_DIR sandbox the Browse routes otherwise enforce carefully for this same admin.

This codebase already has the right validator for exactly this field — isValidChannelAccountId() (kChannelAccountIdPattern, lowercase/digits/hyphens only) — and createChannelAccount() already uses it. The two sibling functions, runChannelAccountLogin() and getChannelAccountLoginStatus(), just never got the same check.

Fix

Fixed at both layers:

  • agents/channels.js: runChannelAccountLogin() and getChannelAccountLoginStatus() now validate accountId with isValidChannelAccountId() before doing anything else, matching createChannelAccount()'s existing behavior.
  • agents/shared.js: resolveWhatsAppCredentialCandidatePaths() also filters its own candidate paths to stay within the credentials directory, closing the config-derived path (readPairedCountsByAccount) that doesn't go through the two HTTP-facing validators above.

Test plan

  • agents-service.test.js: covers both HTTP-facing rejections, with an assertion that readFileSync is never reached for the rejected accountId.
  • New agents-shared.test.js: exercises the containment check directly with a real external file. Verified locally to fail without the fix (the external file was reported as linked: true) and pass with it.
  • Full agents-service/agents-shared/routes-agents/agents-service-coverage suites pass (pre-existing unrelated failures on this environment are Windows-path-separator artifacts in the mock-fs test harness, not caused by this change).

resolveWhatsAppCredentialCandidatePaths() joins accountId straight into
a credentials file path (OPENCLAW_DIR/credentials/whatsapp/<accountId>/
creds.json). normalizeChannelAccountId() -- the only normalization
applied to it -- just trims and defaults to "default"; it never rejects
`../`. Three call sites feed it accountId values with no validation:

  - GET /api/channels/accounts/login-status (routes/agents.js): accountId
    comes straight from req.query.accountId
  - POST /api/channels/accounts/login: same, from req.body.accountId
  - readPairedCountsByAccount (gateway.js, channel status reporting):
    accountId comes from the *keys* of the live openclaw.json config's
    channels.whatsapp.accounts object -- which, for an imported
    workspace, is promoted directly from whatever the imported repo's
    config contained

hasSavedWhatsAppCredentials() reads whatever file resolves and returns
whether it exists and is non-empty -- so accountId=../../../../etc/shadow
turns the (authenticated) login-status endpoint into a boolean
exists-and-non-empty oracle for arbitrary host files, reaching well
outside the OPENCLAW_DIR sandbox the Browse routes otherwise enforce
carefully for this same admin.

This codebase already has the right validator for exactly this field --
isValidChannelAccountId() (kChannelAccountIdPattern, lowercase/digits/
hyphens only) -- and createChannelAccount() already uses it. The two
sibling functions, runChannelAccountLogin() and
getChannelAccountLoginStatus(), just never got the same check.

Fixed at both layers:
  - agents/channels.js: runChannelAccountLogin() and
    getChannelAccountLoginStatus() now validate accountId with
    isValidChannelAccountId() before doing anything else, matching
    createChannelAccount()'s existing behavior.
  - agents/shared.js: resolveWhatsAppCredentialCandidatePaths() also
    filters its own candidate paths to stay within the credentials
    directory, closing the config-derived path (readPairedCountsByAccount)
    that doesn't go through the two HTTP-facing validators above.

Added tests: agents-service.test.js covers both HTTP-facing rejections
(with an assertion that readFileSync is never reached), and a new
agents-shared.test.js exercises the containment check directly with a
real external file, verified locally to fail without the fix (the
external file was reported as "linked") and pass with it.
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.

1 participant