Skip to content

Instantly share code, notes, and snippets.

@wesen
Created August 12, 2025 22:55
Show Gist options
  • Save wesen/bf9c7e598a730625047db43e98bc0481 to your computer and use it in GitHub Desktop.
Save wesen/bf9c7e598a730625047db43e98bc0481 to your computer and use it in GitHub Desktop.

here’s a tight, line-by-line read of what this tiny server actually does, plus the gotchas that matter.

What it does

  1. Bootstrap vs per-connection mode (line 2). [[ $TARGETFILE ]] || TARGETFILE=${1?missing file} exec ncat -e "$0" -kl "${2?missing port}"
  • First run: $TARGETFILE is empty → it sets TARGETFILE (env var only for the following command) to $1 and execs ncat in listen mode (-l) with keep-alive (-k), executing this very script (-e "$0") for each connection.
  • Per connection: the child process inherits TARGETFILE, the [[ $TARGETFILE ]] test now passes, so it skips the ncat exec and continues to handle the HTTP exchange.
  1. Parse the request line (line 3). IFS=$' \r' read -t1 -rn100 request uri _; [[ $uri = / ]]
  • IFS=$' \r' means the split characters are space and carriage return.
  • read -t1 -rn100 reads up to 100 bytes of the first line, 1-second timeout. With the IFS, request='GET', uri='/', _='HTTP/1.1'.
  • The [[ $uri = / ]] guard enforces only “/”; with set -e on the shebang, anything else closes the connection with no response.
  1. Compute headers (line 4). length=$(wc -c < "$TARGETFILE") type=$(file --mime-type --mime-encoding -r -- "$TARGETFILE")
  • wc -c gives the byte count for Content-Length.
  • file returns e.g. path: text/plain; charset=utf-8. Using -r avoids C escapes.
  1. Write the status line + headers (line 5). printf '%s\r\n' 'HTTP/1.1 200 OK' "${type/"$TARGETFILE"/Content-Type}" "Content-Length: $length" ''
  • The ${type/"$TARGETFILE"/Content-Type} trick rewrites path: … into Content-Type: ….
  • Deliberately emits CRLFs and a blank line.
  1. Body for GET only (line 6). [[ $request = GET ]] && cat -- "$TARGETFILE"
  • HEAD works (no body), GET returns the file. Anything else gets headers only.

Subtleties & edge cases

  • The IFS dance is correct. Including both space and \r is the key: it trims the trailing CR and splits the request line on spaces in one read.
  • Silently drops non-root paths. Because of set -e, /foo or /?q=… ends the script before any response (clients see a connection close rather than a 404).
  • Only the first line is read. Extra headers in the request are ignored (fine for this use), and request line >100 bytes times out or truncates → likely fails the / check and disconnects.
  • MIME header derivation. Works because file prefixes with "$TARGETFILE": . If you pass a filename containing newlines (legal on Unix), you can inject header breaks; not a network-side exploit, but worth noting for weird filenames.
  • Correct HEAD semantics. Sends Content-Length without a body.
  • HTTP/1.1 defaults. There’s no Connection: close; clients will still be fine because they can trust Content-Length and then observe EOF. Adding that header would make intent explicit.
  • Concurrency model. ncat -k -e forks a handler per connection—simple and effective for a single file; not portable to OpenBSD nc (needs Nmap’s ncat with -e support).
  • Portability of file output. On common file versions, --mime-type --mime-encoding yields type; charset=… in a single line, so the substitution produces a single Content-Type line as intended.

TL;DR correctness check

  • Serves exactly one file at /, supports GET and HEAD.
  • Emits valid status line, Content-Type, and Content-Length with CRLFs.
  • Refuses everything else by closing the connection.
  • Clever env-var + exec ncat -e "$0" trick cleanly splits “server” and “handler” phases.
  • For its stated purpose (“mini web server for a single file”), it’s tight and works as advertised. If you want me to propose minimal one-liners to make it more HTTP-friendly (e.g., 404 and Connection: close), say the word.

short answer: it’s surprisingly tight, but there are real foot-guns—some remote, some “operator error” class. ranked roughly by risk:

high / critical

  • ncat -e backdoor primitive. Anything that reads from the network and is exec’d per connection is a red-team favorite. One parsing mistake becomes RCE. Many distros disable -e for this reason. If this is ever internet-exposed, assume it’ll get hammered. Prefer --sh-exec with a fixed command or avoid -e entirely.

  • World-bind by default. ncat -kl "${2}" listens on all interfaces. If you meant “local dev only,” you just published a file to the world. Bind explicitly (e.g., -l 127.0.0.1) or firewall it.

  • Header injection via filename (CR/LF). Line 4 uses: type=$(file --mime-type --mime-encoding -r -- "$TARGETFILE") then printf ... "${type/"$TARGETFILE"/Content-Type}" ... Because of -r, file prints the raw filename. Unix allows newlines in filenames. If $TARGETFILE contains \n or \r\n, your printf will emit extra header lines (response-splitting). Even though the attacker can’t change $TARGETFILE over HTTP, this is a sharp edge if the served path is untrusted (uploaded file, symlink, etc.). Easiest harden: don’t embed the name—use file -b to get just the MIME pieces and format the header yourself.

medium

  • Unbounded concurrency / easy DoS. -k + fork per connection means a burst of connects spawns a lot of shells and file processes. The read -t1 limits slowloris somewhat, but you can still exhaust fds/CPU. Cap with --max-conns, run behind a rate-limiting reverse proxy, or drop -k.

  • Cleartext by default. If this ever crosses an untrusted network, it’s trivially sniffable/modified. Use --ssl (or terminate TLS elsewhere) if confidentiality/integrity matters.

  • libmagic attack surface. Calling file on untrusted content has had CVEs. If the file can be attacker-supplied, this brings that risk into your request path.

low / correctness / hygiene

  • Doesn’t read the rest of the request. Only the request line is consumed; headers/body (if any) are ignored. Because the handler exits, the socket closes, so this isn’t request-smuggling, but some proxies/clients may log oddities.

  • Over-strict path handling. Anything except exactly / drops the connection (no 4xx). Not a vuln, just behavior that can confuse clients/tools.

  • Filename pattern edge cases. The ${type/"$TARGETFILE"/Content-Type} uses shell pattern matching on the pattern. If the filename contains glob chars like * or [], the replacement could match more than intended. It still only affects the header line, but it’s brittle.


minimal hardening (no feature creep)

  • Bind locally or to a specific iface: -l 127.0.0.1 (or firewall).

  • Avoid embedding the filename in headers: derive MIME like:

    • mime=$(file -b --mime-type -- "$TARGETFILE")
    • enc=$(file -b --mime-encoding -- "$TARGETFILE")
    • then print Content-Type: ${mime}; charset=${enc}
  • If you must expose it, add a connection cap: --max-conns N (or run behind a tiny reverse proxy).

  • Run as an unprivileged user in a tight working dir.

If you want, I can show a 1–2 line diff that fixes the header-injection and bind-scope without changing the spirit of the script.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment