Skip to content

fix(sdk/javascript): validate the parsed GCP KMS config shape - #1203

Merged
stas-schaller merged 1 commit into
release/storage/javascript/gcp-kms/v1.1.0from
feature/KSM-1515-gcp-config-shape-validation
Sep 25, 2026
Merged

stas-schaller merged 1 commit into
release/storage/javascript/gcp-kms/v1.1.0from
feature/KSM-1515-gcp-config-shape-validation

Conversation

@mgallego-keeper

Copy link
Copy Markdown
Contributor

Summary

loadConfig() parses the config file and never checks the type of the result. JSON.parse() succeeds for null, 0, false, "", an array, and a primitive, none of which is the declared Record<string, string>, and each takes a different silently wrong path: falsy scalars leave the file in plaintext while the log claims encryption started; an array makes every later save a no-op that persists nothing; a primitive gets written to disk encrypted, and only the next saveString() throws a native TypeError. This is the same fail-open family KSM-1455/KSM-1486 closed for a zero-length file, left open for valid but wrong-shaped JSON.

What changes

src/GCPKeyValueStore.ts:

  • A new module-level isValidConfigShape() type guard: rejects null, non-objects, and arrays outright, then requires every value in the object to be a string.
  • A new private rejectInvalidConfigShape() throws a GCPKeyValueStorageError naming the config path, with the same message shape the existing zero-length checks use.
  • Both call sites in loadConfig(), the plaintext parse and the decrypted parse, call the check as its own statement immediately after their own JSON.parse has already returned, not nested inside that parse's own try/catch. A throw from inside that try would be caught by its own catch instead, which would misread a bad shape as "must be encrypted, try decrypting" (the plaintext site) or fold it into the generic decrypted-parse failure (the decryption site), losing the specific reason in both cases.
  • The plaintext site's check runs before the "given config file is not encrypted, starting encryption" log line, so that line no longer fires for content that was never a valid configuration to begin with.

CHANGELOG.md gets an entry under the existing v1.1.0 ### Security section.

Why it matters

Three groups of previously-silent failures, all reproduced against the real module:

  1. null, 0, false, "": the if (config) guard is falsy for all of these, so the save is skipped, this.config stays {}, and init() resolves successfully, but the file is left as plaintext on disk, exactly what the encryption step was supposed to prevent.
  2. [], [1,2,3]: this.config becomes an array. JSON.stringify ignores an array replacer when the value itself is an array, so the computed hash matches lastSavedConfigHash by coincidence, and every later saveString()/saveStorage() takes the no-op "no changes detected" skip and persists nothing, resolving successfully.
  3. 123, "a string": both are truthy, so the file is encrypted and overwritten with the primitive first, and only the next set() call throws a native TypeError when it tries to assign a property on a primitive.

Tests

Test Asserts Fails without the fix
TEST 1: init() rejects when the whole file content is null / 0 / false / "" Rejects with a named GCPKeyValueStorageError, file byte-for-byte unchanged, the misleading "starting encryption" log line never fires Yes
TEST 2: init() rejects for an empty or non-empty array, before any write Rejects, file unchanged Yes
TEST 3: init() rejects for a bare number or a quoted string, file untouched, not overwritten first File unchanged (the assertion that matters: today's bug overwrites first), rejection is GCPKeyValueStorageError, not a native TypeError Yes
TEST 4: three happy-path tests A normal plaintext config still encrypts and loads across processes; a normal encrypted config still loads; an empty {} still bootstraps a fresh first run No, by design
TEST 4: two explicit-rejection tests An object with a non-string value (a number, a nested object) is rejected, not silently coerced or dropped Yes
TEST 5: init() rejects when the DECRYPTED content is null / 0 / [] / 123 The same check holds on the decryption path, "the half most likely to be missed" per the ticket, built against a real encrypted blob via the package's own identity-wrap crypto mock Yes
KSM-1455 regression A zero-length file is still refused with the existing "is empty" message No, by design
Corrupt-file regression A decryptable file whose decrypted content isn't JSON at all still produces the existing "Failed to parse decrypted config file" error, not the new shape error No, by design

Verified by reverting only the source hunk and re-running: exactly the 14 tests above marked "Yes" fail, and only those.

npx jest --maxWorkers=5
Test Suites: 11 passed, 11 total
Tests:       143 passed, 143 total

npx tsc --noEmit and npx eslint 'src/**/*.ts' are both clean.

Verified against all four other PRs currently open on this release branch (#1201 KSM-1516, #1202 KSM-1514, #1197 KSM-1534, #1199 KSM-1517): merged all five together in a scratch clone, resolved three trivial same-anchor conflicts (this PR's own new declarations landing next to KSM-1514's in the same spot, and both CHANGELOG entries appending at the same line), and the combined tree passes 176/176.

Follow-ups (not in this PR)

  • Noticed in passing, not fixed here since it's unrelated to this ticket's scope: the if (jsonError && decryptionError) check a few lines below the decryption parse site is unreachable dead code. The catch block that sets decryptionError = true always throws its own error immediately afterward, so the later check can never actually observe that flag as true. Confirmed by a test in this PR that initially asserted the wrong (unreachable) message and had to be corrected.

Jira: KSM-1515

@stas-schaller
stas-schaller self-requested a review September 25, 2026 17:11

@stas-schaller stas-schaller left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean 👍

@stas-schaller
stas-schaller force-pushed the feature/KSM-1515-gcp-config-shape-validation branch from a90aac8 to b387ddb Compare September 25, 2026 17:43
@stas-schaller
stas-schaller merged commit 4fcc027 into release/storage/javascript/gcp-kms/v1.1.0 Sep 25, 2026
3 checks passed
stas-schaller added a commit that referenced this pull request Sep 29, 2026
…-1534-package-json-engines-overrides

Brings the branch up to date with #1200-#1203 and #1199 (it was cut before
#1200 merged). Resolves the CHANGELOG conflict by keeping both the KSM-1534
and KSM-1514 entries, and fixes the stale docs Mateo's review flagged:

- README.md: Node.js 20 -> 22 (this PR's own fix)
- CHANGELOG.md: rescope the "Both entries below" intro to the two entries it
  actually describes (KSM-1450/1458), not the newly-adjacent KSM-1534 entry
- CHANGELOG.md: KSM-1534 entry no longer implies a released version ever
  declared >=20 (none did; 1.0.0 had no engines field at all)
- CHANGELOG.md: KSM-1500 entry drops the now-false "CI already tests on
  Node 20" claim, since CI tests on 22 as of this same ticket
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.

2 participants