fix(api): harden input and cron robustness (NaN params, stablecoin crons, LNURL)#3981
fix(api): harden input and cron robustness (NaN params, stablecoin crons, LNURL)#3981TaprootFreak wants to merge 5 commits into
Conversation
… absichern Nicht-numerische :id-/Query-Werte wurden bisher per +id zu NaN gecoerct und flossen in Integer-Spalten-Queries, was unter Postgres einen HTTP 500 (statt 400) samt Error-Log auslöst. ParseIntPipe auf die echten numerischen Route-Parameter und das erforderliche buyId-Query liefert nun ein sauberes 400 vor dem Handler; optionale numerische Query-Parameter werden per Number.isInteger-Guard validiert. String-IDs (uid, externalId, LNURL-Routen, legacy-Sentinel) bleiben unverändert.
- getBridgeTvl: bei fehlendem EUR-Token-Preis die Bridge-Position überspringen statt durch undefined/0 zu dividieren (vermied NaN/Infinity im TVL). Der tote '?? 0'-Fallback in getBridgePriceFallback (fehlendes await) wird entfernt. - getDEPS/getJuice: im Fehlerfall rethrowen statt undefined zurückzugeben, damit der Cron keinen poolShares-losen Log schreibt, der den öffentlichen /info-Reader mit einem TypeError (HTTP 500) brechen würde. - processLogInfo: apiUrl in den Konfig-Guard aufnehmen (vorher nur graphUrl); der stille 0-Savings-Fallback in juice-client entfällt dadurch. - getPositionV2s: Pagination mit seen-Cursor-Set und Max-Pages-Limit gegen Endlosschleifen bei fehlerhaftem Indexer; Truncation wird geloggt.
createLnurlMetadata baute das LUD-06-Metadaten-JSON per String-Interpolation aus dem merchant-kontrollierten memo; ein Anführungszeichen/Backslash konnte das JSON brechen oder Einträge injizieren. JSON.stringify escaped korrekt und liefert immer gültiges Metadaten-JSON (description_hash bleibt selbstkonsistent).
❌ TypeScript: 1 errors |
Der Handler erwartet nach der ParseIntPipe-Umstellung number statt string; der Spec-Aufruf wird entsprechend von '1' auf 1 angepasst.
- frankencoin.getFPS: im Fehlerfall rethrowen (wie deuro/juice), damit der Cron keinen poolShares-losen Log schreibt, der /frankencoin/info bräche. - support-issue.getIssueSearch: numerischen id-Zweig per Number.isInteger guarden, damit der getFile-Endpoint nicht doch noch NaN -> Postgres-500 auslöst. - payment-link stickers: Number(id) -> +id für einheitlichen Coercion-Stil.
Danswar
left a comment
There was a problem hiding this comment.
Reviewed the diff against current develop (after #3954/#3979/#3978). The core is sound and the NaN→400 coverage genuinely holds for the user/account/custody tier — the remaining unguarded +id endpoints I checked (route.updateRoute, user.updateUserAdmin, bank-data) are all ADMIN/COMPLIANCE/SUPPORT-gated, matching the deferred bucket. A few points below, mostly inline.
No regression tests (PR-level). For a defensive change whose whole value is "these specific defects can't recur", nothing asserts the new behavior — that NaN now yields 400, that the cron rethrows instead of writing a partial log, that the LNURL memo is escaped. The only test delta is mechanically retyping one spec's id from '1' to 1. Nothing prevents a future refactor from reverting ParseIntPipe back to +id. A handful of targeted specs (one per defect class) would lock the fixes in.
Framing nit. The description says "no silent 0/NaN" / "a clear error beats a masked wrong value", but the continue in getBridgeTvl and the MAX_PAGES truncation both serve a silently-undercounted public TVL (warn-only). Better than a crash, but it's the same class of masked wrong value the PR says it eliminates — worth reconciling the wording with the behavior. Details inline.
|
|
||
| if (!eurTokenPrice) { | ||
| this.logger.warn(`Skipping bridge TVL for ${eurTokenContract.address}: no EUR token price available`); | ||
| continue; |
There was a problem hiding this comment.
This continue skips the bridge position entirely, so getBridgeTvl returns a TVL that is silently undercounted (warn-only) when an EUR price is missing. That is a quieter wrong value than the NaN it replaces, but it is still a wrong value served to the public /deuro/info — at odds with the PR's stated "no masked wrong value" principle. Worth either making the whole TVL computation fail-closed here, or explicitly documenting that undercount-with-warn is the accepted behavior.
| const positionV2s: DEuroPositionGraphDto[] = []; | ||
| const seen = new Set<string>(); | ||
| let after: string | null = null; | ||
| const MAX_PAGES = 100; |
There was a problem hiding this comment.
MAX_PAGES = 100 is an unjustified magic number. The query is positionV2s(after: $after) with no explicit first:, so the real cap is 100 × <indexer default page size> — unknowable from this code. If the position count ever approaches it, getPositionV2s silently returns an incomplete list and the downstream public TVL/borrow totals are wrong, with only a logger.warn as signal. Suggestions: (a) log the actual page count on every run, not just on cap-hit, and/or (b) set an explicit page size and derive the cap from realistic data headroom. Same applies to juice-client.ts.
| const positionV2s: JuicePositionGraphDto[] = []; | ||
| const seen = new Set<string>(); | ||
| let after: string | null = null; | ||
| const MAX_PAGES = 100; |
There was a problem hiding this comment.
Same MAX_PAGES concern as deuro-client.ts:77 — magic number plus silent truncation of a public-facing total.
| return juiceResult; | ||
| } catch (e) { | ||
| this.logger.error(`Error while getting pool shares ${juice?.id ?? 0}`, e); | ||
| throw e; |
There was a problem hiding this comment.
Rethrowing here aborts the whole processLogInfo cycle — collateralTvl, bridgeTvl, and positionV2s are already computed above and get discarded, and no log is written this cycle. The trade is correct (a stale-but-valid previous log beats a poisoned one that 500s every /info read), but note the blast radius: a transient RPC blip on the pool-shares call now costs the entire 10-minute sample, not just poolShares. Same pattern in deuro.service.ts:174 and frankencoin.service.ts:186. Fine to keep — just flagging it is a deliberate fail-bigger, worth a one-line code comment so the next reader does not "optimize" it back.
|
|
||
| static createLnurlMetadata(memo: string): string { | ||
| return `[["text/plain", "${memo}"]]`; | ||
| return JSON.stringify([['text/plain', memo]]); |
There was a problem hiding this comment.
Correct fix for the injection. One transient edge: this also changes the metadata string's whitespace (", " → ","), so description_hash (sha256 of this string) changes value. Within a single pay flow it stays self-consistent, since both the served metadata (payment-link.service.ts) and the invoice hash (lightning-client.ts) go through this function. But during a rolling deploy, an in-flight LNURL-pay where old-format metadata was already served and a new-format hash is then minted will mismatch and the wallet rejects the invoice. Low-probability and self-clearing, but worth being aware of when timing the deploy.
| @ApiOkResponse({ type: WalletAppDto }) | ||
| async getById(@Param('id') id: string): Promise<WalletAppDto> { | ||
| return this.walletAppService.getWalletAppById(+id).then((l) => this.toDto(l)); | ||
| async getById(@Param('id', ParseIntPipe) id: number): Promise<WalletAppDto> { |
There was a problem hiding this comment.
Minor: ParseIntPipe's isNumeric is /^-?\d+$/, so negative ids pass the pipe (e.g. /walletApp/-5 → -5). Harmless here (yields a 404), but "validated as integer" is not "validated as a valid id" — if any of these routes ever assume a positive id, ParseIntPipe alone will not guard it.
What this is
A repo-wide robustness sweep closing three classes of pre-existing defects that all share the same shape: a malformed input or a transient failure produces an HTTP 500 (or a wrong value) where a clean 400 / a well-defined error is correct. Surfaced while reviewing #3954 / #3964 / #3978 / #3979 and a subsequent full repo pass.
Guiding principle throughout: fail-closed / fail-loud instead of silent fallbacks — a clear error beats a masked wrong value.
1. NaN → Postgres 500 on numeric parameters
Problem. Many endpoints took a numeric ID as
@Param('id') id: stringand coerced it with+id. For non-numeric input this yieldsNaN, which flows into an integer-column query (WHERE id = $1). Postgres then throwsinvalid input syntax for type integer: "NaN"→ HTTP 500 + error log instead of a proper 400/404. Same class #3954/#3979 already addressed for the buy/sell/swap controllers — here consistently extended to the remaining genuinely numeric IDs.Fix.
ParseIntPipeon the genuinely numeric route params (and the requiredbuyIdquery); optional numeric query params are validated with aNumber.isIntegerguard (noParseIntPipe({ optional: true }), since that option does not exist in the pinned@nestjs/common@9.4.3and would break the build).Affected endpoints (user/account/custody-reachable, highest risk first):
GET /paymentLink/walletApp/:id(unauth)wallet-app.controller.tsPUT /realunit/buy/:id/confirm,sell/:id/confirm,sell/:id/unsigned-transactions,sell/:id/broadcast(USER)realunit.controller.tsGET /support/issue/:id/message/:messageId/file—messageIdvia pipe; the polymorphic numericidbranch ingetIssueSearchis guarded withNumber.isInteger(:idstays string for uid/quote prefixes)support-issue.controller.ts,support-issue.service.tsPUT /transaction/:id/target+?buyId,GET /transaction/:id/refund,PUT /transaction/:id/refund(ACCOUNT)transaction.controller.tsPUT /recommendation/:id/confirm,:id/reject(ACCOUNT)recommendation.controller.tsPUT /bankAccount/:id(ACCOUNT)bank-account.controller.tsPOST /custody/order/:id/confirm(CUSTODY)custody.controller.tsGET /transaction/detail/single(?id,?order-id),PUT /paymentLink/assign(?linkId),GET /paymentLink/stickers(?ids) — query guardstransaction.controller.ts,payment-link.controller.tsDeliberately NOT changed (string IDs — a numeric cast would break them): LNURL/Lightning routing IDs (
lnurlp/lnurld/lnurlw),custody-account/:id(accepts thelegacysentinel), the polymorphic id-or-uid paths intransaction.controller, andexternalLinkId/externalPaymentId/key/ KYCcode. These are intentionallystring.2. dEURO / juice / frankencoin — cron robustness
The stablecoin integrations run every 10 minutes (
*_LOG_INFOcron) and feed the publicGET /deuro/info,GET /juice/infoandGET /frankencoin/infoendpoints.getBridgePriceFallback()was missing anawait, which made?? 0dead code and let the function returnundefined;getBridgeTvlthen computedmintedValue / undefined→ NaN/Infinity in the TVL. Fix: skip and log the bridge position when the EUR token price is missing (mirroring the existinggetTvlByCollateralsguard) — no silent0/NaN. (dEURO only; juice'sgetBridgeTvldoes not divide by a price.)getDEPS/getJuice/getFPScatch without return. On an RPC blip the catch block returnedundefined; the cron then wrote a log withoutpoolShares, and the next/infocall readpoolShares.marketCap→ TypeError / HTTP 500 until the next successful cron. Fix: rethrow on error so no incomplete log is written — applied to all three sister integrations (dEURO, juice, frankencoin) for consistent error semantics.processLogInfoonly checkedgraphUrlbut also usedapiUrlunguarded. Fix: both in the guard; the now-redundant silent 0-savings fallback injuice-clientis removed.getPositionV2sranwhile (hasNextPage)with no cursor-progress or page limit — a faulty indexer could cause an infinite loop + growing array (OOM). Fix:seencursor set +MAX_PAGESlimit; reaching the cap is logged (visible truncation).3. LNURL metadata — JSON injection
createLnurlMetadatabuilt the LUD-06 metadata JSON via string interpolation from the merchant-controlledmemo([["text/plain", "${memo}"]]). A"or\in thememocould break the JSON or inject entries — visible to the paying customer viaGET /lnurlp/:id. Fix:JSON.stringify([['text/plain', memo]])escapes correctly and always produces valid JSON; both callers use the same function, so thedescription_hashstays self-consistent.Deliberately out of scope
+idendpoints (e.g.kyc-admin,buy-crypto/buy-fiatprocess,asset,mros,support,user-data) share the same NaN→500 class but are only reachable by privileged internal roles — low risk, a separate follow-up sweep makes sense.bridgeToDeuroMaxUint256 allowance: reviewed and confirmed not a bug (it sits inside theif (allowance.lt(weiAmount))guard) — left untouched.Tests / gates
npm run type-check✅ ·eslint(changed files) ✅ ·prettier --check✅ · affectedjestspecs ✅500 → 400. Conforming clients are unaffected.Release Checklist
Pre-Release
Post-Release