I didn't call it done after fixing where entrusted API keys leaked; I handed the defense to the code

日本語

Contents of this article

Summary

In another web app I develop through my personal company, I found that an API key a user brought in could leak through two paths, the error response and the server log, and I closed both. This app has a feature where a user brings their own API key to call an external API, and it takes as a design premise that the entrusted key is not saved on the server and is left in neither the log nor the database. What I chose this time was a design decision: not to call it done after fixing the two spots I found, but to keep guarding that premise itself by entrusting it to a permanent test and to a guard that makes no place for the app to write its data in the first place.

The defect I found was that the exception handling in two endpoints that call an external API was emitting the message string of the caught exception into the response body and the console output. If a string coming from the request, for example a URL with the key mixed in, rides on that message, the entrusted key leaks into both the response to the very user who brought the key and the log of the server I operate. There were two such paths.

I built the fix in three layers. The first layer is the implementation fix: I fixed the response body to a generic failure message, removed the detail field, and closed the response side and the log side at once. The second layer is a permanent test that runs a decoy key, planted with a mark that stands out at a glance, through every branch, and checks at runtime that the mark appears at no destination other than the authentication header to the external API. The third layer is a static analysis that rejects, at the point of declaration, an identifier whose name suggests persistence, together with a guard that narrows where the app leaves its data to only the predetermined storage inside the device, so no place to write is created at all. I stacked the three layers in three consecutive commits, and without changing a single existing test, grew the tests from 199 to 234, all passing throughout.

What I wanted to write about in this case is less the fixing of the key leak in two spots itself than how to keep the fix defended. Checking that the exception message contains no key before emitting it looks safe, but whether a key rides on it changes with the environment it runs in and the way the failure occurs, so you cannot fully judge the presence of a key by looking at the message alone. So rather than relying on a person to remember the operational promise of leaving no key, I made a form where the code creates no place to write the data in the first place.

The body available with a subscription follows the order in which I made the decisions. It begins with the design that decided not to hold the key on the server and the exception handling leak path that nearly broke that premise, and traces the fix that closed the response and the log at once. Next comes how I assembled the permanent test that runs a decoy key and the static analysis and guard that let no place to write be created, both stacked to keep the fix defended. Finally I write the limits of what each of the three layers catches and misses, and the points not yet confirmed, and close by placing this key incident on top of the checks I have accumulated so far.

Who this is for and what you can take away

This article is for developers who build a service on the premise of not storing a secret entrusted by users, for example an authentication secret like an API key or a token, and who want to keep that non-storing premise defended as the behavior of the implementation rather than as an operational promise. You can take away a way of thinking that catches and closes, as a single type, the failure where a string that came from outside rides straight onto the output, as the exception message string does; the three-layer way of assembling, onto the implementation fix, a runtime test that runs a decoy key with a planted mark through every branch and a static analysis and guard that reject at declaration an identifier suggesting persistence; and the discernment of the limit of what each layer misses. You can also take away, as a concrete design decision, how an operational promise to stop using the persistence API differs from a structural sealing that makes no place to write in the first place. This article is based on a key leak I actually found and fixed in another web app I develop through my personal company, and the record of the tests and guards I stacked to keep that fix defended.