here’s a tight, line-by-line read of what this tiny server actually does, plus the gotchas that matter.
- Bootstrap vs per-connection mode (line 2).
[[ $TARGETFILE ]] || TARGETFILE=${1?missing file} exec ncat -e "$0" -kl "${2?missing port}"
- First run:
$TARGETFILEis empty → it setsTARGETFILE(env var only for the following command) to$1andexecs 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 thencatexec and continues to handle the HTTP exchange.
- 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 -rn100reads 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 “/”; withset -eon the shebang, anything else closes the connection with no response.
- Compute headers (line 4).
length=$(wc -c < "$TARGETFILE") type=$(file --mime-type --mime-encoding -r -- "$TARGETFILE")
wc -cgives the byte count forContent-Length.filereturns e.g.path: text/plain; charset=utf-8. Using-ravoids C escapes.
- 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 rewritespath: …intoContent-Type: …. - Deliberately emits CRLFs and a blank line.
- Body for GET only (line 6).
[[ $request = GET ]] && cat -- "$TARGETFILE"
- HEAD works (no body), GET returns the file. Anything else gets headers only.
- The IFS dance is correct. Including both space and
\ris 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,/fooor/?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
fileprefixes 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-Lengthwithout a body. - HTTP/1.1 defaults. There’s no
Connection: close; clients will still be fine because they can trustContent-Lengthand then observe EOF. Adding that header would make intent explicit. - Concurrency model.
ncat -k -eforks a handler per connection—simple and effective for a single file; not portable to OpenBSDnc(needs Nmap’sncatwith-esupport). - Portability of
fileoutput. On commonfileversions,--mime-type --mime-encodingyieldstype; charset=…in a single line, so the substitution produces a singleContent-Typeline as intended.
- Serves exactly one file at
/, supports GET and HEAD. - Emits valid status line,
Content-Type, andContent-Lengthwith 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:
-
ncat -ebackdoor 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-efor this reason. If this is ever internet-exposed, assume it’ll get hammered. Prefer--sh-execwith a fixed command or avoid-eentirely. -
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")thenprintf ... "${type/"$TARGETFILE"/Content-Type}" ...Because of-r,fileprints the raw filename. Unix allows newlines in filenames. If$TARGETFILEcontains\nor\r\n, yourprintfwill emit extra header lines (response-splitting). Even though the attacker can’t change$TARGETFILEover HTTP, this is a sharp edge if the served path is untrusted (uploaded file, symlink, etc.). Easiest harden: don’t embed the name—usefile -bto get just the MIME pieces and format the header yourself.
-
Unbounded concurrency / easy DoS.
-k+ fork per connection means a burst of connects spawns a lot of shells andfileprocesses. Theread -t1limits 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
fileon untrusted content has had CVEs. If the file can be attacker-supplied, this brings that risk into your request path.
-
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.
-
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.