Problem/Motivation

MintController::resolveFile() performs no entity access check and no field access check. Its only content test is isPublished().

Any holder of the signing secret can therefore mint a working download URL for any published gated file, by UUID, regardless of who they are or whether they could view the referencing entity.

That is a defensible server-to-server design, and it may well be the intended one. The problem is that the README's security-model section documents the cryptography thoroughly — HMAC-SHA256, hash_equals, use-burning, lock-serialised redemption, fails closed with no secret configured — and does not say that mint itself authorises nothing. An integrator reading that section can reasonably conclude the endpoint applies the same access rules the /system/files veto does. It does not.

The practical consequence is that the signing secret's blast radius is the entire gated corpus, and that fact has to be inferred from source rather than read from the docs. Anyone provisioning the secret into a second service — a decoupled front end, for instance — is making a much larger trust decision than the documentation signals.

Proposed resolution

Documentation, at minimum. State plainly in the README security model that mint is an unauthenticated-by-identity, secret-authenticated endpoint: possession of the secret is full authority over every published gated file. Say what that implies for where the secret may be deployed.

Optionally, two hardening directions worth discussing:

  • Identity-aware minting. Accept an optional acting account and run the referencing entity's access check, so a mint on behalf of a user cannot exceed that user's own rights. Would let a front end mint per-visitor rather than as an omnipotent service.
  • Scoped secrets. Support more than one signing secret, each with an allowlist of gated fields or media bundles, so a service that only ever serves one class of document cannot mint another.

Remaining tasks

  • README security-model section: state the mint trust boundary.
  • Decide whether either hardening direction is in scope for 1.x or a 2.x concern.

User interface changes

None for the documentation change.

API changes

None for the documentation change. Identity-aware minting would be an additive, optional request parameter.

Data model changes

None. Scoped secrets would add configuration, not schema.


Second, smaller finding in the same area

The README states that enabling gating "forces and locks the private file system" for the field. That is enforced only in file_gate_form_field_config_edit_form_alter() — a form alter. On a deployment where configuration is imported rather than entered through the UI (drush config:import, which is how most non-trivial sites ship config), the form never runs, so a field storage can carry gated: true alongside uri_scheme: public and the advertised guarantee does not hold.

Worth enforcing in config validation or a storage presave constraint rather than only in the UI, so the guarantee is true on every path that can create the config.

Test coverage note

The suite is Kernel-only. Nothing exercises the real /system/files route, image style derivatives, JSON:API file resources, or the deny hook with an empty secret configured. Functional coverage of the deny path seems worth adding given that path is the module's core purpose.

Comments

jmcerda created an issue. See original summary.

jmcerda’s picture

## Config-import half fixed; design settled for the rest

The second finding here is fixed on the 1.x branch. A field could carry `file_gate.gated: true` alongside `uri_scheme: public` after a configuration import, because the private-scheme lock is a *form* alter and `drush config:import` never runs the form. The configuration then asserted the files were gated, the admin UI showed them as gated, and they were readable by anyone with the URL — with nothing erroring, because nothing was broken. The gate simply never engaged.

Two guards, covering different sites: an import validator that rejects the combination and names the field, and `hook_requirements()` at ERROR for sites already in that state. The validator rejects rather than rewriting `uri_scheme` silently, which would make a site disagree with its own exported configuration and oscillate on the next export.

The README now also states the trust boundary in plain terms: **mint authorizes nothing**, so the secret's blast radius is the entire gated corpus and it must never be provisioned into a public web tier.

## The two remaining items are now separate issues

Both are design changes rather than defects, and they are worth scheduling independently:

- **Scoped signing secrets** — this is what actually addresses the finding in the title. Config holds `secret_id → scope` while settings.php supplies the values, so secrets stay out of exported configuration. Scope by field rather than media bundle, because gating is declared on the field storage and the resolver already keys on it. The signed URL gains a key id (`k=`, like a JWT `kid`) because the mint credential and the HMAC key are the same value today; absent `k` falls back to the single secret for compatibility. Scope is enforced at **redemption** as well as at mint, so narrowing a secret behaves like revocation rather than leaving outstanding grants live until expiry.

- **Optional identity-aware mint** — deliberately optional. This module exists because Drupal's entity access is too permissive for private files, so layering that access back onto mint adds a weaker guarantee rather than restoring one. The primary use cases (lead capture, campaign tokens, OTP) are anonymous and have no acting account at all, so a mandatory check would break them and would still do nothing about blast radius. It is defense in depth for the authenticated subset, and worth not overstating.

Staying open here as the umbrella record until both land.

jmcerda’s picture

Status: Active » Fixed

Released in [1.1.0](https://www.drupal.org/project/file_gate/releases/1.1.0).

**The config-import finding shipped.** A field could carry \`file_gate.gated: true\` alongside \`uri_scheme: public\` after an import, because the private-scheme lock is a *form* alter and \`drush config:import\` never runs the form. The configuration asserted the files were gated, the admin UI showed them as gated, and they were readable by anyone with the URL — with nothing erroring, because nothing was broken. An import validator now rejects the combination and names the field, and \`hook_requirements()\` reports sites already in that state.

**The README trust boundary** is now stated in the words this issue asked for: mint authorizes nothing, so the shared secret's blast radius is the entire gated corpus and it must not sit in a tier serving anonymous traffic.

The release notes lead with the upgrade consequence, since a site already in the bad state will now see \`config:import\` fail where it previously succeeded.

**Closing rather than keeping this open as an umbrella.** The two remaining items have their own issues, with the design settled on each, so this record adds nothing they do not carry:

- #3614157: Scoped signing secrets, so a front end that serves whitepapers cannot mint an NDA(https://www.drupal.org/project/file_gate/issues/3614157) — scoped signing secrets, Major. This is what actually addresses the finding in this issue's title.
- #3614158: Optional identity-aware mint, so a grant issued for a user cannot exceed that user's own rights(https://www.drupal.org/project/file_gate/issues/3614158) — optional identity-aware mint, Normal and deliberately so: it is defense in depth for the authenticated subset and does nothing about blast radius on the anonymous path.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

jmcerda’s picture

Status: Fixed » Closed (fixed)

Closing. Trust-boundary documentation shipped in 1.1.0; scoped signing secrets shipped in 1.2.0 (#3614157).