Skip to content

fix: mask a custom field's secret for an API caller who may not see it - #830

Merged
blaipr merged 1 commit into
mainfrom
fix/the-api-masks-a-custom-field-secret-too
Aug 20, 2026
Merged

fix: mask a custom field's secret for an API caller who may not see it#830
blaipr merged 1 commit into
mainfrom
fix/the-api-masks-a-custom-field-secret-too

Conversation

@blaipr

@blaipr blaipr commented Aug 20, 2026

Copy link
Copy Markdown
Member

account/view?customFields=1 answered with the decrypted value of an encrypted custom field, whoever asked.

ItemTrait::getCustomFieldsForItem() decrypts whenever the row carries a key, without asking who is looking — the deciding is left to whoever renders it. The theme does decide:

// aux-customfields.inc
value="<?php echo !$_getvar('showViewCustomPass') && !empty($field->value) ? '***' : $_e($field->value); ?>"

AccountHelper sets showViewCustomPass from the account's own view-password permission, and CUSTOMFIELD_VIEW_PASS maps to the same isAccViewPass profile flag. That flag is consulted in five web view controllers and nowhere else. Nothing decided on the API path, so the adapter returned 'value' => $data->value.

Why this is more than an omission

The API is otherwise deliberate about exactly this. Its account transform returns login, url, notes and metadata — but not pass, because retrieving a password is account/viewPass, a separate action needing a separate token. A token scoped to account/view was handing out what that separation exists to gate.

Api::setupUser() loads the user's profile into context, so the check was possible all along.

The change

The ACL is threaded through the Adapter base — the three adapters that include custom fields (Account, Client, Category) already autowire it, so DI needed no change, and the wiring tests confirm the container still builds them.

Masking matches the theme's *** rather than dropping the key: the response keeps its shape and still reports encrypted: true, so a caller can tell a masked secret from an empty field.

A value that was never encrypted is returned as it is. Masking every custom field would have been a different change and a wrong one — testAValueThatWasNeverEncryptedIsReturnedRegardless pins that.

Scope left open, deliberately

The web applies the per-account view-password permission; this applies the profile-level CUSTOMFIELD_VIEW_PASS. Closing that last gap means threading the account's permission object into an adapter shared with Client and Category, which have no per-account notion. The substantive leak — a caller with no password permission at all reading secrets — is closed; the residual is recorded rather than silently half-done.

Verification

Reverting the mask fails testAnEncryptedValueIsMaskedForACallerWhoMayNotViewPasswords. Six existing adapter tests construct these positionally and broke on the new parameter — which is what a full-suite run is for.

3994 unit + 976 integration pass; PHPStan level 6 on src and PHPCS clean.

`account/view?customFields=1` answered with the decrypted value of an encrypted
custom field, whoever asked.

`ItemTrait::getCustomFieldsForItem()` decrypts whenever the row carries a key,
without asking who is looking — the deciding is left to whoever renders it. The
theme does decide: `aux-customfields.inc` prints `***` unless
`showViewCustomPass`, which `AccountHelper` sets from the account's own
view-password permission, and `CUSTOMFIELD_VIEW_PASS` maps to the same
`isAccViewPass` profile flag. That flag is consulted in five web controllers and
nowhere else. Nothing decided on the API path.

What makes it more than an omission is that the API is deliberate about the
account's own password: the account transform returns login, url, notes and
metadata but not `pass`, because retrieving a password is `account/viewPass` — a
separate action, with a separate token. A token for `account/view` was handing
out what that separation exists to gate. `Api::setupUser()` puts the profile in
context, so the check was possible all along.

The ACL is threaded through the Adapter base, which the three adapters that
include custom fields already autowire, and the masking matches the theme's
`***` rather than dropping the key — the response keeps its shape and still
reports `encrypted: true`, so a caller can tell a masked secret from an empty
field. A value that was never encrypted is returned as it is; masking every
custom field would have been a different change, and the third test pins that.

Six existing adapter tests construct these positionally and broke on the new
parameter, which is what a full-suite run is for.
@blaipr
blaipr merged commit 8e2365e into main Aug 20, 2026
8 checks passed
@blaipr
blaipr deleted the fix/the-api-masks-a-custom-field-secret-too branch August 20, 2026 18:10
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