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.
- 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.
- 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.
- 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.
Summary
POST /api/application/sendaccepts ax-tianji-cacheJWT header as asession cache token. In
src/server/model/application/index.tslines 38 to47,
findSession()verifies the signature and then returns the decodedpayload verbatim:
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.tslines 187 to 218) validates claim types,requires
result.websiteId === websiteId, reloads a live session from thedatabase, 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 (
staleTokeninsrc/server/router/__test__/website.test.ts),which suggests the hardening was intentional there and missed here.
Separately,
createToken(session)insrc/server/router/application.ts(lines 92 and 164) passes no options, so cache tokens carry
iatbut noexpand 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
ApplicationSessionrowis deleted, and can drive
identifywrites with attacker-chosen sessiondata. Token claims also silently override request payload values: events
land under the token's
applicationIdrather 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 requiresnothing 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.
The response body is a JWT. Decoding it shows the full session object,
including
applicationIdandworkspaceId, with noexpclaim.ApplicationSessionrow, then replay the sametoken:
Observed: HTTP 200 and a persisted event row, although the session the token
describes no longer exists.
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 alive session through
loadSession(result.id)instead of trusting tokencontents, and add
expiresInwhen signing these tokens. Porting thestaleTokenregression test to the application router would lock thebehavior in.
I am happy to open a PR with this change if you agree with the approach.