GHSA-R4JH-29PC-GFQR

Vulnerability from github – Published: 2026-11-12 22:23 – Updated: 2026-11-12 22:23
VLAI
Summary
basic-ftp: Uncaught exception crashes the Node.js process when a password (or resumed-download path) contains CR/LF
Details

Summary

Client.login() calls FTPContext.send() from inside the control-socket data event handler. send() throws on CR, LF or NUL, and nothing catches the throw. A password containing \r\n therefore raises an uncaught exception and terminates the whole Node.js process. It does not reject the returned promise. Any application that passes untrusted input as the FTP password can be crashed remotely with one request. The same pattern exists in the REST/RETR path of downloadTo() when the remote path contains control characters.

Details

The CRLF guard (FTPContext.send, src/FtpContext.ts ~L233) correctly refuses to send commands containing \r, \n or \0:

send(command: string) {
    if (/[\r\n\0]/.test(command)) {
        throw new Error(`Invalid command: Contains control characters. (${command})`);
    }

That works when send() is called from handle(), because it runs inside a Promise executor and the throw becomes a rejection. But in two places send() runs inside a response handler, which is called synchronously from the socket’s data event:

Client.login() (src/Client.ts ~L246-248): after the server answers 331 to USER, the handler calls this.ftp.send("PASS " + password). The USER command was already sent and is valid, so the password is only checked at this point. The call chain is socket.on("data") → _onControlSocketData → _passToHandler → responseHandler → send() → throw. No try/catch exists anywhere in that chain, so it becomes an uncaughtException. downloadTo() (src/transfer.ts ~L368-370): when startAt > 0, the initial command is REST n, which contains no user-controlled text. When the server answers 350, the handler calls config.ftp.send("RETR " + config.remotePath). This remotePath was never checked before. A path with a control character throws in the data handler in the same way. (Found by code reading, not reproduced.)

Because the exception happens during data event dispatch, it can’t be handled with try/catch or .catch() around client.access() or downloadTo(). The application only survives if it has a global process.on("uncaughtException") handler.

Proof of concept

Tested on Node.js v22.22.0 against basic-ftp 6.2.2 (built from source). It uses a minimal mock FTP server on localhost:

const net = require("net")
const ftp = require("basic-ftp")

const srv = net.createServer(s => {
  s.write("220 hi\r\n")
  s.on("data", d => {
    const c = d.toString()
    if (c.startsWith("USER")) s.write("331 pw\r\n")
    else if (c.startsWith("PASS")) s.write("230 ok\r\n")
    else s.write("200 ok\r\n")
  })
})

srv.listen(0, async () => {
  const c = new ftp.Client(2000)
  try {
    await c.access({
      host: "127.0.0.1",
      port: srv.address().port,
      user: "bob",
      password: "x\r\nDELE important"   // attacker-controlled value
    })
  } catch (e) {
    console.log("rejected:", e.message)  // never reached
  }
  console.log("handled gracefully")      // never reached
  process.exit(0)
})

Result: the process terminates with an uncaught exception and neither catch nor the final console.log runs:

Error: Invalid command: Contains control characters. (PASS x
DELE important)
    at FTPContext.send (dist/FtpContext.js:189:19)
    at Object.responseHandler (dist/Client.js:186:26)
    at FTPContext._passToHandler (dist/FtpContext.js:322:24)
    at FTPContext._onControlSocketData (dist/FtpContext.js:312:18)
    at Socket.<anonymous> (dist/FtpContext.js:133:44)

The server in the PoC is benign. Any normal FTP server that replies 331 to USER triggers the same crash.

Impact

Denial of service (uncaught exception, process termination), CWE-248.

It affects applications that pass untrusted or user-influenced values as the FTP password, or as the remote path of a resumed download. Examples are web apps or services that let users enter their own FTP credentials, or file-transfer or backup tools driven by user-supplied paths. One request crashes the whole Node.js process and every other client it serves. It needs no authentication to the FTP server and no special server behaviour. The attacker only needs control over the string. Confidentiality and integrity are not affected, and the CRLF guard still blocks command injection.

Suggested fix:

In FTPContext._passToHandler(), wrap the handler call in a try/catch that rejects the task and calls closeWithError(err). Validate password in login(), and remotePath in _downloadToStream(), before any I/O and reject early.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 6.2.2"
      },
      "package": {
        "ecosystem": "npm",
        "name": "basic-ftp"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "6.2.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-126346"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-248",
      "CWE-755"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-11-12T22:23:50Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "### Summary\n\nClient.login() calls FTPContext.send() from inside the control-socket data event handler. send() throws on CR, LF or NUL, and nothing catches the throw. A password containing \\r\\n therefore raises an uncaught exception and terminates the whole Node.js process. It does not reject the returned promise. Any application that passes untrusted input as the FTP password can be crashed remotely with one request. The same pattern exists in the REST/RETR path of downloadTo() when the remote path contains control characters.\n\n### Details\n\nThe CRLF guard (FTPContext.send, src/FtpContext.ts ~L233) correctly refuses to send commands containing \\r, \\n or \\0:\n```ts\nsend(command: string) {\n    if (/[\\r\\n\\0]/.test(command)) {\n        throw new Error(`Invalid command: Contains control characters. (${command})`);\n    }\n```\n\nThat works when send() is called from handle(), because it runs inside a Promise executor and the throw becomes a rejection. But in two places send() runs inside a response handler, which is called synchronously from the socket\u2019s data event:\n\nClient.login() (src/Client.ts ~L246-248): after the server answers 331 to USER, the handler calls this.ftp.send(\"PASS \" + password). The USER command was already sent and is valid, so the password is only checked at this point. The call chain is socket.on(\"data\") \u2192 _onControlSocketData \u2192 _passToHandler \u2192 responseHandler \u2192 send() \u2192 throw. No try/catch exists anywhere in that chain, so it becomes an uncaughtException.\ndownloadTo() (src/transfer.ts ~L368-370): when startAt \u003e 0, the initial command is REST n, which contains no user-controlled text. When the server answers 350, the handler calls config.ftp.send(\"RETR \" + config.remotePath). This remotePath was never checked before. A path with a control character throws in the data handler in the same way. (Found by code reading, not reproduced.)\n\nBecause the exception happens during data event dispatch, it can\u2019t be handled with try/catch or .catch() around client.access() or downloadTo(). The application only survives if it has a global process.on(\"uncaughtException\") handler.\n\n### Proof of concept\n\nTested on Node.js v22.22.0 against basic-ftp 6.2.2 (built from source). It uses a minimal mock FTP server on localhost:\n\n```js\nconst net = require(\"net\")\nconst ftp = require(\"basic-ftp\")\n\nconst srv = net.createServer(s =\u003e {\n  s.write(\"220 hi\\r\\n\")\n  s.on(\"data\", d =\u003e {\n    const c = d.toString()\n    if (c.startsWith(\"USER\")) s.write(\"331 pw\\r\\n\")\n    else if (c.startsWith(\"PASS\")) s.write(\"230 ok\\r\\n\")\n    else s.write(\"200 ok\\r\\n\")\n  })\n})\n\nsrv.listen(0, async () =\u003e {\n  const c = new ftp.Client(2000)\n  try {\n    await c.access({\n      host: \"127.0.0.1\",\n      port: srv.address().port,\n      user: \"bob\",\n      password: \"x\\r\\nDELE important\"   // attacker-controlled value\n    })\n  } catch (e) {\n    console.log(\"rejected:\", e.message)  // never reached\n  }\n  console.log(\"handled gracefully\")      // never reached\n  process.exit(0)\n})\n```\nResult: the process terminates with an uncaught exception and neither catch nor the final console.log runs:\n```\nError: Invalid command: Contains control characters. (PASS x\nDELE important)\n    at FTPContext.send (dist/FtpContext.js:189:19)\n    at Object.responseHandler (dist/Client.js:186:26)\n    at FTPContext._passToHandler (dist/FtpContext.js:322:24)\n    at FTPContext._onControlSocketData (dist/FtpContext.js:312:18)\n    at Socket.\u003canonymous\u003e (dist/FtpContext.js:133:44)\n```\nThe server in the PoC is benign. Any normal FTP server that replies 331 to USER triggers the same crash.\n\n### Impact\n\nDenial of service (uncaught exception, process termination), CWE-248.\n\nIt affects applications that pass untrusted or user-influenced values as the FTP password, or as the remote path of a resumed download. Examples are web apps or services that let users enter their own FTP credentials, or file-transfer or backup tools driven by user-supplied paths. One request crashes the whole Node.js process and every other client it serves. It needs no authentication to the FTP server and no special server behaviour. The attacker only needs control over the string. Confidentiality and integrity are not affected, and the CRLF guard still blocks command injection.\n\nSuggested fix:\n\nIn FTPContext._passToHandler(), wrap the handler call in a try/catch that rejects the task and calls closeWithError(err).\nValidate password in login(), and remotePath in _downloadToStream(), before any I/O and reject early.",
  "id": "GHSA-r4jh-29pc-gfqr",
  "modified": "2026-11-12T22:23:50Z",
  "published": "2026-11-12T22:23:50Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/patrickjuchli/basic-ftp/security/advisories/GHSA-r4jh-29pc-gfqr"
    },
    {
      "type": "WEB",
      "url": "https://github.com/patrickjuchli/basic-ftp/commit/0495b906bdf891e68806721b07b333dba65ec88e"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/patrickjuchli/basic-ftp"
    },
    {
      "type": "WEB",
      "url": "https://github.com/patrickjuchli/basic-ftp/releases/tag/v6.2.3"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "basic-ftp: Uncaught exception crashes the Node.js process when a password (or resumed-download path) contains CR/LF"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

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.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
How durations are calculated

Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.

Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.

Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.