[Regression on closed #550] No `trust proxy` setting — behind a load balancer every client shares the proxy's IP for throttling, logs and handoff auditing

オープン
#741 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

まだ誰も着手していません。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
53/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
express, typescript
領域
backend, security

調査の方向性

Start in src/main.ts at bootstrap(), then read src/app.module.ts for the throttler and src/auth/auth.controller.ts for IP logging. Check configuration.ts, .env.example, the README, and main.spec.ts for the proposed configuration, deployment guidance, and test coverage. Done means proxy trust is configured safely from settings and the documented test passes.

索引モデルが issue の本文から書いたものです。

説明

bug security

Location

  • src/main.ts:29-50 — bootstrap(); the Express instance is never configured with trust proxy
  • src/app.module.ts:33-38 — ThrottlerModule.forRoot([...]), which keys on the client IP
  • src/auth/auth.controller.ts:83, 90, 100-113 — @Ip() ip used in the handoff-exchange audit log

Problem

grep -rn "trust proxy\|getHttpAdapter" src/ returns nothing. The app is created with NestFactory.create(AppModule, { rawBody: true }) and never calls app.set('trust proxy', ...) (or the equivalent via app.getHttpAdapter().getInstance()).

Express defaults trust proxy to false. With it disabled, req.ip is the TCP peer address and X-Forwarded-For is ignored entirely. In any deployment where the Node process sits behind a reverse proxy, ingress controller or cloud load balancer — which is every realistic production topology, and the one the repo's own Docker/Compose tooling implies — the TCP peer is always the proxy. So req.ip is a single constant value for the entire internet.

This is closed issue #550, unchanged.

Why it matters

Three separate mechanisms silently degrade, all of them security-relevant:

1. Rate limiting collapses into one global bucket. ThrottlerGuard is registered as an APP_GUARD (app.module.ts:70-73) and its default tracker is the client IP. With every request reporting the proxy's address, the configured limits stop being per-client and become per-deployment:

  • default: 120 requests/minute for all users combined. A moderately busy platform exceeds that on legitimate traffic alone, at which point every user starts receiving HTTP 429 — a self-inflicted outage.
  • @Throttle({ short: { limit: 1, ttl: 1000 } }) on POST /github/sync/:owner/:repo (github.controller.ts:31) becomes one sync request per second globally.
  • @Throttle({ short: { limit: 3, ttl: 1000 } }) on GET /auth/github (auth.controller.ts:37) becomes three login initiations per second for the entire platform.

Conversely, the protection the limits were added for disappears: a single attacker gets the whole shared budget and is indistinguishable from every other client, so there is no way to throttle them without throttling everyone.

2. The handoff-exchange audit log records the wrong address. AuthController.exchangeCode logs rejected handoff attempts with the caller's IP specifically so that bursts of guessed or replayed codes are visible server-side:

this.logger.warn(`Handoff exchange rejected on ${route} from ${ip}: invalid or expired code`);

Every one of those lines will name the load balancer. The log line exists to answer "who is doing this?" and cannot.

3. Protocol-dependent logic sees the wrong scheme. Without trust proxy, req.protocol is http for every request even when the client connection was HTTPS, and req.secure is false. Any present or future code that branches on those (cookie secure handling, redirect construction) will make the wrong decision behind TLS termination.

Reproduction / failure path

  1. Run the backend behind any reverse proxy, e.g.:
# nginx
location / {
  proxy_pass http://127.0.0.1:4000;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
}
  1. From two different client machines, send 121 requests in under a minute through the proxy to any route, e.g. GET /api/bounties.
  2. Observed: the 121st request receives HTTP 429 regardless of which machine sent it — the two clients share one bucket. Expected: each client has its own 120/minute allowance.
  3. Attempt a bad handoff exchange from either machine (POST /api/auth/handoff with {"code":"bogus"}) and check the logs: the warn line reports 127.0.0.1 (the proxy), not the real client.

Suggested fix

Configure trust explicitly in bootstrap(), after NestFactory.create and before the guards and middleware run:

const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
app.set('trust proxy', configService.get('trustProxy', { infer: true }));

Make the value configuration-driven rather than hard-coded, because the correct setting depends on topology and a too-permissive value is itself a vulnerability: with trust proxy set to true, a client can forge X-Forwarded-For and spoof an arbitrary IP, defeating the throttler just as thoroughly in the other direction. Add a TRUST_PROXY env var to configuration.ts and .env.example that accepts Express's documented forms — a hop count (1 for a single known proxy, which is what most deployments want), a subnet/address list, or false (the default for a directly-exposed process).

Document in the README which value to use for the repo's own Docker Compose and for a typical ingress deployment, and add a main.spec.ts case asserting app.set is called with 'trust proxy' and the configured value.

主要言語
TypeScript
スター
2
フォーク
90
平均マージ
31分
マージ済み PR(30日)
20

環境構築

  • Dockerfile または Docker Compose ファイルあり
  • プルリクエストのテンプレートなし
  • コントリビューションガイドなし

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

MergeFi/backend のほかの issue

MergeFi/backend の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。