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:
$TARGETFILE
is empty → it setsTARGETFILE
(env var only for the following command) to$1
andexec
s 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 thencat
exec 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 -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 “/”; withset -e
on 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 -c
gives the byte count forContent-Length
.file
returns e.g.path: text/plain; charset=utf-8
. Using-r
avoids 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
\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 trustContent-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 OpenBSDnc
(needs Nmap’sncat
with-e
support). - Portability of
file
output. On commonfile
versions,--mime-type --mime-encoding
yieldstype; charset=…
in a single line, so the substitution produces a singleContent-Type
line as intended.
- Serves exactly one file at
/
, supports GET and HEAD. - Emits valid status line,
Content-Type
, andContent-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:
-
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")
thenprintf ... "${type/"$TARGETFILE"/Content-Type}" ...
Because of-r
,file
prints the raw filename. Unix allows newlines in filenames. If$TARGETFILE
contains\n
or\r\n
, yourprintf
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—usefile -b
to 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 andfile
processes. Theread -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.
-
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.