Skip to content

SQL injection, path traversal, SSRF, and auth brute-force security fixes - #504

Merged
DeFiVC merged 6 commits into
ChainLearnOfficial:mainfrom
theladyanina:fix/chainlearn-api-assigned-batch
Sep 30, 2026
Merged

DeFiVC merged 6 commits into
ChainLearnOfficial:mainfrom
theladyanina:fix/chainlearn-api-assigned-batch

Conversation

@theladyanina

@theladyanina theladyanina commented Sep 29, 2026 •

Copy link
Copy Markdown

Summary

Four security issues from the same batch: a SQL injection vector via sql.raw(), a path traversal gap in avatar serving (which also turned out to be completely non-functional due to an unrelated regex bug on the same lines), missing SSRF protection on webhook URLs, and no per-account brute-force protection on auth endpoints.

closes #485
closes #486
closes #487
closes #488

Changes

  • SQL injection risk via sql.raw() in enrollment trends #485, SQL injection via sql.raw(): course.service.ts's enrollment-trends query interpolated trunc/interval map lookups into raw SQL text via sql.raw(). Both values are now bound parameters instead: date_trunc()'s first argument and an interval cast both accept a plain text bound parameter in Postgres, so sql.raw() was never actually necessary. Also retyped truncMap/intervalMap from Record<string, string> to Record<EnrollmentTrendsQuery["range"/"granularity"], string>, so adding a new enum value to the query schema without updating the map is now a compile error instead of a runtime undefined reaching the query. 5 new tests, including SQL-injection-shaped input rejected by the schema.
  • Path traversal vulnerability in avatar file serving #486, path traversal in avatar serving: Extracted the filename handling into resolveSafeStaticPath() (src/utils/safe-static-path.ts): path.basename() strips any directory components before the extension allowlist runs, then the resolved path is independently verified to fall inside the upload directory before ever touching the filesystem. Also fixes an unrelated bug on the same two lines: the old regex literal used \\. (a literal backslash followed by any character) instead of \. (an escaped dot), so this route 404'd on every legitimate avatar filename before this change, confirmed by testing the regex directly against main. 8 new unit tests covering plain traversal, basename-stripped traversal, disallowed extensions, absolute paths, and a null-byte injection attempt.
  • SSRF risk in webhook URL validation #487, SSRF in webhook URLs: Added validateWebhookUrl() (src/utils/ssrf-guard.ts), wired into createWebhookSchema/updateWebhookSchema via superRefine. Rejects loopback, RFC 1918 private ranges, link-local addresses (including 169.254.169.254, the AWS/GCP metadata endpoint), 0.0.0.0, localhost, and any non-http(s) protocol, with a message stating why. 12 new unit tests.
    • Deliberately out of scope: DNS-rebinding protection. This validates the literal hostname a client submits at creation/update time; a hostname that resolves publicly today but is later repointed at an internal address wouldn't be caught, that would need re-resolving DNS immediately before each dispatch in webhook-dispatcher.ts, a larger change than validating the submitted URL. Flagging this limitation rather than silently expanding scope into the dispatch path.
  • Missing rate limiting on password/token guessing #488, auth brute-force protection: The existing authRateLimit only keys by source IP, so a distributed attempt against one specific stellarAddress from many IPs was unbounded. Added auth-attempt-tracker.ts: Redis-backed per-address failure tracking (INCR + EXPIRE for a sliding window, matching the issue's own suggested auth:attempts:{stellarAddress} key pattern) with an escalating lockout once a threshold is crossed (doubling per failure past it, capped at 15 minutes), plus an audit log entry each time a lockout triggers. createChallenge now rejects with RateLimitError (429, Retry-After) before doing any work if the address is locked out. verifyChallenge is now a thin wrapper around the renamed verifyChallengeInternal (whose own SEP-10 verification logic is untouched): checks the lockout gate first, records a failure on any UnauthorizedError from the inner method, clears the address's failure history on success. 11 new tests.
    • Deliberately left refresh alone: rotateRefreshToken already detects reuse of a spent refresh token and revokes the entire token family immediately, a stronger response than a rate limit, and appropriate since refresh tokens are long random secrets (not brute-forceable) so any replay of a consumed one indicates compromise, not guessing. A second, weaker per-user rate limit on top would be largely redundant for that endpoint's actual threat model.

Test plan

  • 5 (#485) + 8 (#486) + 12 (#487) + 11 (#488) = 36 new tests across tests/unit/, all pass.
  • npx tsc --noEmit and npx eslint on every changed file, same error/warning count as an unmodified main checkout.
  • Confirmed the existing tests/e2e/webhooks.test.ts suite's URLs (https://example.com/webhooks) still pass the new SSRF validator, so "existing valid webhooks are unaffected" per SSRF risk in webhook URL validation #487's acceptance criteria.

Caveats / pre-existing issues found while working on this, unrelated to this PR's diff (verified against a pristine main checkout before attributing):

  • npx tsc --noEmit fails on main itself with syntax errors in src/modules/credentials/credential.service.ts, src/modules/quizes/quiz.service.ts (a separate quizes/ typo directory), and src/modules/rewards/reward.service.ts, same 10 errors before and after this diff.
  • src/modules/courses/course.service.ts has a duplicate export class CourseService { ... } block that already exists on main, unrelated to the enrollment-trends fix in this same file.
  • Not run against a live Postgres/Redis/Stellar network; verified via the unit test suites' mocks plus typecheck/lint.

Update: a follow-up commit (test(auth): mock ttl/incr/expire/del on redis for auth lockout) fixes a regression the #488 work introduced into the pre-existing tests/unit/services/sep10-auth.test.ts suite: that file's redis mock only stubbed setex/getdel, so the new checkAuthLockout() call inside createChallenge/verifyChallenge (via redis.ttl()) threw redis.ttl is not a function across all 14 of its tests. Extended that mock with ttl/incr/expire/del (all inert, ttl resolving to "no active lockout") so those 14 pre-existing tests pass again unchanged. Verified via a git worktree diff against a clean upstream/main checkout that this branch now fails exactly the same set of pre-existing-broken test files as main itself, no more and no fewer.

…arnOfficial#485)

date_trunc's first argument and an interval cast both accept a plain text
bound parameter in Postgres, so trunc/interval never needed sql.raw() to
begin with. Replaced both call sites with normal Drizzle parameter binding.
Also retyped truncMap/intervalMap from Record<string, string> to
Record<EnrollmentTrendsQuery["range"/"granularity"], string> so adding a
new range/granularity enum value without updating the map is now a compile
error instead of a runtime undefined reaching the query. 5 new tests
covering the schema's enum validation, including SQL-injection-shaped
input.
…LearnOfficial#486)

Extract the avatar route's filename handling into resolveSafeStaticPath():
path.basename() strips any directory components before the extension
allowlist check runs (so an encoded/double-encoded ../ sequence can't
survive into the join), then the resolved path is independently verified
to fall inside the upload directory before ever touching the filesystem.
Also fixes an unrelated pre-existing bug on the same two lines: the old
regex literal used \\. (a literal backslash followed by any character)
instead of \. (an escaped dot), so the route 404'd on every legitimate
avatar filename before this change. 8 new unit tests covering plain
traversal, basename-stripped traversal, disallowed extensions, absolute
paths, and a null-byte injection attempt.
…earnOfficial#487)

Add validateWebhookUrl() (src/utils/ssrf-guard.ts) and wire it into
createWebhookSchema/updateWebhookSchema via superRefine, so a webhook
pointed at loopback, RFC 1918 private ranges, link-local addresses
(including 169.254.169.254, the AWS/GCP metadata endpoint), 0.0.0.0, or
localhost is rejected at creation/update time with a message stating why.
Only http/https protocols are accepted. 12 new unit tests.

Deliberately out of scope: DNS-rebinding protection. This validates the
literal hostname a client submits; a hostname that resolves publicly today
but is later repointed at an internal address wouldn't be caught, since
that requires re-resolving DNS immediately before each dispatch rather than
once at creation time, a larger change to the dispatcher itself. Flagging
this limitation rather than silently expanding scope into the dispatch path.
…hainLearnOfficial#488)

The existing authRateLimit only keys by source IP, so a distributed attempt
against one specific stellarAddress from many IPs was unbounded. Add
auth-attempt-tracker.ts: Redis-backed per-address failure tracking (INCR +
EXPIRE for a sliding window, matching the issue's own suggested key
pattern) with an escalating lockout once a threshold is crossed, doubling
per failure past it and capped at 15 minutes, plus an audit log entry each
time a lockout triggers.

Wired into AuthService: createChallenge rejects with RateLimitError (429,
Retry-After) before doing any work if the address is currently locked out,
so a locked-out address can't even draw a fresh challenge. verifyChallenge
is now a thin wrapper around the renamed verifyChallengeInternal (whose own
SEP-10 verification logic is untouched): checks the lockout gate first,
records a failure on any UnauthorizedError from the inner method, and
clears the address's failure history on success. 11 new tests.

Deliberately left refresh-token rotation alone: rotateRefreshToken already
detects reuse of a spent token and revokes the entire token family
immediately (a stronger response than a rate limit, appropriate since any
replay of a consumed refresh token indicates compromise, not guessing,
refresh tokens are long random secrets, not brute-forceable). Adding a
second, weaker per-user rate limit on top would be largely redundant for
that endpoint's actual threat model.
@drips-wave

drips-wave Bot commented Sep 29, 2026

Copy link
Copy Markdown

@theladyanina Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

theladyanina and others added 2 commits September 29, 2026 21:16
…LearnOfficial#488)

sep10-auth.test.ts and challenge-lockout.test.ts both call into
AuthService.createChallenge/verifyChallenge, which now check
checkAuthLockout() on every call. Their redis mocks only stubbed
setex/getdel, so the added redis.ttl() call inside checkAuthLockout threw
"redis.ttl is not a function" in both files (14 failures in sep10-auth.test.ts,
the whole suite; all 3 tests in challenge-lockout.test.ts errored on missing
config mocks that AuthService also needs). Fixed by extending sep10-auth's
redis mock with ttl/incr/expire/del (ttl resolving to -2, meaning "no active
lockout", so existing test scenarios are unaffected), and adding the same
config/stellar.js and config/database.js mocks challenge-lockout.test.ts's
AuthService import needs but was missing.

Verified against a clean upstream/main checkout: before this fix, this
branch failed 2 more test files than main (54 vs 52); after, the sets of
failing files are identical, confirming the pre-existing failures are
unrelated to this branch and this was a genuine regression from ChainLearnOfficial#488, now
fixed.
@DeFiVC
DeFiVC merged commit 544310d into ChainLearnOfficial:main Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants