Skip to content

fix(sharing): publicSharing.eligibility is declared-but-unenforced — a draft or internal record mints a public share link and is served anonymously #7861

Description

@huangyiirene

Found while implementing share-link publishing in the HotCRM app (objectstack-ai/hotcrm#601, PR objectstack-ai/hotcrm#1103). Not fixable from that side, and the app deliberately shipped without the feature rather than declare a key that does nothing — the measurement is below.

Same class as #3865 (full access level declared Transfer/Share/Delete and was equivalent to edit), and #3865's resolution — remove the option rather than let it mislead — is one of the two routes here.

The key

packages/spec/src/data/object.zod.ts, inside publicSharing:

eligibility: z.string().optional().describe('CEL expression that must evaluate to true on the target record'),

with the TSDoc above it:

Optional CEL/JSONLogic predicate evaluated against the candidate record when a link is created. When the predicate returns false, the create call fails with 422 (e.g. "draft records cannot be shared"). Evaluator is the same one used by sharing rules.

Nothing evaluates it.ShareLinkService.getPolicy() builds its policy object from enabled / allowedAudiences / allowedPermissions / maxExpiryDays / redactFields and never reads eligibility; createLink() runs no predicate anywhere. A read across the whole installed platform at 17.0.0-rc.6 finds no consumer of the key at all — the only other hits for the word are unrelated prose in driver-sql.

The sibling keys are all genuinely enforced, which is what makes this one easy to miss in review:

keymeasured on 17.0.0-rc.6
enabledENFORCED — false ⇒ every create refused, SHARING_NOT_ENABLED
allowedAudiencesENFORCED — signed_in refused 422 AUDIENCE_NOT_ALLOWED
allowedPermissionsENFORCED — a non-view grant refused 422
maxExpiryDaysENFORCED — a longer expiry refused 422 EXPIRY_TOO_LONG
redactFieldsAPPLIED to every token-served response
eligibilityINERT

Measured end to end

Driving the real ShareLinkService against an object declaring exactly what the docs suggest —

publicSharing: {
enabled: true,
allowedAudiences: ['public', 'link_only'],
allowedPermissions: ['view'],
redactFields: ['owner_id'],
eligibility: "record.status == 'published' && record.audience == 'public'",
}
createLink(published + public, audience 'public') → OK
createLink(published + INTERNAL, audience 'public') → OK ← should be 422
createLink(DRAFT + public, audience 'public') → OK ← should be 422
resolveToken( the internal link , {}) → SERVED, anonymously

The last line is the impact: GET /api/v1/share-links/:token/resolve has no auth check, reads the record under SYSTEM_CTX and returns it. So the record an author believed unshareable is served to a caller with no principal.

Severity is bounded by the fact that link creation itself requires authentication (isAuthenticated(ctx) on POST /share-links) plus read access to the record — so this is not an anonymous-to-anonymous hole. It is: any staff user who can read a record can publish it to the open internet, past a policy the object author wrote specifically to prevent that, and the policy raises no error while being ignored. That is the shape a restrictive policy must never fail in — the schema's own history note beside this block makes the point about the sibling keys:

On a policy whose whole job is to be restrictive, a silently dropped key fails OPEN.

Why an app cannot work around it

Worth recording, because the obvious mitigation looks available and is not. The chokepoint exists: createLink persists every link with one engine.insert('sys_share_link', row, { context: SYSTEM_CTX }), and a beforeInsert hook on sys_share_link was measured working against a real ObjectQL engine — it fires despite the system context (triggerHooks filters on object name and session.skipAutomations, which that context does not carry), ctx.api reads the candidate record, and a throw refuses the link. It covers the console share dialog and a raw REST create alike.

But a metadata app cannot register it. validateCrossReferences in @objectstack/spec refuses any hook whose object is not in the stack's own objects:

Hook 'share_link_article_eligibility' references object 'sys_share_link' which is not defined in objects.

collectObjectNames reads config.objects only, so there is no wildcard escape either — '*' is not in that set. So the rule is expressible, enforceable, and unreachable from where apps live.

Two routes

A. Implement it. Evaluate the predicate in createLink before the insert, refusing with 422, using the same evaluator sharing-rule conditions use. Note that compileCelToFilter (the sharing-rule path) compiles to a pushdown filter and rejects things a record-level predicate should accept — has(record.x) among them, per objectstack-ai/hotcrm's measured operator matrix — so "the same evaluator" may need to mean the record-level one rather than the filter compiler. Worth deciding explicitly, since the TSDoc already promises the sharing-rule evaluator.

B. Remove the key, per #3865 / ADR-0078 precedent, so that authors cannot write a policy that silently does nothing. If B, @objectstack/spec's strict object will then reject eligibility outright, which is the loud failure this needs — and the docs paragraph promising the 422 must go with it.

A is worth preferring here, unlike #3865: an eligibility gate is the one thing that makes publicSharing safe to enable on any object holding a mix of public and internal records, which is most of them. Without it the only safe posture is enabled: false, and that is the posture objectstack-ai/hotcrm#1103 shipped — the feature was dropped rather than approximated.

Repro environment

17.0.0-rc.6 as pinned by objectstack-ai/hotcrm, @objectstack/plugin-sharing from that pin, driven through ShareLinkService directly and through a real ObjectQL + InMemoryDriver. Happy to hand over the probe scripts if useful.


Generated by Claude Code

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions