You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
S3Keyspace::with_tenant_prefix derives a tenant's data-key prefix by trimming
leading and trailing / from the tenant id. That derivation is not injective:
tenants a, /a, a/, and //a all resolve to the data prefix a. Meanwhile
the tenant registry key is injective (percent-encoded, since #271), so those
same ids are four distinct, separately-registrable tenants.
The result is an asymmetry: the registry says four tenants, the data keyspace says
one.
Found while fixing #384 (the Elasticsearch equivalent) and deliberately kept
separate — the two have very different remediation stories, see "Why this was not
folded into #384".
trim_matches('/') strips all leading and trailing slashes, so the mapping is
many-to-one.
This is the same defect class #271 fixed for the registry key. That fix introduced
the injective registry_object_id (keyspace.rs:383-395) and its doc comment is
explicit that a lossy derivation must never establish identity — but it changed
only the registry path. The data path kept the trim.
Tenant id
Data prefix (with_tenant_prefix)
Registry key (registry_object_id)
a
a
tenants/a.json
/a
a
tenants/%2Fa.json
a/
a
tenants/a%2F.json
//a
a
tenants/%2F%2Fa.json
a/b
a/b
tenants/a%2Fb.json
Note a/b is not affected — only leading/trailing slashes are trimmed, so
hierarchical ids get their own prefix. The defect is specific to slash padding.
PrefixPerTenant is the default (config.rs:71-87) and the only mode crates/hfs/src/main.rs ever constructs (:462, :2305, :2464), so this is
the mode every S3 deployment actually runs.
Every S3 data path goes through it: tenant_location (backend.rs:194-202) is
the single funnel for resource keys, history, and purge_tenant_data.
Reachability — narrower than #384, and worth being precise about
A slash-padded tenant id has to reach storage. Of the four routing surfaces:
X-Tenant-ID header — rejects /. is_valid_tenant_id
(crates/rest/src/tenant/resolver.rs:283-290) allows only [A-Za-z0-9_-]. Not reachable.
URL path prefix — same charset check
(crates/rest/src/middleware/tenant_prefix.rs:62-68). Not reachable.
Admin provisioning — validate_tenant_id
(crates/rest/src/handlers/admin_tenants.rs:128-139) explicitly permits /
with no position constraint, so /a is accepted. Reachable.
JWT tenant claim — JwtTenantExtractor (resolver.rs:149-156) passes the
claim into TenantId::new with no validation of any kind, and extractors/tenant.rs:163-185 treats it as authoritative. Reachable, if the
IdP emits such a value.
So an ordinary authenticated user on a header- or URL-routed deployment cannot
reach this. It needs either an operator provisioning a slash-padded id, or an IdP
whose tenant claim carries one.
purge_tenant_data (crates/persistence/src/backends/s3/storage.rs:1119-1140)
resolves the location "exactly as request handling does" — via tenant_location,
hence via the trim — and then deletes every object under resources_prefix() and history_root_prefix(). For id /a that is tenant a's resources and its
version history.
Meanwhile deregister_tenant("/a") removes only tenants/%2Fa.json, so tenant a's registry record survives. The end state is a tenant that is still registered,
still listed, and has had all of its data deleted. The handler reports success
(admin_tenants.rs:301-306).
This needs only the admin API and no JWT involvement.
2. Provisioning /a writes into tenant a's keyspace.
POST /admin/tenants {"id": "/a"} passes validation, finds no existing registry
record (distinct key), registers, and then calls seed_new_tenant
(admin_tenants.rs:238-240), which seeds conformance resources through the normal
write path — i.e. into prefix a. The seeded content is spec SearchParameters and
CompartmentDefinitions under fixed ids, so in practice it overwrites tenant a's
copies with identical documents. Low harm in itself; it is listed because it is
the moment the two tenants silently become one, and it happens without anyone
touching data deliberately.
3. If both ids are routable, full cross-tenant read/write/delete.
Where the JWT path supplies the tenant, a principal on /a reads, updates, and
deletes tenant a's resources through ordinary FHIR requests. This is the
isolation break proper, and it is the part gated on IdP behaviour.
Is this a security issue?
Yes, but a narrow one, and it should not be filed as a vulnerability:
It is a genuine tenant data-isolation defect on the system of record.
It is not reachable by an ordinary authenticated user in the deployments
that use header or URL-path tenant routing, because both reject / outright.
It is not a privilege escalation: the admin-reachable paths (1 and 2) are
available to a caller who already holds cross-tenant administrative scope and
could affect tenant a directly anyway. What is wrong is that they affect a different tenant than the one named, with no indication.
Consequence 3 is a real cross-tenant read/write, but it requires the operator's
IdP to emit a tenant claim with leading or trailing slashes — which nothing in
HFS documents as forbidden, but which is also not a natural thing for an IdP to
produce.
The realistic failure is an operator destroying or merging the wrong tenant's
data by provisioning or deleting a slash-padded id, not an attacker pivoting
between tenants. Calibrating it as a data-integrity defect with isolation
consequences rather than an exploitable vulnerability.
No evidence any deployment has hit this; slash-padded tenant ids are unusual and
nothing in the product suggests creating one.
Fix options
Option A — make the data prefix injective. Escape the tenant id into a single
key segment, exactly as registry_object_id already does for the registry key
(keyspace.rs:383-395); the helper exists and is tested. This is the same shape
as the #384 fix and would be the identity on tenant ids that contain no /, \, space, or % — i.e. every id the header and URL-path routes can produce.
The obstacle is migration, and it is materially harder than #384's: S3 is the
system of record, not a derived index. For a/b-shaped ids the prefix would
change from a/b/... to a%2Fb/..., which means copying live objects, with no $reindex-equivalent rebuild path. Any change here needs either a compatibility
read path or a documented object-migration step. That is the whole reason this is
a separate issue.
Note the migration cost falls only on deployments using hierarchical (a/b) or
slash-padded ids; ids from the header/URL charset are unaffected either way.
Option B — reject slash-padded ids at the boundary. Tighten validate_tenant_id (admin_tenants.rs:128-139) to forbid leading/trailing /
(and, arguably, / entirely — see #385), and validate the JWT claim. Cheap, no
migration, and it closes the reachable paths. It does not make the derivation
correct, so it leaves the trap in place for any future caller that constructs a TenantId directly.
Option C — both, with B shipping first as the immediate stop-gap.
My inclination is B first, then A if and when #385 settles the canonical tenant-id
charset — because if #385 rejects / in tenant ids outright, A's remaining value
is defence in depth rather than a fix for anything reachable.
The Elasticsearch fix was safe to ship without a migration because its index is a
derived artifact rebuildable with $reindex. That argument does not transfer:
changing an S3 key prefix moves live objects that nothing else can reconstruct.
Bundling a change that needs an object migration into a PR that needs none would
have obscured exactly the risk that matters.
Summary
S3Keyspace::with_tenant_prefixderives a tenant's data-key prefix by trimmingleading and trailing
/from the tenant id. That derivation is not injective:tenants
a,/a,a/, and//aall resolve to the data prefixa. Meanwhilethe tenant registry key is injective (percent-encoded, since #271), so those
same ids are four distinct, separately-registrable tenants.
The result is an asymmetry: the registry says four tenants, the data keyspace says
one.
Found while fixing #384 (the Elasticsearch equivalent) and deliberately kept
separate — the two have very different remediation stories, see "Why this was not
folded into #384".
Root cause
crates/persistence/src/backends/s3/keyspace.rs:36-43:trim_matches('/')strips all leading and trailing slashes, so the mapping ismany-to-one.
This is the same defect class #271 fixed for the registry key. That fix introduced
the injective
registry_object_id(keyspace.rs:383-395) and its doc comment isexplicit that a lossy derivation must never establish identity — but it changed
only the registry path. The data path kept the trim.
with_tenant_prefix)registry_object_id)aatenants/a.json/aatenants/%2Fa.jsona/atenants/a%2F.json//aatenants/%2F%2Fa.jsona/ba/btenants/a%2Fb.jsonNote
a/bis not affected — only leading/trailing slashes are trimmed, sohierarchical ids get their own prefix. The defect is specific to slash padding.
Scope
PrefixPerTenanttenancy mode.BucketPerTenantresolves throughan exact map lookup with no trimming (
backend.rs:203-222), and fix(persistence): correct tenancy capability advertisements (#369) #379 addedduplicate-bucket validation there.
PrefixPerTenantis the default (config.rs:71-87) and the only modecrates/hfs/src/main.rsever constructs (:462,:2305,:2464), so this isthe mode every S3 deployment actually runs.
tenant_location(backend.rs:194-202) isthe single funnel for resource keys, history, and
purge_tenant_data.Reachability — narrower than #384, and worth being precise about
A slash-padded tenant id has to reach storage. Of the four routing surfaces:
X-Tenant-IDheader — rejects/.is_valid_tenant_id(
crates/rest/src/tenant/resolver.rs:283-290) allows only[A-Za-z0-9_-]. Not reachable.(
crates/rest/src/middleware/tenant_prefix.rs:62-68). Not reachable.validate_tenant_id(
crates/rest/src/handlers/admin_tenants.rs:128-139) explicitly permits/with no position constraint, so
/ais accepted. Reachable.JwtTenantExtractor(resolver.rs:149-156) passes theclaim into
TenantId::newwith no validation of any kind, andextractors/tenant.rs:163-185treats it as authoritative. Reachable, if theIdP emits such a value.
So an ordinary authenticated user on a header- or URL-routed deployment cannot
reach this. It needs either an operator provisioning a slash-padded id, or an IdP
whose tenant claim carries one.
Consequences, most concrete first
1.
DELETE /admin/tenants/%2Fa?purge=truedestroys tenanta's data.purge_tenant_data(crates/persistence/src/backends/s3/storage.rs:1119-1140)resolves the location "exactly as request handling does" — via
tenant_location,hence via the trim — and then deletes every object under
resources_prefix()andhistory_root_prefix(). For id/athat is tenanta's resources and itsversion history.
Meanwhile
deregister_tenant("/a")removes onlytenants/%2Fa.json, so tenanta's registry record survives. The end state is a tenant that is still registered,still listed, and has had all of its data deleted. The handler reports success
(
admin_tenants.rs:301-306).This needs only the admin API and no JWT involvement.
2. Provisioning
/awrites into tenanta's keyspace.POST /admin/tenants {"id": "/a"}passes validation, finds no existing registryrecord (distinct key), registers, and then calls
seed_new_tenant(
admin_tenants.rs:238-240), which seeds conformance resources through the normalwrite path — i.e. into prefix
a. The seeded content is spec SearchParameters andCompartmentDefinitions under fixed ids, so in practice it overwrites tenant
a'scopies with identical documents. Low harm in itself; it is listed because it is
the moment the two tenants silently become one, and it happens without anyone
touching data deliberately.
3. If both ids are routable, full cross-tenant read/write/delete.
Where the JWT path supplies the tenant, a principal on
/areads, updates, anddeletes tenant
a's resources through ordinary FHIR requests. This is theisolation break proper, and it is the part gated on IdP behaviour.
Is this a security issue?
Yes, but a narrow one, and it should not be filed as a vulnerability:
that use header or URL-path tenant routing, because both reject
/outright.available to a caller who already holds cross-tenant administrative scope and
could affect tenant
adirectly anyway. What is wrong is that they affect adifferent tenant than the one named, with no indication.
IdP to emit a tenant claim with leading or trailing slashes — which nothing in
HFS documents as forbidden, but which is also not a natural thing for an IdP to
produce.
The realistic failure is an operator destroying or merging the wrong tenant's
data by provisioning or deleting a slash-padded id, not an attacker pivoting
between tenants. Calibrating it as a data-integrity defect with isolation
consequences rather than an exploitable vulnerability.
No evidence any deployment has hit this; slash-padded tenant ids are unusual and
nothing in the product suggests creating one.
Fix options
Option A — make the data prefix injective. Escape the tenant id into a single
key segment, exactly as
registry_object_idalready does for the registry key(
keyspace.rs:383-395); the helper exists and is tested. This is the same shapeas the #384 fix and would be the identity on tenant ids that contain no
/,\, space, or%— i.e. every id the header and URL-path routes can produce.The obstacle is migration, and it is materially harder than #384's: S3 is the
system of record, not a derived index. For
a/b-shaped ids the prefix wouldchange from
a/b/...toa%2Fb/..., which means copying live objects, with no$reindex-equivalent rebuild path. Any change here needs either a compatibilityread path or a documented object-migration step. That is the whole reason this is
a separate issue.
Note the migration cost falls only on deployments using hierarchical (
a/b) orslash-padded ids; ids from the header/URL charset are unaffected either way.
Option B — reject slash-padded ids at the boundary. Tighten
validate_tenant_id(admin_tenants.rs:128-139) to forbid leading/trailing/(and, arguably,
/entirely — see #385), and validate the JWT claim. Cheap, nomigration, and it closes the reachable paths. It does not make the derivation
correct, so it leaves the trap in place for any future caller that constructs a
TenantIddirectly.Option C — both, with B shipping first as the immediate stop-gap.
My inclination is B first, then A if and when #385 settles the canonical tenant-id
charset — because if #385 rejects
/in tenant ids outright, A's remaining valueis defence in depth rather than a fix for anything reachable.
Relation to other issues
/, or forbidsleading/trailing separators, that closes the reachable paths here. This issue
should probably be resolved after Tenant ids have no canonical validator: divergent charsets and an infallible TenantId::new #385 decides, since the decision determines
whether option A is a fix or a hardening.
tenantspermanently breakslist_tenants()#271 — introduced the injective registry key but left the data path on thelossy trim; this is the other half of that fix.
Why this was not folded into #446
The Elasticsearch fix was safe to ship without a migration because its index is a
derived artifact rebuildable with
$reindex. That argument does not transfer:changing an S3 key prefix moves live objects that nothing else can reconstruct.
Bundling a change that needs an object migration into a PR that needs none would
have obscured exactly the risk that matters.