Skip to content

Application /send cache token never expires and skips session validation #287

Description

@iamwhitehat

Summary

POST /api/application/send accepts a x-tianji-cache JWT header as a
session cache token. In src/server/model/application/index.ts lines 38 to
47, findSession() verifies the signature and then returns the decoded
payload verbatim:

if (cacheToken) {
    const result = parseToken(cacheToken);
    if (result) {
      return result as any;
    }
}

There is no liveness check, no binding between token claims and the request
payload, and no expiry enforcement. The website router's equivalent function
(src/server/model/website/index.ts lines 187 to 218) validates claim types,
requires result.websiteId === websiteId, reloads a live session from the
database, and purges stale cache entries. The application-side copy does not
apply any of those checks. There is a regression test covering stale tokens
on the website side (staleToken in src/server/router/__test__/website.test.ts),
which suggests the hardening was intentional there and missed here.

Separately, createToken(session) in src/server/router/application.ts
(lines 92 and 164) passes no options, so cache tokens carry iat but no exp
and do not expire.

Impact

Any client that fires one legitimate event receives a permanently replayable
credential for writing further events into the workspace's analytics.
Replayed tokens keep working after the underlying ApplicationSession row
is deleted, and can drive identify writes with attacker-chosen session
data. Token claims also silently override request payload values: events
land under the token's applicationId rather than the payload's.

On severity: forging arbitrary identities requires knowing the server's
JWT secret, and default installs fall back to a random value
(src/server/utils/env.ts). Replay of legitimately issued tokens requires
nothing beyond one real page view, so that part applies to every deployment.

Reproduction

Verified locally against commit 1a7907f, Node 26, Postgres 16,
NODE_ENV=production.

  1. Send one legitimate event:
curl -X POST http://localhost:12345/api/application/send \
  -H 'Content-Type: application/json' \
  -d '{"type":"event","payload":{"application":"<cuid>","url":"https://example.com/page"}}'

The response body is a JWT. Decoding it shows the full session object,
including applicationId and workspaceId, with no exp claim.

  1. Delete the corresponding ApplicationSession row, then replay the same
    token:
curl -X POST http://localhost:12345/api/application/send \
  -H 'Content-Type: application/json' \
  -H "x-tianji-cache: <token-from-step-1>" \
  -d '{"type":"event","payload":{"application":"<cuid>","url":"https://example.com/replay"}}'

Observed: HTTP 200 and a persisted event row, although the session the token
describes no longer exists.

  1. Replaying with an identify event writes attacker-chosen keys:
curl -X POST http://localhost:12345/api/application/send \
  -H 'Content-Type: application/json' \
  -H "x-tianji-cache: <token-from-step-1>" \
  -d '{"type":"identify","payload":{"application":"<cuid>","data":{"plan":"injected"}}}'

Observed: HTTP 200 and rows written to ApplicationSessionData.

Suggested fix

Mirror the website-side validation in the application model: validate decoded
claim types, require result.applicationId === payload.application, reload a
live session through loadSession(result.id) instead of trusting token
contents, and add expiresIn when signing these tokens. Porting the
staleToken regression test to the application router would lock the
behavior in.

I am happy to open a PR with this change if you agree with the approach.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions