Skip to content

Commit 6ce4cb9

Browse files
fix(core): Correctness, robustness and cleanup from secret-field review
- Redact config args by definition only, not by whether the value looks encrypted, removing a corruption path for non-secret args whose value starts with enc:v1:. - Add the operation code to SecretAccessInput; type customField entity as possibly undefined (quotes / default-resolved types). - Remove the forbidden non-null assertion (lint error) and unify arg output shape. - Tolerate a single undecryptable value: log and fall back to the placeholder rather than failing the whole list query (transformer and resolver). - Give a clear error for a malformed ciphertext instead of a raw TypeError. - Check the key-check write result in the verifier so a failed write is not silent. - Reject secret:true with a defaultValue at registration. - Remove the now-empty PaymentMethodEntityResolver. Relates to #2648
1 parent 4d6c1fa commit 6ce4cb9

9 files changed

Lines changed: 71 additions & 26 deletions

File tree

packages/core/src/api/api-internal-modules.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ import {
6969
PaymentAdminEntityResolver,
7070
PaymentEntityResolver,
7171
} from './resolvers/entity/payment-entity.resolver';
72-
import { PaymentMethodEntityResolver } from './resolvers/entity/payment-method-entity.resolver';
7372
import {
7473
ProductAdminEntityResolver,
7574
ProductEntityResolver,
@@ -177,7 +176,6 @@ export const adminEntityResolvers = [
177176
AdministratorEntityResolver,
178177
CustomerAdminEntityResolver,
179178
OrderAdminEntityResolver,
180-
PaymentMethodEntityResolver,
181179
FulfillmentAdminEntityResolver,
182180
PaymentAdminEntityResolver,
183181
ProductVariantAdminEntityResolver,

packages/core/src/api/resolvers/entity/configurable-operation-entity.resolver.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { REDACTED_SECRET_PLACEHOLDER } from '@vendure/common/lib/shared-constant
44
import { GraphQLResolveInfo } from 'graphql';
55

66
import { ConfigService } from '../../../config/config.service';
7+
import { Logger } from '../../../config/logger/vendure-logger';
78
import { ConfigArgService } from '../../../service/helpers/config-arg/config-arg.service';
89
import { RequestContext } from '../../common/request-context';
910
import { Ctx } from '../../decorators/request-context.decorator';
@@ -15,9 +16,12 @@ import { Ctx } from '../../decorators/request-context.decorator';
1516
* config arg values are gated: a value is either decrypted (when the {@link SecretAccessStrategy}
1617
* permits) or replaced with a redaction placeholder, so no per-operation-type wiring is required.
1718
*
18-
* An arg is treated as secret if its definition declares `secret: true`, so that a value which is
19-
* not (or not yet) encrypted at rest — e.g. legacy plaintext written before the field was marked
20-
* secret — is still redacted rather than served in the clear.
19+
* An arg is treated as secret based on its definition (`secret: true`), so that a value which is not
20+
* (or not yet) encrypted at rest — e.g. legacy plaintext written before the field was marked secret —
21+
* is still redacted rather than served in the clear. Conversely, a non-secret arg whose value happens
22+
* to look like ciphertext is left untouched, so it is not accidentally redacted or run through
23+
* `decrypt()`. If `secret: true` is later removed from a def, any encrypted values already stored are
24+
* passed through unchanged until the operation is next saved.
2125
*/
2226
@Resolver('ConfigurableOperation')
2327
export class ConfigurableOperationEntityResolver {
@@ -33,15 +37,14 @@ export class ConfigurableOperationEntityResolver {
3337
const owner = this.deriveOwner(info);
3438
const output: ConfigArg[] = [];
3539
for (const arg of operation.args) {
36-
const isEncrypted = arg.value != null && encryptionStrategy?.isEncrypted(arg.value) === true;
37-
const isSecret = isEncrypted || this.configArgService.hasSecretArg(operation.code, arg.name);
38-
if (arg.value == null || !isSecret) {
40+
if (arg.value == null || !this.configArgService.hasSecretArg(operation.code, arg.name)) {
3941
output.push({ ...arg });
4042
continue;
4143
}
4244
const canReveal = secretAccessStrategy
4345
? await secretAccessStrategy.canAccessSecret(ctx, {
4446
kind: 'configArg',
47+
code: operation.code,
4548
entityType: owner.entityType,
4649
field: owner.field,
4750
argName: arg.name,
@@ -50,11 +53,22 @@ export class ConfigurableOperationEntityResolver {
5053
let value: string;
5154
if (!canReveal) {
5255
value = REDACTED_SECRET_PLACEHOLDER;
56+
} else if (encryptionStrategy && encryptionStrategy.isEncrypted(arg.value)) {
57+
try {
58+
value = encryptionStrategy.decrypt(arg.value);
59+
} catch (e: any) {
60+
// A single arg that cannot be decrypted must not fail the whole query.
61+
Logger.error(
62+
`Failed to decrypt secret arg "${arg.name}" of operation "${operation.code}": ` +
63+
(e.message as string),
64+
);
65+
value = REDACTED_SECRET_PLACEHOLDER;
66+
}
5367
} else {
54-
// When revealing, decrypt if encrypted; a legacy plaintext value is returned as-is.
55-
value = isEncrypted ? encryptionStrategy!.decrypt(arg.value) : arg.value;
68+
// A legacy plaintext value is returned as-is.
69+
value = arg.value;
5670
}
57-
output.push({ name: arg.name, value });
71+
output.push({ ...arg, value });
5872
}
5973
return output;
6074
}

packages/core/src/api/resolvers/entity/payment-method-entity.resolver.ts

Lines changed: 0 additions & 8 deletions
This file was deleted.

packages/core/src/config/system/default-encryption-strategy.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ export class DefaultEncryptionStrategy implements EncryptionStrategy {
8888
}
8989
const key = this.assertKey();
9090
const [ivPart, authTagPart, dataPart] = ciphertext.slice(CIPHERTEXT_PREFIX.length).split(':');
91+
if (ivPart == null || authTagPart == null || dataPart == null) {
92+
throw new Error(
93+
'The value is not a well-formed ciphertext (expected `enc:v1:<iv>:<authTag>:<data>`).',
94+
);
95+
}
9196
try {
9297
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivPart, 'base64url'));
9398
decipher.setAuthTag(Buffer.from(authTagPart, 'base64url'));

packages/core/src/config/system/secret-access-strategy.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,22 @@ export type SecretAccessInput =
3030
fieldName: string;
3131
/**
3232
* @description
33-
* The entity instance carrying the custom field.
33+
* The entity instance carrying the custom field. Available for the common case where the
34+
* entity's `customFields` are resolved by the built-in entity resolver, but may be
35+
* `undefined` for types whose `customFields` are resolved by GraphQL's default resolver
36+
* (e.g. `ShippingMethodQuote`/`PaymentMethodQuote`, or some plugin-defined types).
3437
*/
35-
entity: VendureEntity;
38+
entity: VendureEntity | undefined;
3639
}
3740
| {
3841
kind: 'configArg';
42+
/**
43+
* @description
44+
* The code of the configurable operation the arg belongs to, e.g. the payment method
45+
* handler or collection filter code. Always defined, and together with `argName` uniquely
46+
* identifies which secret is being accessed.
47+
*/
48+
code: string;
3949
/**
4050
* @description
4151
* The name of the entity type the operation belongs to, e.g. `'PaymentMethod'` or

packages/core/src/entity/register-custom-entity-fields.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ function registerCustomFieldsForEntity(
5959
'with "list: true".',
6060
);
6161
}
62+
if (defaultValue !== undefined) {
63+
throw new Error(
64+
`ERROR: The custom field "${customField.name}" cannot combine "secret: true" ` +
65+
'with a "defaultValue", because a column default would be stored unencrypted.',
66+
);
67+
}
6268
}
6369
const instance = new ctor();
6470
const registerColumn = () => {

packages/core/src/entity/value-transformers.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import type { EncryptionStrategy } from '../config/system/encryption-strategy';
2+
import { REDACTED_SECRET_PLACEHOLDER } from '@vendure/common/lib/shared-constants';
13
import { ValueTransformer } from 'typeorm';
24

3-
import type { EncryptionStrategy } from '../config/system/encryption-strategy';
5+
import { Logger } from '../config/logger/vendure-logger';
46

57
/**
68
* Decimal types are returned as strings (e.g. "20.00") by some DBs, e.g. MySQL & Postgres
@@ -39,7 +41,17 @@ export class EncryptedFieldTransformer implements ValueTransformer {
3941
const stringValue = String(value);
4042
// Only values produced by encrypt() may be passed to decrypt(). A legacy plaintext value,
4143
// written before the field was marked secret, is returned unchanged.
42-
return strategy.isEncrypted(stringValue) ? strategy.decrypt(stringValue) : value;
44+
if (!strategy.isEncrypted(stringValue)) {
45+
return value;
46+
}
47+
try {
48+
return strategy.decrypt(stringValue);
49+
} catch (e: any) {
50+
// A single row whose value cannot be decrypted (corrupted ciphertext, a manual edit, or a
51+
// partial key change) must not fail the entire query. Log and fall back to the placeholder.
52+
Logger.error(`Failed to decrypt a secret custom field value: ${e.message as string}`);
53+
return REDACTED_SECRET_PLACEHOLDER;
54+
}
4355
}
4456

4557
private strategy(): EncryptionStrategy {

packages/core/src/service/helpers/encryption-key-verifier/encryption-key-verifier.service.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,19 @@ export class EncryptionKeyVerifierService implements OnModuleInit, OnApplication
7979
// First use of an encryption key against this database: bind it to the current key.
8080
// The settings store value type does not accept a bare string, hence the cast (as in
8181
// the InstallationIdCollector).
82-
await this.settingsStoreService.set(
82+
const result = await this.settingsStoreService.set(
8383
ctx,
8484
SETTINGS_KEY,
8585
encryptionStrategy.encrypt(KEY_CHECK_PLAINTEXT) as any,
8686
);
87+
if (!result.result) {
88+
// `set` catches internally and returns a result rather than throwing, so without this
89+
// a failed write would silently leave the wrong-key guard un-armed on later boots.
90+
Logger.warn(
91+
'Could not persist the encryption key check; the key-mismatch guard will not be ' +
92+
`active until it succeeds. ${result.error ?? ''}`,
93+
);
94+
}
8795
return;
8896
}
8997
let decrypted: string | undefined;

packages/core/src/service/service.module.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ActiveOrderService } from './helpers/active-order/active-order.service'
1111
import { ConfigArgService } from './helpers/config-arg/config-arg.service';
1212
import { CustomFieldRelationService } from './helpers/custom-field-relation/custom-field-relation.service';
1313
import { CustomerChannelAssignmentService } from './helpers/customer-channel-assignment/customer-channel-assignment.service';
14+
import { EncryptionKeyVerifierService } from './helpers/encryption-key-verifier/encryption-key-verifier.service';
1415
import { EntityDuplicatorService } from './helpers/entity-duplicator/entity-duplicator.service';
1516
import { EntityHydrator } from './helpers/entity-hydrator/entity-hydrator.service';
1617
import { EntitySlugService } from './helpers/entity-slug.service';
@@ -29,12 +30,11 @@ import { PaymentStateMachine } from './helpers/payment-state-machine/payment-sta
2930
import { ProductPriceApplicator } from './helpers/product-price-applicator/product-price-applicator';
3031
import { RefundStateMachine } from './helpers/refund-state-machine/refund-state-machine';
3132
import { RequestContextService } from './helpers/request-context/request-context.service';
32-
import { EncryptionKeyVerifierService } from './helpers/encryption-key-verifier/encryption-key-verifier.service';
3333
import { SettingsStoreService } from './helpers/settings-store/settings-store.service';
34-
import { StoredMediaService } from './helpers/stored-media/stored-media.service';
3534
import { ShippingCalculator } from './helpers/shipping-calculator/shipping-calculator';
3635
import { SlugValidator } from './helpers/slug-validator/slug-validator';
3736
import { SlugService } from './helpers/slug.service';
37+
import { StoredMediaService } from './helpers/stored-media/stored-media.service';
3838
import { TranslatableSaver } from './helpers/translatable-saver/translatable-saver';
3939
import { TranslatorService } from './helpers/translator/translator.service';
4040
import { VerificationTokenGenerator } from './helpers/verification-token-generator/verification-token-generator';

0 commit comments

Comments
 (0)