User-agent redaction in the shared pino config โ
- Status: Landed 2026-08-30 on
feat/admin-and-merchant-portals. The pino half is done and proven live. The Cloud Run half is untouched and cannot be touched from this repo. - Issue:
#1004: fix(services): every Cloud Run service logs the raw user-agent, so "we collect no device data" is not accurate - Parent brief: device data collection, which surfaced this finding while answering
#1003. - Operator decision: "For the user agent, lets definitely sanitize and remove please. Especially if it won't break anything. Lets fix it today."
- Follow-up landed the same day: the App Check header from the sibling survey below.
#1007, recorded in What the sibling survey produced.
What was this, exactly? โ
Hygiene, not a leak. โ
- A user-agent is not PII. It carries no phone number, no name, no address. It is a fingerprinting input: a string that narrows which device a request came from, and which combines with other signals to re-identify.
- It was in the logs because pino-http serializes every request header by default, not because anyone decided to keep it. Retention by default is the whole defect.
- The operator's framing: "it is moreso we are not capturing PII as much as possible, such as phone numbers, names, etc." Read this as reducing what we hold, not as closing a breach.
Nothing read it, so removing it broke nothing. โ
- A repo-wide grep across all seven services for any read of the user-agent header returns zero hits. No route, no middleware, no rate limiter.
- The browser reads its own
navigator.userAgentinapps/web/src/lib/deviceOptimization.js,locationPermission.jsanddebugMobileAuth.js. That is a page inspecting itself locally and transmitting nothing. Out of scope, unchanged.
What changed? โ
One path in one shared file, inherited by all seven services. โ
packages/shared/services/index.jsgains'req.headers["user-agent"]'inPINO_REDACT_CONFIG.paths.- Every service passes that same object to
pinoHttp, so the single entry covers auth, venues, lanterns, analytics, merchants, assistant and docs.services/api/auth/src/index.js:70is the one that matters most, since it handles phone sign in. - The redacted value becomes
[REDACTED], the config's existing censor, so a log line keeps its shape and a grep for the field still finds it.
How was it proven? โ
The unit test proves the config scrubs; the live run proves the service does. โ
- A passing unit test is not proof that a running service redacts, so both were done.
Test: packages/shared/__tests__/services/pinoRedact.test.js โ
- Four cases, run through a real pino logger built with
PINO_REDACT_CONFIGand a memory sink, not an assertion about the array's contents alone. - Confirmed meaningful: replaying the same payload through the pre-fix path list emits the probe user-agent in full, so the test fails against the old config.
- Full shared suite after the change: 180 passed, 14 files.
Live: auth-api and lanterns-api, run locally on ports 8194 and 8195 โ
- Started with
PORT=<n> node --env-file=.env.local src/index.json non-default ports, so neither collided with the two other lanes sharing this checkout. - Probe request carried
User-Agent: LANTERNUAPROBE1004/1.0 (Pixel 8; Android 14).
Before, the auth-api log line:
json
"headers": {
"host": "127.0.0.1:8194",
"accept": "*/*",
"user-agent": "LANTERNUAPROBE1004/1.0 (Pixel 8; Android 14)",
"referer": "https://example.test/probe-referer",
"x-forwarded-for": "203.0.113.77",
"accept-language": "en-GB,en;q=0.9",
"sec-ch-ua-platform": "\"Android\"",
"authorization": "[REDACTED]"
}After, same request, same service:
json
"headers": {
"host": "127.0.0.1:8194",
"accept": "*/*",
"user-agent": "[REDACTED]",
"referer": "https://example.test/probe-referer",
"x-forwarded-for": "203.0.113.77",
"accept-language": "en-GB,en;q=0.9",
"sec-ch-ua-platform": "\"Android\""
}grep -c LANTERNUAPROBE1004over the whole post-fix log file returns 0, for both services.lanterns-apiwas run as a second service to confirm the inheritance claim rather than assert it from the import graph.
What does the Cloud Run gap leave open? โ
This issue is HALF closed, and calling it done would be wrong. โ
- Cloud Run's serving infrastructure writes its own request log to
run.googleapis.com%2Frequestswith anhttpRequeststruct. That struct includesuserAgent, plusremoteIp,referer,requestUrl,statusandlatency. - No application-side redact config can reach it. It is emitted by the platform, outside the process, after our code has returned.
PINO_REDACT_CONFIGis structurally incapable of touching it. - So after this change, the raw user-agent is still retained for every request to every deployed service. What changed is that our own log line no longer duplicates it.
What it would actually take. โ
- A Log Router exclusion on the
_Defaultsink for that log name drops the platform request entries. It is all-or-nothing: exclusions drop whole entries and cannot null one field, so latency and status observability go with it. - A scrubbing sink, routing the entries to Pub/Sub or BigQuery, stripping
userAgentthere, and excluding the originals from_Default. That keeps the observability and is real infrastructure to build and own. - Shorter bucket retention on
_Default(30 days by default) is minimization rather than removal, and is the cheapest partial step.
Retention is still unverified, and the reason is unchanged. โ
gcloudis installed on the dev VM, at/home/mechelle/google-cloud-sdk/bin/gcloud, it is just not onPATH, sowhich gcloudreturns nothing and this document previously read that as absent. Retention and exclusion filters can be read and set from here using the absolute path. (Corrected 2026-08-30; the earlier "not installed" note was wrong, not stale.)
What did the sibling headers show, and what changed? โ
Nothing but the user-agent was changed. Everything below is a proposal. โ
- pino-http serializes the whole headers object, so every header a client sends lands in the log unless a path redacts it. These were observed in real log lines during the live run, not inferred.
| Header | What it is | Finding | Proposal |
|---|---|---|---|
x-forwarded-for | The client IP chain | Load bearing. Express derives req.ip from it under trust proxy, and req.ip is what the rate limiters and abuse defence key on (packages/shared/middleware/rateLimiter.js:109, services/api/auth/src/routes/phoneOtp.js, appeals.js) | Do not redact without a decision. Redaction affects the log line only, not req.ip, so it would not break rate limiting. But an IP is the primary abuse-investigation signal and dropping it from logs is a security tradeoff the operator should make deliberately |
x-firebase-appcheck | A Firebase App Check attestation token | The strongest finding here, and it is a credential rather than a fingerprint. The web app attaches it to essentially every API call (apps/web/src/lib/apiClient.js:49, signupApi.js:63, flash.js:457, plus the auth screens) and it landed in the log in full, verified live | DONE, #1007. Proposed here, then fixed the same day on the operator's call. See What the sibling survey produced |
referer | The referring URL | Effectively harmless here. All three apps set Referrer-Policy: strict-origin-when-cross-origin (apps/{web,admin,site}/public/_headers), and calls to the Cloud Run services are cross-origin, so a browser sends the origin only. Lantern also uses hash routing, and browsers never send the fragment in a referer regardless | Leave it. It carries no path, no venue id, no route. It is mildly useful for spotting which app called |
accept-language | Language and region preference | A fingerprinting input in the same family as the user-agent, and a weak region signal. Nothing reads it | Candidate for the same treatment, on the same reasoning. Not applied, because the operator's decision named the user-agent |
sec-ch-ua-platform and the sec-ch-ua-* family | User-Agent Client Hints | The successor to the user-agent string, and the reason a UA-only fix ages badly. Chromium sends sec-ch-ua, sec-ch-ua-mobile and sec-ch-ua-platform by default. Verified live: sec-ch-ua-platform: "Android" reached the log untouched | Candidate, and the more durable fix. A wildcard entry would cover the family as browsers add hints. Not applied, same reason |
x-client-version | The app's own version header | Ours, non-identifying, useful | Leave it |
remoteAddress and remotePort | pino-http's own socket fields, beside headers | On Cloud Run these are the load balancer, not the client, so they are low value rather than sensitive | Leave it |
The pattern worth naming: the body paths in this config were never the main event. โ
PINO_REDACT_CONFIGcarries elevenreq.body.*paths, and pino-http's default serializer does not log bodies at all. Those entries are belt-and-suspenders against a future serializer change.- The headers are what actually reach Cloud Logging. That is where the next attention belongs.
What the sibling survey produced โ
The App Check header was the one finding she took, and it landed the same day. โ
- The survey above proposed it; the operator ruled to fix it in this lane, scoped to that header alone.
#1007carries the whole survey so the rows nobody acted on do not get rediscovered. 'req.headers["x-firebase-appcheck"]'joinsPINO_REDACT_CONFIG.paths, besideauthorizationrather than beside the user-agent, because it is a credential.
Severity, at the volume it actually warrants. โ
- It is not a user credential. An App Check token attests that a request came from a genuine app instance, so a leaked one buys someone an App Check bypass for its remaining lifetime, and nothing else. No account access, no user data.
- That still puts it in
authorization's class rather than the user-agent's, and there was never a reason to retain it. It is worth fixing at exactly that volume and no louder. Writing it up as worse than it is trains people to skim the label, which is the real cost.
Proven the same way, because the standard held. โ
- Test: two more cases in
packages/shared/__tests__/services/pinoRedact.test.js, driving a real pino logger. One asserts the token does not survive; one pins the lowercase path, since Node's HTTP parser lowercases every incoming header name before it reachesreq.headerswhile the web app sendsX-Firebase-AppCheck. Confirmed meaningful by replaying the payload through the pre-fix path list, which emits the token in full. Shared suite: 182 passed across 14 files. - Live:
auth-apion port 8194, probed withX-Firebase-AppCheck: APPCHECKSENTINEL1007.notarealtoken.value. The log line now reads"x-firebase-appcheck": "[REDACTED]", andgrep -cfor the sentinel over the whole file returns 0. Repeated onlanterns-apion port 8195, also 0.
It does not break App Check, and that was tested rather than reasoned about. โ
- Redaction is applied by the serializer at log time, so the middleware still reads the live header. Demonstrated on the same gated route,
POST /auth/phone/createUser, with the middleware active and enforcing:
| Request | Response | What it proves |
|---|---|---|
| No App Check header | 401 APP_CHECK_REQUIRED | The middleware sees a genuinely absent header |
| The sentinel token | 403 APP_CHECK_FAILED | The middleware sees a present value and hands it to Firebase, which rejects it on its own merits |
- If the middleware had been reading
[REDACTED], both cases would have returned 403 and the 401 would be impossible. - The same argument already had a precedent:
req.headers.authorizationhas been in this redact list from the start, andverifyFirebaseTokenreads it on every authenticated call in production.
One second log path was checked and is clean. โ
- The middleware's own failure warn logs
error: err?.messagefrom the Firebase verifier, which is outside whatPINO_REDACT_CONFIGcovers, since the config redacts header paths rather than arbitrary strings. - Verified live: the message is generic (
Decoding App Check token failed. Make sure you passed the entire string JWT which represents the Firebase App Check token.) and does not echo the token. Worth re-checking if the firebase-admin verifier's error text ever changes.
What was not done? โ
- Nothing under
apps/. The browser-sidenavigator.userAgentreads are local and out of scope. - Client Hints were left alone. The operator did not take the
sec-ch-ua-*wildcard option. The point that a user-agent-only fix ages badly still stands and is recorded on#1007for later. x-forwarded-forandaccept-languageunchanged. Both remain proposals in the table above.- No Cloud Run, Log Router, or retention change. Not possible from here and not authorized.