Skip to content

Harden auth deactivation, remove unauthenticated OTP endpoints, stop routing table drain - #112

Open
memplethee-lab wants to merge 8 commits into
LintError:mainfrom
memplethee-lab:feature/auth-hardening-and-route-fixes
Open

memplethee-lab wants to merge 8 commits into
LintError:mainfrom
memplethee-lab:feature/auth-hardening-and-route-fixes

Conversation

@memplethee-lab

Copy link
Copy Markdown
Contributor

Summary

Fixes three assigned defects across the NestJS API and the Python AI engine: an
authentication bypass on deactivated accounts, two unauthenticated OTP admin
endpoints, and a routing service that destructively consumed its own chain list
and degraded over time.

#14 — deactivated accounts could still sign in

AuthService.signIn never checked user.isActive, so
PATCH /admin/users/:id/toggle-status had no effect on email/password login.
Every other auth path in the codebase already checks it (requestUserOtp,
verifyUserOtp, AdminService.login, AdminService.verifyOtp), which is what
made this easy to miss in review.

The check is placed after bcrypt.compare and reuses the generic
'Invalid credentials' message. Checking status first would return a distinct
message for a disabled account and turn POST /auth/login into a
user-enumeration oracle. The ordering is documented in the method comment so it
is not refactored away.

#15 — unauthenticated OTP admin endpoints

POST /auth/otp/process and POST /auth/otp/cleanup were labelled "Admin
endpoint" but AuthController carries no @UseGuards, so both were anonymous.
/otp/process forced OTP generation for every pending request on demand while
the service logged each generated code to stdout, and /otp/cleanup deleted OTP
rows older than 30 days.

Both operations already run on cron — processPendingOtpRequests every 30
seconds and cleanupExpiredOtps daily — which was the stated intent in the
original comments, so the HTTP triggers added no capability. Both removed.

Separately, the OTP cron no longer logs the code. It logs the request id and
user email only, so log aggregation cannot become an OTP-disclosure channel. The
console.* calls in the service are replaced with the NestJS Logger.

#16 — routing table was drained by ordinary traffic

find_best_routes assigned viable_chains = self.available_chains (an alias,
not a copy) and then used heapq.heappop, which removes elements in place. Each
request whose amount exceeded every chain's liquidity permanently deleted up to
limit chains from the long-lived routing table.

I reproduced the drain: 100 fallback calls took the table from 8 entries to
0.
After that optimal_route became None, and
PaymentsService.initiateTransaction dereferenced it unconditionally — an
unhandled TypeError: Cannot read properties of null 500 triggered by ordinary
traffic.

  • Copy the fallback list (list(...)) instead of aliasing it.
  • Use heapq.nsmallest, which does not mutate its input, replacing the manual
    heapify/heappop loop; clamp limit so a non-positive or oversized value
    cannot misbehave.
  • ai/main.py returns 503 when no route is viable, so it never emits a null
    optimal_route. HTTPException is re-raised so the 503 is not swallowed into
    a generic 500.
  • PaymentsService narrows the route and throws ServiceUnavailableException,
    so no record is written and nothing is broadcast. RoutingResult.optimal_route
    is now explicitly nullable rather than forcing a lie at the type level.

Verification

Per your instruction I did not run npm install, CI, or the test suites.
What I did run:

  • Drain reproduced and fix confirmed — modelled both the old and new
    algorithms with equivalent semantics: old drained 8 → 0 over 100 calls, new
    stayed at 8 with identical, deterministic results.
  • TypeScript syntax — parsed all 7 changed .ts files with tsc --noResolve (parse only, no module resolution, since node_modules is absent).
  • Python — no Python runtime is installed on this machine, so
    ai/tests/test_payment_routing.py has never been executed. It is written
    against unittest and the import style matches main.py, but it is unverified
    and is the first thing I would run manually.

CI needed one change to exercise the new tests: the AI Engine job only ran
python -m compileall, so no Python test was ever executed. Added a
unittest discover step (-t . makes ai/ top-level so the tests can use the
same from services... import ... style as main.py).

Notes for review

Closes #14
Closes #15
Closes #16

memplethee-lab and others added 8 commits October 12, 2026 14:56
signIn() never checked user.isActive, so an admin deactivating a user via
PATCH /admin/users/:id/toggle-status had no effect on password login - the
account kept working. Every other auth path in this service (requestUserOtp,
verifyUserOtp, AdminService.login, AdminService.verifyOtp) already checks it.

The check is placed AFTER bcrypt.compare and reports the same generic
'Invalid credentials' message. Checking status first would return a distinct
message for a disabled account and turn this endpoint into a user-enumeration
oracle. Documented in the method comment so the order is not refactored away.

Closes LintError#14

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /auth/otp/process and POST /auth/otp/cleanup were unauthenticated and
unguarded despite being labelled admin endpoints - AuthController carries no
@UseGuards at all.

/otp/process forced OTP generation for every pending request on demand, so an
anonymous caller could trigger generation and harvest the codes from the
service log. /otp/cleanup deleted OTP rows older than 30 days, so it was an
anonymous data-deletion endpoint.

Both operations already run on cron - processPendingOtpRequests every 30
seconds and cleanupExpiredOtps daily - which was the stated intent in the
original comments, so the HTTP triggers added no capability.

Closes LintError#15

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The OTP cron wrote every generated code to stdout:

    console.log(\[OTP Cron] Generated OTP \ for user ...\)

Combined with the removed unauthenticated trigger, that turned log access into
an OTP-disclosure channel. The code is now never logged - only the request id
and user email - with a comment so it is not reintroduced.

Replaces the console.log/console.error calls in this service with the NestJS
Logger, which routes through the app's log pipeline and honours log levels.

Closes LintError#15

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fallback branch assigned viable_chains = self.available_chains - an alias,
not a copy - and then used heapq.heappop, which removes elements in place. Every
request whose amount exceeded all chain liquidity permanently deleted up to
limit chains from the long-lived routing table.

Verified the drain: 100 fallback calls took the table from 8 entries to 0.
Routing degraded with request history until process restart, which is what made
payments start failing for reasons that depended on earlier traffic.

- Copy the fallback list with list(...) instead of aliasing it.
- Use heapq.nsmallest, which does not mutate its input, replacing the
  manual heapify/heappop loop.
- Clamp limit so a non-positive or oversized value cannot misbehave.

The list-comprehension branch was already safe and is left alone.

Closes LintError#16

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
analyze_routing returned a null optimal_route when no route was viable.
PaymentsService.initiateTransaction dereferenced that unconditionally, so an
unroutable transaction surfaced as an unhandled TypeError ("Cannot read
properties of null") - a 500 triggered by ordinary traffic once the routing
table had been drained.

Return 503 with a real message when no route is viable, and re-raise
HTTPException so the 503 is not swallowed into a generic 500 by the enclosing
except block.

Closes LintError#16

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
initiateTransaction read routingResult.optimal_route.chain unconditionally.
When the routing engine had no viable route the field was null, so the call
threw "TypeError: Cannot read properties of null" - an unhandled 500 that
surfaced as an internal error rather than a dependency failure.

Narrow the route once after the fraud check and throw
ServiceUnavailableException when it is absent, so the caller gets a 503 and no
transaction record is written or broadcast.

Makes RoutingResult.optimal_route explicitly nullable in AiService so the type
reflects reality instead of forcing a non-null assertion at the call site.

Closes LintError#16

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds regression coverage for LintError#14, LintError#15 and LintError#16. Each test was written to fail
against the unfixed code.

LintError#14 - signIn:
  - unknown email, wrong password, deactivated user, active user
  - a deactivated user with a correct password is rejected
  - no token is issued and lastLogin is not recorded for a rejected login
  - the message for unknown / wrong-password / deactivated is identical, so
    the endpoint cannot be used as a user-enumeration oracle
  - the password is compared before the account status is checked

LintError#15 - removed routes:
  - POST /auth/otp/process and /auth/otp/cleanup return 404
  - neither reaches the service methods
  - no HTTP handler remains bound to the OTP generation or cleanup jobs
  - the public /auth/otp/request and /auth/otp/verify routes still resolve

LintError#16 - routing:
  - the table survives 100 consecutive fallback calls with its contents intact
  - repeated calls return identical results
  - a freshly constructed service and a heavily used one agree, so results do
    not depend on call history
  - results are sorted, bounded by limit, and limit is clamped
  - _to_usd documents the USD unit for liquidity filtering
  - an unavailable route raises ServiceUnavailableException, is not a
    TypeError, broadcasts nothing, and writes no transaction record

Closes LintError#14
Closes LintError#15
Closes LintError#16

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The AI Engine job only ran a syntax check, so no Python test in ai/tests was
ever executed. Add a unittest discovery step so the routing regression tests
added for LintError#16 actually run.

`-t .` sets ai/ as the top-level directory, which is what lets the tests use the
same `from services... import ...` style as main.py.

Closes LintError#16

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant