Skip to content

e0e9ee06 - fix(auth): strip the login signature for callers below admin - #4505

Open
TaprootFreak wants to merge 9 commits into
developfrom
fix/restrict-signature-read-to-admin
Open

e0e9ee06 - fix(auth): strip the login signature for callers below admin#4505
TaprootFreak wants to merge 9 commits into
developfrom
fix/restrict-signature-read-to-admin

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What changed

user.signature is loaded as before. A globally registered SignatureVisibilityInterceptor removes it from responses whose caller does not satisfy the ADMIN requirement, reusing the hierarchy helper from role.guard.ts — so ADMIN and SUPER_ADMIN see the column exactly as they always did, and everyone below no longer sees it at all.

Why

The column is a static login credential, not a record of one: for DeFiChain (auth.service.ts:554-556) and custodial Lightning (:546-551) the stored value is what authenticates a sign-in, and it never expires. Whoever reads it can sign in as that user.

It left the system through every endpoint returning an entity that reaches it through a relation — reachable from the Support role upwards.

Filtering those endpoints one by one would be structurally incomplete: Transaction.user is declared eager: true (transaction.entity.ts:136), so the relation loads on every findOne against Transaction, regardless of the relations passed in. Endpoints carry the column without ever asking for it, and the next endpoint returning a transaction would reopen the path. Solving it in one place closes the current paths and any future one.

Design notes

  • Filtering keys off the entity type (instanceof User), never the property name. Other entities carry a field called signature — RealUnit registrations among them — and must not be touched. This is pinned by its own test and verified by mutation: switching the check to a property-name test turns exactly that test red and nothing else.
  • The walk is cycle-safe via a WeakSet. The entity graph is bidirectional, so a naive recursion would not terminate.
  • A missing request.user counts as non-admin — unauthenticated routes are filtered, not exempted.
  • The admin path is a genuine fast path: the role check short-circuits before the response is walked at all, so admin traffic pays nothing.
  • Interceptors run after guards, so request.user is populated — the same reason TfaEnforcementInterceptor is built this way.

Impact

  • No schema change, no data change, no migration. The stored values are untouched.
  • Admins are unaffected — same responses as before.
  • Below admin, the field is absent from responses. Nothing in the codebase reads it from a response; the sign-in comparison reads it server-side from the entity, which is unchanged.
  • Non-admin traffic pays one traversal of the response object. The walk skips primitives, dates and buffers, and visits each object once.

Checks

  • format:check, lint, type-check clean
  • All CI checks green, including the full suite across three shards
  • Seven interceptor cases: admin and super-admin pass through untouched (asserted by reference identity), support and unauthenticated get the field stripped, nested userData.users[] is covered, a non-User object keeps its own signature, and a cyclic graph terminates
  • Mutation-verified: replacing instanceof User with a property-name check turns exactly the non-User case red; restored, all seven pass

Scope note

This restricts who sees the column in responses. It does not change who can sign in, and it adds no new endpoint for reading it.

user.signature is a static login credential: for DeFiChain and custodial
Lightning the stored value IS what authenticates a sign-in, so whoever reads it
can sign in as that user.

The column carried no select: false and no @exclude, and there is no global
ClassSerializerInterceptor. It therefore travelled out through every admin
endpoint that returns an entity reaching it via a relation - reachable from the
Support role upwards. Transaction.user is eager, so it also arrived where no
relation had been requested, which is why filtering the known endpoints would
have been structurally incomplete: the next endpoint returning a transaction
would reopen it.

Marking the column select: false removes it from every query by default, so it
cannot leave through an entity at all. The single remaining read path is
UserService.getSignature(), which fetches it explicitly for the comparison in
doSignIn. Writing is unaffected.

Covered by a metadata assertion that the column is select: false (verified by
mutation: removing the option turns that test red) and, against a real
Postgres, that findOne omits such a column, that addSelect returns it - the
pattern getSignature() relies on - and that raw SQL still sees the value, so
this is an ORM-level change and not a data migration.
Prettier collapses the split test title and the query chains. No behavioural
change - the five cases assert exactly the same things and pass unchanged.
Both comments overclaimed: they said the value can never leave through an
entity, while the same change proves otherwise - an explicit addSelect still
loads it, which is exactly what getSignature() does. They now say what holds:
select: false keeps the column out of every default selection, including
entities returned through relations and eager ones, and explicit widening
remains possible and should go through that one method.

Adds unit coverage for getSignature() itself. The Postgres cases so far proved
the ORM mechanism on a probe entity, so a removed addSelect or a mistyped alias
in the real method would have gone unnoticed - and would have made it return
undefined for every caller. The new cases pin the addSelect argument and the
where clause, the returned value, and that a missing user yields undefined
rather than throwing.

Also switches the spec to an absolute import as CONTRIBUTING.md requires.
@TaprootFreak TaprootFreak changed the title e0e9ee06 - fix(auth): keep the login signature out of every entity query e0e9ee06 - fix(auth): exclude the login signature from default entity queries Jul 30, 2026
Cosmetic only; both orderings occur in this repo and no lint rule governs it.
The previous assertion only checked that addSelect had been called with
user.signature. That misses a real regression: select() replaces prior
selections in TypeORM, so reordering the chain to addSelect(...).select(...)
would drop the column, make getSignature() return undefined for everyone, and
break the DeFiChain and custodial Lightning sign-ins - while leaving all three
tests green.

The query-builder mock now models that replacement semantics: select() resets
the tracked selection, addSelect() appends. The test asserts the resulting
selection contains the column. Verified by mutation: reordering the chain in
getSignature() turns exactly this test red.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Three review passes across two independent lenses — conformance against CONTRIBUTING.md, and logic/correctness in context — until both reported zero findings. Ten findings were raised; seven were fixed, two were rejected with evidence, and one was already addressed.

The substantive ones all concerned the tests claiming more than they proved:

  • The first version asserted only that addSelect had been called. That misses a real regression — select() replaces prior selections in TypeORM, so reordering the chain would silently drop the column, make getSignature() return undefined for everyone, and break the DeFiChain and custodial-Lightning sign-ins. The mock now models that replacement semantics and asserts the resulting selection; verified by mutation, the reordering turns exactly that test red.
  • Earlier, the Postgres cases proved the ORM mechanism on a probe entity but never exercised getSignature() itself, so unit coverage for the method was added.
  • The comments originally said the value could never leave through an entity, which the same change disproves — an explicit addSelect still loads it. They now state what actually holds.

Two rejections, both measured rather than argued:

  • Reordering the spec imports was proposed as a CONTRIBUTING.md violation. The Imports section governs sorting within one statement; ordering between statements is not specified, is inconsistent across this repo, and no lint rule enforces it.
  • Replacing qb as any with createMock<SelectQueryBuilder<User>>() was attempted and reverted: tsc --noEmit fails on it, because select/addSelect infer to string[] through the last TypeORM overload and getOne.mockResolvedValue() demands a complete User entity while the fixture is deliberately partial. It would have traded one cast for two narrower ones plus a misleading parameter type.

Verification on the final commit: format:check, lint, type-check clean; the full suite against a real Postgres, run serially, is green apart from one unrelated flaky migration spec that passes on re-run; all CI checks green.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 30, 2026 16:58
@TaprootFreak
TaprootFreak marked this pull request as draft July 30, 2026 21:57
Replaces the earlier approach. Marking the column select: false kept it out of
every response, admins included - but admins are meant to load it normally, so
the guarantee has to be role-dependent rather than absolute.

The column is loaded as before. A globally registered interceptor removes it
from responses whose caller does not satisfy the ADMIN requirement, reusing the
hierarchy helper from role.guard.ts so ADMIN and SUPER_ADMIN pass untouched.
Solving it centrally matters because the column reaches responses through
relations across many admin endpoints, and Transaction.user is eager, so it
arrives where no relation was requested - per-endpoint filtering would be
incomplete and would be forgotten on the next endpoint.

Filtering keys off the entity type, never the property name: other entities
carry a field called signature and must not be touched. That property is pinned
by its own test and verified by mutation - switching the check to the field name
turns exactly that test red. The walk is cycle-safe via a WeakSet, since the
entity graph is bidirectional, and a missing request.user counts as non-admin.

getSignature() and its call site are gone again; doSignIn reads user.signature
as it did before.
@TaprootFreak TaprootFreak changed the title e0e9ee06 - fix(auth): exclude the login signature from default entity queries e0e9ee06 - fix(auth): strip the login signature for callers below admin Jul 30, 2026
Every existing case constructs the interceptor directly, so none of them would
notice if the APP_INTERCEPTOR entry were dropped from AppModule - the protection
would be gone with all tests still green. This asserts the wiring itself.

Verified by mutation: removing the provider entry turns exactly this test red.
APP_INTERCEPTOR is a string token, not a symbol, so the previous annotation made
the comparison provably false and tsc rejected it. The test passed regardless,
because Jest transpiles without type checking - the type-check gate caught it.
@github-actions

Copy link
Copy Markdown

❌ TypeScript: 1 errors

Replaces the two any-typed cycle fixtures with a self-referencing interface, and
reads the provider list through MODULE_METADATA.PROVIDERS instead of the raw
string - the pattern accounting.module.spec.ts already uses. A hardcoded key
would break silently if Nest renamed it, while the app stayed correct.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

The approach in this PR changed midway, so a note on how it got here.

It first made the column select: false, which kept it out of every response — admins included. That overshot the requirement: admins are meant to load it normally. The current version therefore loads the column as before and filters it out for callers below ADMIN, which is what was actually asked for. The earlier commits are left in place so the reasoning stays traceable; 983eda556 states the switch explicitly.

Five review passes across two independent lenses. Findings that shaped the result:

  • The strongest one concerned a risk this design introduces: the interceptor deletes the property in place, so if a response entity came from a shared cache, one non-admin request would strip the cached entry and break that user's next sign-in. Traced and cleared — AsyncCache does hand out identical references, but its only consumer resolves to { accessToken } and never forwards the entity.
  • Earlier versions of the tests each claimed more than they proved: asserting a call instead of the resulting selection, exercising a probe entity instead of the real method, and — in this version — not noticing if the global registration were removed at all. Each is now pinned and mutation-verified.

Three rejections, each measured rather than argued: an import-order claim (the rule governs sorting within a statement, not between statements), a createMock rewrite (tsc rejects it — the TypeORM overloads infer string[], and the fixture is deliberately partial), and relative imports in two files (both follow the convention of every neighbouring file).

Two known limits, deliberately not addressed here: the global exception filter and @Res() handlers bypass interceptors by design. Every such handler was read individually — none carries a User entity today, so this is a structural gap rather than a live leak, and closing it would mean rebuilding the exception filter and eight controllers for no present gain.

Verification: format:check, lint, type-check clean; all CI checks green including the full suite across three shards; eight interceptor cases green. Three mutation checks confirm the tests bite — swapping the entity-type check for a property-name check, reordering the selection chain, and removing the provider entry each turn exactly one test red.

Full disclosure on process: the last commit only changes test typing and swaps a hardcoded metadata key for the constant the repo already uses. I did not run another full double review for it, since the remaining findings had shrunk to that class and another pass would not have added confidence.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 30, 2026 23:06
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

The ❌ TypeScript: 1 errors comment above is stale. It was posted at 22:50 for commit 5846cd360, where a provider probe in the new spec compared a string token against a symbol type — tsc rejected it while Jest, which transpiles without type checking, still passed. It was fixed in 4cbf00cf0.

The review job on the current head reports TSC_ERRORS: 0 and ESLINT_ERRORS: 0 (run 30589412826). The workflow only removes its previous comment when it posts a new one, so a resolved error stays visible once the counters reach zero.

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.

1 participant