Skip to content

fix(security): validate legacy share-token answers (bogus public scores) - #6

Merged
aymandakirgh merged 1 commit into
mainfrom
fix/legacy-token-answer-validation
Jun 18, 2026
Merged

fix(security): validate legacy share-token answers (bogus public scores)#6
aymandakirgh merged 1 commit into
mainfrom
fix/legacy-token-answer-validation

Conversation

@aymandakirgh

Copy link
Copy Markdown
Owner

Why

decodeResultFromURL's legacy base64-JSON branch (pre-1.1 tokens) accepted parsed.a after only checking it was a non-null, non-array object. It never validated the answer values, and it echoed the token's own parsed.s straight into the score. So a crafted token bypassed the 0–4 invariant that parseAnswerDigits enforces for the v1/v2 formats:

btoa('{"a":{"acq_channels":50}}')               → valid, overallScore 83
btoa('{"a":{<all 15 ids>:99},"s":999}')         → overallScore 2475/100

This path is reachable from the public surfaces: buildShareModel → decodeShareToken → decodeResultFromURL → scoreDiagnostic. The bogus score renders in the public OG image, the page <title>, and shared dashboards — anyone could mint a share link advertising an absurd score.

What

  • New sanitizeLegacyAnswers(): every answer must be an integer 0–4 keyed by a known AARRR question id; unknown keys are dropped and any out-of-range value rejects the whole token (returns null → the generic valid:false fallback model). This reuses the same invariant the v1/v2 paths already enforce.
  • Recompute the score from the validated answers and stop trusting the token's attacker-controlled s.
  • Well-formed legacy tokens still decode unchanged.

Verification

  • npm run typecheck ✅ · npm run lint ✅ · npm test230 passed
  • 2 new regression tests (out-of-range rejected incl. overallScore falls back to 0; well-formed legacy token still decodes 0–100). Existing null-answers regression still passes.

decodeResultFromURL's legacy base64-JSON branch accepted `parsed.a` after
only checking it was a non-null, non-array object — it never validated the
answer VALUES, and it echoed the token's own `parsed.s` straight into the
score. So a crafted token bypassed the 0–4 invariant that parseAnswerDigits
enforces for the v1/v2 formats:

  btoa('{"a":{"acq_channels":50}}')                  → valid, overall 83
  btoa('{"a":{<all 15 ids>:99},"s":999}')            → overall 2475/100

These render on the PUBLIC OG image, the page <title>, and shared dashboards
(buildShareModel → decodeShareToken → decodeResultFromURL → scoreDiagnostic),
so anyone could mint a share link showing an absurd score.

Add sanitizeLegacyAnswers(): every answer must be an integer 0–4 keyed by a
known AARRR question id; unknown keys are dropped and any out-of-range value
rejects the whole token (null → the generic, valid:false fallback model).
Recompute the score from the validated answers and stop trusting the token's
`s`. Well-formed legacy tokens still decode.

Adds 2 regression tests (out-of-range rejected; well-formed still decodes);
the existing null-answers regression test still passes.

Co-authored-by: mattia-mamini-gh <281593356+mattia-mamini-gh@users.noreply.github.com>
@aymandakirgh
aymandakirgh merged commit 5167cbe into main Jun 18, 2026
1 check passed
@aymandakirgh
aymandakirgh deleted the fix/legacy-token-answer-validation branch June 18, 2026 08:56
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