Skip to content

Development - #207

Open
jordanlambrecht wants to merge 75 commits into
mainfrom
development
Open

Development#207
jordanlambrecht wants to merge 75 commits into
mainfrom
development

Conversation

@jordanlambrecht

Copy link
Copy Markdown
Owner

No description provided.

jordanlambrecht and others added 9 commits August 19, 2026 10:15
A TorrentLeech account with 2FA enabled cannot be polled at all today. The
login posts only username and password, TL answers with no session cookie, and
the adapter reports "Invalid TorrentLeech credentials" — sending the user off to
re-check a password that was never wrong.

TL's own login form takes a third input, alt2FAToken. It is NOT a TOTP secret
and nothing here computes a one-time code: the site issues a static token at
Site Profile => Alt 2FA Token for exactly this case, which is also how other
clients drive this login.

- parseTlCredentials reads an optional alt2FAToken. Blank is treated as absent,
  and the all-lowercase alt2fatoken spelling is accepted too, because that is
  the key other clients store it under and being strict about the capitalisation
  of a pasted token helps nobody.
- login() sends the field only when it is set, so a request for an account
  without 2FA is byte-identical to what it was before.
- A refused login now reads the body to say why. "One Time Password" in the
  response names 2FA and points at the Site Profile page; a refusal when a token
  WAS supplied blames the token as well as the password.
- Both credential entry points get an optional field: AddTrackerDialog and
  TrackerSettingsSheet's change-credentials flow.

Drive-by, called out for review: AddTrackerDialog's resetForm never cleared the
TorrentLeech username and password, so a closed dialog kept the password in
memory and re-showed it on reopen. Adding a third secret without fixing that
would have made it worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured against the live site: TorrentLeech answers a REJECTED login with
200 and the login page, and still sets tluid, tlpass, member_id, pass_hash and
session_id. The adapter's success check was "did we get a tluid", so a refused
login looked like a good one, the never-signed-in session was cached, and every
later page fetch came back as the login page — surfaced as "Session expired".

That error is what a user actually sees today when their account has 2FA, and
it points at exactly the wrong thing: it sends them off to refresh a session
that was never established, rather than telling them a token is missing. It
also made the Alt 2FA Token detection added in the previous commit unreachable,
since the cookie check passed before the body was ever examined.

A real login answers 302. The check is now tluid AND a redirect, falling back
to inspecting the body when it is not one — and the fallback rejects only on
POSITIVE evidence of a refusal (the One Time Password heading, a login-form, a
link to /user/account/login). A 200 that does not look like the login page is
still accepted, so this tightens a wrong check without inventing a new way to
fail for anyone it works for today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A user who configures a proxy still leaked their real address to TorrentLeech.
The profile GET was proxied; the login POST was not — so every
re-authentication, the one request that carries the password, went out
directly. For anyone proxying because the tracker must only ever see a
specific exit address, that is the whole feature failing on its most sensitive
request.

The comment in the adapter said this was impossible ("proxyFetch is GET-only
with no body support"). That has not been true for a while: proxyFetch takes
`method` and `body` already. The real gap was narrower — it discarded the
response, so a caller could not read the Set-Cookie it logged in for.

- proxyFetch exposes `headers` and adds `text()`. Without headers, no adapter
  that authenticates can ever use the proxy.
- TorrentLeech's login uses it when a proxyAgent is present and keeps the
  direct fetch otherwise, so nothing changes for users with no proxy
  configured. https.request does not follow redirects, matching the
  `redirect: "manual"` the direct path relies on to catch the 302 that carries
  the session cookie.
- The 2FA and bad-credential detection reads whichever body it got, so the
  error messages are identical on both paths.

Stacked on the Alt 2FA Token change — both touch login(), and the error
handling here builds on the branching added there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…parser

Every stat the TorrentLeech adapter reports except username and uploaded is
wrong, and each one is wrong in a way that looks plausible rather than broken,
so nothing surfaces it:

  field        reported
  class        "ic TL" -- for every account on the site
  downloaded   0 bytes -- for every account on the site
  ratio        Infinity, derived from that zero
  buffer       the entire upload total, derived from that zero
  seeding      the leading digits of a byte total
  leeching     the leading digits of a byte total
  hit & runs   null, meaning "this tracker does not expose it"

Four separate causes:

- `.profile-downloaded-details` does not exist. Only the uploaded figure
  carries a details class; its downloaded counterpart is a bare
  `.profile-info-details` span inside `.profile-downloaded`. The selector
  matched nothing, so every account parses as having downloaded zero bytes,
  which makes computeRatio return Infinity and computeBufferBytes return the
  entire upload total. An account that owes the tracker is reported as
  unconditionally healthy, and checkRatioBelowMinimum can never fire because
  Infinity < minimumRatio is false.

- The user class was matched with a regex over the whole document's text.
  Every TorrentLeech page carries a nav link reading "Classic TL", and it
  comes first, so /Class:?\s*\n?\s*([A-Za-z][A-Za-z ]*)/ captured "ic TL".

- Seeding and leeching counts took the first number in a top-bar cell that
  reads as a size followed by the count in parentheses. The first number is
  the leading digits of the byte total, not a torrent count.

- Hit and runs were hardcoded null while the page has a `title="Hit and Run"`
  cell all along. For a tool whose purpose is catching hit and runs, that is
  the number that matters.

Class and points now come from the semantic markup (`.label-user-class`,
`.total-TL-points`) with the old text-scraping kept as a fallback, so a layout
change degrades instead of silently returning a wrong answer.

The tests could not have caught any of this: the fixture was written from the
same assumption as the parser, inventing a `profile-downloaded-details` span
the site never emits, so it agreed with the bug. It is replaced with markup
copied from a live page -- nav link, top bar and asymmetric spans intact -- and
the parser now reproduces every figure that page displays, including the buffer
TorrentLeech prints itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Satisfaction was decided by seed time alone:

    const requiredSeedSeconds =
      rules?.seedTimeHours > 0 ? rules.seedTimeHours * 3600 : null
    const unsatisfied = requiredSeedSeconds
      ? torrents.filter((t) => t.seedingTime < requiredSeedSeconds)
      : []

Ratio never entered it, so a tracker whose rule is an either/or could not be
expressed. TorrentLeech states one outright — "There are two ways for you to
give back to the community": seed to 1:1, or seed for your class's minimum
time. Its registry entry therefore carried `seedTimeHours: 0`, and the
consequence was not that its torrents were judged leniently. It was that
`requiredSeedSeconds` came out null and the Unsatisfied table, the unsatisfied
count and the H&R-risk card **all silently disappeared** for that tracker. No
error, no empty state, just nothing — which is why it has gone unnoticed.

`rules.satisfactionMode: "any" | "all"` now says how the thresholds combine,
and satisfaction moves into `@/lib/satisfaction` as pure functions.

**Ratio participates only for an entry that declares a mode.** `minimumRatio`
has always been in the registry as an ACCOUNT-level figure — it drives the
ratio-danger alert and the analytics baseline. Reading it as a per-torrent
requirement wherever it is non-zero would re-interpret all 55 entries against
rules nobody has re-read, and for a seed-time-only tracker that lands in the
unsafe direction: torrents marked satisfied that are not, which is how hit and
runs are earned. An entry without a mode behaves exactly as it does today, so
the field doubles as a marker that a tracker's rules have been verified.

Progress is the nearer route under `any` and the laggard under `all`. That is
not cosmetic: it drives the sort order, and sorting on seed time alone buried a
torrent sitting at 0.99 ratio — releasable within the hour — beneath one with
days on the clock and no way to clear. "Remaining" now names which route is
nearer rather than always quoting seed time, and reports nothing owed once the
torrent is satisfied by the other route.

`ratioProgress` deliberately treats a non-finite or negative ratio as zero
progress rather than as complete. Clients report Infinity for a torrent that
has downloaded nothing and -1 as a sentinel; either one read as "far past the
requirement" would mark torrents satisfied that are not.

TorrentLeech's entry is filled in from its own wiki: `satisfactionMode: "any"`,
and `seedTimeHours: 240` — 10 days, the Registered-class requirement and the
longest on the site, chosen deliberately because the class is not knowable from
the registry and over-seeding is the safe direction to be wrong in.

28 tests, including the sentinel-ratio cases and an end-to-end check that the
TorrentLeech entry now resolves to a real requirement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Trivy failed the Docker Build & Scan job on 32 HIGH/CRITICAL findings in
two Go binaries under /schema-sync/node_modules.

The TypeScript 7 native compiler (10 HIGH) reaches the image only through
devDependencies, so the schema-sync stage now installs with --prod. That
needs --ignore-scripts alongside it, because the "prepare": "husky"
script is a devDependency and would otherwise fail the install.

esbuild 0.25.12 (22 findings, one CRITICAL) is built on go1.23.12 and
arrives via drizzle-kit, which pins ^0.25.x at both 0.31.10 and
1.0.0-rc.4. The 0.25.x line was abandoned after Nov 2025 and
drizzle-orm#5481 has been open since March 2026, so the esbuild override
is pinned to ^0.28.2 (go1.26.5) instead. That collapses three esbuild
copies to one and drops the abandoned @esbuild-kit path along with it
(esbuild ~0.18.20, GHSA-67mh-4wv8-2f99).

Seven Go stdlib CVEs are still genuinely unfixed at go1.26.5. They are
documented in .trivyignore rather than suppressed, and clear on the
first esbuild built with Go 1.27.

Verified: drizzle-kit push applies all 19 tables against postgres:18
from a --prod install, and Trivy reports no gobinary findings.
@github-actions

Copy link
Copy Markdown

Knip Code Analysis

Found 128 total issues

Category Count
Unused Dependencies 1
Unused Dev Dependencies 4
Unused Exports 60
Unused Types 63
View details

Run pnpm knip locally to see the full report.

Use pnpm knip:filter pattern to filter results by file path.


Use /** @public */ JSDoc tags to mark intentionally exported symbols.

@github-actions

Copy link
Copy Markdown

✅ Security audit passed

Passed (38/38)

  • ✅ Auth enforcement on protected routes (per-handler)
  • ✅ No dangerous functions (eval, innerHTML, etc.)
  • ✅ No hardcoded secrets in source
  • ✅ Security headers in next.config.ts
  • ✅ Cookie security (httpOnly, sameSite, secure)
  • ✅ No sensitive fields in API responses
  • ✅ No .env files committed to repo
  • ✅ No raw SQL in API routes
  • ✅ No fetch/redirect with unvalidated URLs in routes
  • ✅ Timing-safe comparison for secret values
  • ✅ No raw SQL migration files (schema-first only)
  • ✅ External fetch calls have timeouts
  • ✅ Docker container runs as non-root user
  • ✅ Public routes match proxy allowlist
  • ✅ File delete operations have path traversal defense
  • ✅ Password hashing uses Argon2 (not SHA-256/bcrypt)
  • ✅ Encrypted columns written via encrypt()
  • ✅ TOTP 2FA flow integrity
  • ✅ Emergency lockdown flow integrity
  • ✅ Scrub & delete (nuke) flow integrity
  • ✅ Backup restore flow integrity
  • ✅ Login flow integrity
  • ✅ Auth result checked before proceeding
  • ✅ Backup password inputs bounded before key derivation
  • ✅ Webhook delivery fetch uses redirect: "error"
  • ✅ SESSION_SECRET minimum-length guard in auth/crypto modules
  • ✅ Notification URL validators include SSRF protection
  • ✅ Dockerfile does not COPY sensitive files
  • ✅ No secret env vars in client components
  • ✅ Adapter Cookie headers guard against injection
  • ✅ Adapter files do not log credential values
  • ✅ No console.log in API routes
  • ✅ No TODO/FIXME in security-critical files
  • ✅ JSON.parse wrapped in try-catch
  • ✅ No swallowed errors in catch blocks
  • ✅ Request body size validation on upload routes
  • ✅ BigInt fields use string serialization
  • ✅ No raw error messages in API responses

Summary: 38/38 checks passed

See scripts/security-audit.ts for check definitions and SECURITY.md for the full security architecture.

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.

2 participants