Skip to content

UNOMI-975: Bind a public context request to the profile its own cookie names - #851

Merged
sergehuber merged 3 commits into
masterfrom
UNOMI-975-public-profile-cookie-binding
Aug 17, 2026
Merged

UNOMI-975: Bind a public context request to the profile its own cookie names#851
sergehuber merged 3 commits into
masterfrom
UNOMI-975-public-profile-cookie-binding

Conversation

@sergehuber

Copy link
Copy Markdown
Contributor

/context.json and /eventcollector accept a profile id in the request body and a session id with the
request. For a public caller these are now treated as claims to be checked rather than as
instructions: the body profileId is honoured only when it matches the caller's own
context-profile-id cookie, and a supplied session is continued only when that cookie already owns
it. The cookie is the single source of truth for who a public caller is.

A session that is not continued is detached rather than rebound, and its id is not echoed back in
the response - a client that saw its own id returned would keep replaying an id the server has not
accepted.

Anonymous browsing, personas and profile overrides are unchanged. A caller with no cookie is still
issued a profile, which is how tracking has always worked. The server-side path is preserved: a
caller holding the tenant private key still sets the body profileId, gated on hasSystemAccess()
behind isTrustedProfileCaller() so the distinction has one seam rather than being spread across the
call sites.

The shipped profile cookie now defaults to HttpOnly. The model above rests on that cookie not being
readable from page script, so the default is part of the design rather than a preference.

ContextEndpointBaselineIT records the behaviour on both sides of the change: its compat_* tests must
pass either way, and its hardened_* tests pin the new guarantees. The nine binding tests in
ContextServletIT pin what must not change.

Jira: https://issues.apache.org/jira/browse/UNOMI-975

…e names

/context.json and /eventcollector accept a profile id in the request body and a session id with the
request. For a public caller these are now treated as claims to be checked rather than as
instructions: the body profileId is honoured only when it matches the caller's own
context-profile-id cookie, and a supplied session is continued only when that cookie already owns
it. The cookie is the single source of truth for who a public caller is.

A session that is not continued is detached rather than rebound, and its id is not echoed back in
the response - a client that saw its own id returned would keep replaying an id the server has not
accepted.

Anonymous browsing, personas and profile overrides are unchanged. A caller with no cookie is still
issued a profile, which is how tracking has always worked. The server-side path is preserved: a
caller holding the tenant private key still sets the body profileId, gated on hasSystemAccess()
behind isTrustedProfileCaller() so the distinction has one seam rather than being spread across the
call sites.

The shipped profile cookie now defaults to HttpOnly. The model above rests on that cookie not being
readable from page script, so the default is part of the design rather than a preference.

ContextEndpointBaselineIT records the behaviour on both sides of the change: its compat_* tests must
pass either way, and its hardened_* tests pin the new guarantees. The nine binding tests in
ContextServletIT pin what must not change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@asf-gitbox-commits
asf-gitbox-commits force-pushed the UNOMI-975-public-profile-cookie-binding branch from 9912918 to e3741c6 Compare August 14, 2026 14:32
sergehuber and others added 2 commits August 16, 2026 09:19
…ofile-cookie-binding

# Conflicts:
#	manual/src/main/asciidoc/recipes.adoc
The ownership rule the previous commit added only reached named sessions. An anonymous session
records no owner at all - PrivacyService#getAnonymousProfile returns a profile with no itemId, so
the session's profileId is null - which left two ways past it. The de-anonymising branch rebound
such a session to whoever presented its id and saved it, and the invalidateSession guard tested
"owner differs", which a null owner passed. Either one handed a visitor's session to a caller that
merely knew the id.

Ownership is now established positively rather than by absence of a mismatch, through a single
isOwnedByCookieBearer() used by both call sites, so an unowned session answers "not yours". Binding
an anonymous session back to a named profile is reserved for trusted callers; a public caller leaves
it anonymous and the visitor picks up a named session once its client rotates the session id. That
also stops the rebinding from retroactively re-attributing every event already recorded in the
session to a real profile, which is the outcome anonymous browsing was asked for to begin with.

The refusal to bind a body profileId now logs at WARN rather than DEBUG, matching the two sibling
refusals: an integration that used to bind a profile this way stops working at that line, and DEBUG
left an operator with nothing to find. The anonymous case stays at INFO on purpose - it cannot tell
a takeover attempt from the visitor who just turned anonymity off, and it repeats until the session
id rotates, so a WARN there would devalue the ones that mean something.

Also in the same pass: sanitize the one new log statement that interpolated a request-supplied id
raw, drop the null guard on a mandatory @reference so a missing identity service fails loudly
instead of silently downgrading every caller to untrusted, and reorder the profile-switch branch so
the security condition reads without a double negative.

Tests: the anonymous takeover is pinned at unit level for both the rebinding and invalidateSession
routes, plus the trusted caller that must still be allowed through. ContextEndpointBaselineIT gains
end-to-end coverage for the anonymous takeover, for /eventcollector - previously only exercised for
compatibility, never for the hardening it shares with /context.json - and a runtime assertion that
the profile cookie really is issued HttpOnly rather than only that the shipped default says so. The
foreign-session test now also asserts the refused id is not echoed and that the rightful owner still
holds the session afterwards.

Docs: the migration guide gains the "Client-facing hardening (3.1)" section that four pages already
linked to but which was never written, covering each field's 3.0 and 3.1 behaviour, the HttpOnly
default and the widened cookie validation. Corrected the claim in builtin-event-types and recipes
that cross-profile targetId and systemProperties.* writes require a trusted caller - that gate is
source-address based and independent of this distinction - and the session rules in
how-profile-tracking-works, which stated the guarantee more broadly than the code delivered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sergehuber
sergehuber merged commit 0163e13 into master Aug 17, 2026
11 of 13 checks passed
@sergehuber
sergehuber deleted the UNOMI-975-public-profile-cookie-binding branch August 17, 2026 08:21
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