feat(core): Encrypt secret custom fields and config args at rest - #5051
Conversation
Introduce the EncryptionStrategy (with an AES-256-GCM default keyed from the VENDURE_ENCRYPTION_KEY environment variable) and the SecretAccessStrategy (defaulting to a new Permission.ReadSecret check) which together underpin the `secret` field feature. Both are configured via systemOptions and initialised during bootstrap, and the server fails fast if a secret field is configured without a usable encryption key. Relates to #2648
…s at rest A custom field or config arg marked `secret: true` has its value encrypted via the configured EncryptionStrategy before being stored, and decrypted when loaded. Custom-field secrets use a TypeORM value transformer and are stored as unbounded text; config-arg secrets are encrypted in ConfigArgService.parseInput and decrypted in argsArrayToHash. When the API returns a redaction placeholder for a secret, submitting that placeholder back on an update preserves the stored value; on a create it is rejected. Relates to #2648
…values The decrypted value of a `secret` custom field or config arg is only returned to callers permitted by the SecretAccessStrategy (by default, holders of Permission.ReadSecret); everyone else receives a redaction placeholder. This adds field resolvers for PaymentMethod and ShippingMethod handler/checker operations, which previously returned the raw stored value, and applies the same rule to custom field resolvers. Secret custom fields are also excluded from the generated filter and sort inputs, since their stored value is ciphertext. Relates to #2648
Verify that secret values are stored encrypted, are returned decrypted only to ReadSecret holders and redacted otherwise, are preserved when the placeholder is submitted on update, and are rejected when the placeholder is submitted on create. Also updates the administrator snapshot for the new ReadSecret permission. Relates to #2648
Relates to #2648
Secret config arg handling was wired per-resolver and only covered PaymentMethod and ShippingMethod, so a secret arg on a collection filter or promotion condition/action returned raw ciphertext to any reader and double-encrypted on edit. Move redaction to a single ConfigurableOperation.args field resolver that gates every configurable operation in the API, keyed on the self-identifying ciphertext prefix, so any operation type (core or custom) is covered with no per-resolver wiring. Thread previously-stored values into the promotion and collection update paths so a resubmitted placeholder is preserved. Replace SecretAccessInput with a discriminated union on kind (customField | configArg) and validate that secret config args are non-list strings at bootstrap. Relates to #2648
Store a known value encrypted with the active key the first time an encryption key is used against a database, and verify it on every startup. If the configured key no longer matches (e.g. a database was restored into an environment with a different VENDURE_ENCRYPTION_KEY, or the key was changed), the server fails to start with a clear error instead of failing later with scattered runtime decryption errors. The check uses the settings store, so no schema change is required, and is purely additive: if the store cannot be read it is skipped. Relates to #2648
The DefaultEncryptionStrategy no longer reads VENDURE_ENCRYPTION_KEY from the environment directly. Following Vendure's convention that environment variables are read in the config and passed into strategies, the secret must be provided explicitly via the strategy's `secret` option. When a secret field or arg is configured without a usable key, the bootstrap error now shows the exact config to add. Relates to #2648
The bootstrap key-check value was registered in the settings store without a read permission, so any authenticated user could fetch it via getSettingsStoreValue. Because it is a known plaintext encrypted with the active key, that gave an offline brute-force oracle for the encryption secret. Restrict read access to the SuperAdmin, who already has full access to decrypted secrets. Relates to #2648
The systemic resolver redacted a config arg only when its stored value looked encrypted. A secret arg holding a plaintext value — e.g. data written before the field was marked secret, or via a raw insert — was served in the clear to any caller with the ordinary read permission, bypassing ReadSecret. Redact based on the arg's `secret` definition instead, so such values are redacted (and only decrypted, or returned as-is when legacy plaintext, for a ReadSecret holder). Relates to #2648
Key derivation used a single unsalted SHA-256 of the secret, which is cheap to brute-force offline for a weak or low-entropy secret. Switch to scrypt, a memory-hard KDF, so each guess is expensive, and warn at startup when the secret is shorter than the recommended length. The salt is a fixed application constant, since the key must be derived synchronously at bootstrap. Relates to #2648
|
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Dashboard Preview: https://admin-dashboard-kc91cqgxs-vendure.vercel.app |
dlhck
left a comment
There was a problem hiding this comment.
Requesting changes for four correctness and authorization issues in the secret-field implementation.
-
Repeated configurable operations are matched to previous values only by code. Duplicate promotion conditions/actions and collection filters can therefore preserve the first instance’s encrypted secret into later instances. Preserve by list position or stable instance identity.
-
SecretAccessStrategy receives the custom-fields wrapper rather than the documented owning VendureEntity, preventing reliable entity-aware authorization.
-
secret: true is silently accepted on relation custom fields because validation occurs only in the non-relation branch; such fields are neither encrypted nor redacted.
-
Legacy plaintext is passed unconditionally to EncryptionStrategy.decrypt() in custom-field transformers and configurable-operation execution, although the public strategy contract only requires decrypting values produced by encrypt(). Check isEncrypted() first.
Repeated configurable operations (duplicate promotion conditions/actions or collection filters sharing a code) were matched to their previous value by code alone, so every placeholder-bearing entry preserved the first entry's secret and overwrote the others. Match by code and by position among same-code entries so each preserves its own value. Relates to #2648
…fields The custom-field secret resolver passed the customFields wrapper object instead of the owning VendureEntity, breaking the documented SecretAccessInput contract and preventing entity-aware authorization. Retain and pass the actual entity. Relates to #2648
The secret field-type validation ran only in the non-relation branch, so a relation custom field with secret:true was silently registered without encryption or redaction. Validate the type and list constraints before branching so unsupported types are rejected at registration. Relates to #2648
Custom-field transformers and configurable-operation execution called decrypt() unconditionally, including on legacy plaintext, although the strategy contract only requires decrypt() to handle values produced by encrypt(). Check isEncrypted() first and return plaintext unchanged otherwise, so custom strategies need not tolerate non-ciphertext and enabling secret on existing data keeps working. Relates to #2648
…r vendure. Use 'vendure.encryption.keyCheck' rather than 'encryption.keyCheck', matching the framework convention for internal settings store keys (e.g. vendure.dashboard.*). Relates to #2648
A secret custom field or config arg with no explicit ui.component now renders as a masked (revealable) password input in the dashboard, and its read-only summary is masked too, so it is visually clear the value is sensitive even for a user permitted to read it. An explicit ui.component still takes precedence. Relates to #2648
The masked password input for secret fields relied on `fieldDef.secret`, but the dashboard's custom-field config and config-arg definition queries did not select `secret`, so it was always undefined and the input rendered as plain text. Add `secret` to both fragments and to the gql.tada schema types. Relates to #2648
Extract the 'secret fields default to the masked password input' logic into a single `resolveInputComponentId` helper used by both the form control and the configurable-operation summary, replacing the duplicated `secret` casts now that the field config types expose `secret` directly. Also forward `onBlur` and `name` from PasswordFormInput, so secret fields routed through it keep blur-driven validation and the input name attribute. Relates to #2648
When a user without permission to read a secret opened the field, the API's redaction placeholder (an internal round-trip sentinel) was shown in the input and, on reveal, displayed in plain text. Such a value is now rendered as a read-only "Hidden" field with no reveal toggle; the placeholder remains the form value so saving the entity still round-trips it and preserves the stored secret. The prefix used to detect the placeholder is inlined rather than imported from @vendure/common, whose CommonJS build cannot be imported as a runtime value in the browser bundle; a unit test asserts it against the real constant to prevent drift. Relates to #2648
Simplify the encryption key setup wording and explain why a secret arg must be a non-list string. Relates to #2648
Registers the new "Hidden — you do not have permission to view this value" label in the message catalogs. Non-source locales are left untranslated, to be filled by the periodic bulk translation pass like other pending strings. Relates to #2648
# Conflicts: # packages/dashboard/src/i18n/locales/ar.po # packages/dashboard/src/i18n/locales/bg.po # packages/dashboard/src/i18n/locales/cs.po # packages/dashboard/src/i18n/locales/de.po # packages/dashboard/src/i18n/locales/en.po # packages/dashboard/src/i18n/locales/es.po # packages/dashboard/src/i18n/locales/fa.po # packages/dashboard/src/i18n/locales/fr.po # packages/dashboard/src/i18n/locales/he.po # packages/dashboard/src/i18n/locales/hr.po # packages/dashboard/src/i18n/locales/hu.po # packages/dashboard/src/i18n/locales/it.po # packages/dashboard/src/i18n/locales/ja.po # packages/dashboard/src/i18n/locales/nb.po # packages/dashboard/src/i18n/locales/ne.po # packages/dashboard/src/i18n/locales/nl.po # packages/dashboard/src/i18n/locales/pl.po # packages/dashboard/src/i18n/locales/pt_BR.po # packages/dashboard/src/i18n/locales/pt_PT.po # packages/dashboard/src/i18n/locales/ro.po # packages/dashboard/src/i18n/locales/ru.po # packages/dashboard/src/i18n/locales/sv.po # packages/dashboard/src/i18n/locales/tr.po # packages/dashboard/src/i18n/locales/uk.po # packages/dashboard/src/i18n/locales/uz.po # packages/dashboard/src/i18n/locales/zh_Hans.po # packages/dashboard/src/i18n/locales/zh_Hant.po
…onfigs The struct-field-to-custom-field-config mapping did not set `secret`, which became a required property once the custom-field config type started selecting it. Struct sub-fields cannot be secret, so it is set to false. Relates to #2648
|



Summary
Adds a
secret: trueflag for custom fields and configurable-operation args. Values marked secret are encrypted at rest in the database and only returned in decrypted form via the API to callers permitted to see them (by default, holders of the newReadSecretpermission); everyone else receives a redaction placeholder.Fixes #2648
How it works
Encryption at rest. A configurable
EncryptionStrategy(default:DefaultEncryptionStrategy, AES-256-GCM) encrypts secret values before they are stored. Custom-field values are encrypted via a TypeORM value transformer; config-arg values are encrypted centrally inConfigArgService.parseInput. Ciphertext carries a versionedenc:v1:prefix, and legacy plaintext is passed through unchanged so enablingsecreton existing data does not break it.Gating decrypted access, centrally. Rather than wiring redaction into each resolver, a single field resolver on the shared
ConfigurableOperationGraphQL type gates the args of every configurable operation in the API — core or custom, on core or custom entities — with no per-operation-type wiring. Custom-field values are gated in the generic custom-field resolver. Both consult aSecretAccessStrategy(default:PermissionSecretAccessStrategy, which requiresReadSecret). Its input is a discriminated union onkind(customField|configArg), so each context carries only the information actually available to it.Editing without re-entering the secret. Because the API returns a placeholder, submitting that placeholder back on an update preserves the stored value; submitting a new value replaces it; on a create the placeholder is rejected.
Bootstrap safety.
secretfield/arg is configured but no usable key is available, the server refuses to start, and the error shows the exact config to add.Config-driven secret. The strategy does not read
process.envitself. Following the framework convention, the secret is read in the config and passed in explicitly:Dashboard
Secret custom fields and secret configurable-operation args render by default as a masked, revealable password input in the Dashboard, so it is visually clear that the value is sensitive even to a user who is permitted to read it. The same masking is applied to the read-only value summaries, so a redacted or decrypted secret is never shown in plain text there either. An explicit
ui.componentoverride still takes precedence over this default.The masked input reuses the existing
password-form-inputcomponent, and the default is resolved centrally inresolveInputComponentId, shared between the form control and the operation summary. For this to work the Dashboard's custom-field and config-arg definition queries now select thesecretflag.When the current user is not permitted to read a secret, the API returns the redaction placeholder rather than the value. The Dashboard recognises this placeholder and renders the field as a read-only "Hidden" input with no reveal control, so the internal placeholder is never shown to the user. The placeholder remains the field's form value, so saving the entity still round-trips it and preserves the stored secret unchanged.
Security hardening
An adversarial review of the design surfaced several issues, now addressed:
ReadSecret. Redaction is now driven by the arg'ssecretdefinition, so such values are redacted regardless of their stored form.Known follow-up (not in this PR): binding each ciphertext to its location with AAD would prevent an attacker with database write access from transplanting one secret's ciphertext into another field. Doing this at row-level granularity requires moving encryption into a layer that knows the entity's identity, so it is left as a separate hardening task.
Limitations
Testing
isEncrypted, does-not-read-env) and the key-check verifier (first-boot write, same-key pass, different-key fail, graceful skip).Docs
New "Secret fields" developer guide covering configuration, the access strategy, editing behaviour, moving data between environments, and limitations.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.