refactor(frontend): harden log decryption against unhandled rejections - #971
refactor(frontend): harden log decryption against unhandled rejections#971omlahore wants to merge 1 commit into
Conversation
envKeys.find(...) can legitimately return undefined: a log entry can reference an environment that no longer has a matching key locally, for example audit history retained for an environment the viewer has since lost access to, or one that was deleted. The non-null assertion on that result would throw inside an async function called with no .catch() and no surrounding try/catch, an unhandled rejection with the same shape as the bug fixed in phasehq#970. Guard against the missing case explicitly and log it instead of asserting non-null, and wrap the decrypt call itself in a try/catch so a genuine decrypt failure for one log entry doesn't do the same thing. I looked into whether envKeys should be a dependency of this effect, since it's read but not listed. It shouldn't: envKeys is state on the enclosing SecretLogs component, and LogRow is defined inside that component's render body, so a change to envKeys already produces a fresh LogRow closure and resets this effect regardless of what's in its dependency array. Confirmed via eslint's own exhaustive-deps message after trying it. Added a comment and the disable directive so this isn't re-litigated later, but did not change that behaviour.
|
Reviewing my own reasoning here, and the justification I gave for this is wrong. Correcting it rather than leaving it to a reviewer. I claimed a log entry can reference an environment with no matching entry in env_keys_filter = {"environment__app": app, "user": org_member, "deleted_at": None}
env_ids = list(EnvironmentKey.objects.filter(**env_keys_filter).values_list("environment_id", flat=True).distinct())
if not env_ids:
return SecretLogsResponseType(logs=[], count=0)So logs are scoped to exactly the environments the user holds a key for. The two lists are consistent by construction, and That leaves this PR as defence-in-depth rather than a live bug fix, which is a materially weaker claim than the one in the description. Two things I'd still argue for keeping it:
Happy for you to close this if you'd rather not carry a guard for an invariant the backend already enforces. I'd rather flag that myself than have it merged on a premise I've since disproved. If you do want it, I can retitle it to something like "harden log decryption against unhandled rejections" so the commit history doesn't claim a bug that wasn't there. |
|
Retitled from It's hardening, not a bug fix: an unhandled rejection becomes a legible failure, and |
What happened
components/logs/SecretLogs.tsx, in theLogRowsub-component:envKeyPaircan beundefined:Array.prototype.findreturns that whenever no entry matches. This isn't hypothetical here, a log entry can reference an environment that no longer has a corresponding key locally, e.g. audit history retained for an environment that's since been deleted, or one the current viewer has lost access to.The
!silences TypeScript but not the runtime. Dereferencingundefined!.keysthrows, and it throws inside an async function (decryptSecretEvent) that's called with no.catch()and no surroundingtry/catch:That's an unhandled promise rejection, the same shape as the bug fixed in #970 (different trigger, same underlying pattern: an async decrypt call with nowhere for its rejection to go).
The fix
if (!envKeyPair)guard that logs and returns, rather than crashing the row.try/catch, so a genuine decrypt failure for one log entry (corrupted ciphertext, key rotation mid-flight, whatever) doesn't hit the same unhandled-rejection shape either.A dead end I want to be upfront about
I initially also added
envKeysto this effect's dependency array, since it's read inside but wasn't listed, and I couldn't immediately see why that was safe. Runningeslintsurfaced its own answer:LogRowis defined insideSecretLogs's render body, so whenenvKeys(the parent's state) updates,SecretLogsre-renders, which recreatesLogRowas a fresh closure and resets this effect regardless of its dependency array. So a staleenvKeyssnapshot from before it finished loading doesn't get stuck, this effect gets another chance once the parent re-renders. I'd suspected the opposite (that a row could stay stuck showing its skeleton forever if key derivation resolved after this effect's one shot), and eslint's own reasoning is what told me that wasn't right. I've left a comment plus the disable directive so this doesn't get "corrected" back into a lint warning later without the context.I'm flagging this in case I'm still missing something about how the two components interact. I did not change this behaviour, only documented it, since I could not find a case where it's actually wrong once I understood why eslint was silent on it.
Testing
tsc --noEmitandeslint components/logs/SecretLogs.tsxare both clean on the changed file. I didn't add a test for this one: the fix is a straightforward null-guard plus a try/catch, and reproducing theenvKeyslookup miss faithfully would need mocking the same GraphQL/Apollo/keyring context stack as the page component, which felt disproportionate for a change this size, same reasoning as the test scope note in #970.