smolboid/tests/pentest.sh

399 lines
18 KiB
Bash
Executable file

#!/usr/bin/env bash
# smolboid pentest: an adversarial pass against our own daemon. Every probe
# asserts the hardened outcome; a probe that succeeds as an attack fails the
# suite. Raw sockets via python3 for the HTTP surface, real fumi sends for
# the mail surface.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FUMI="$ROOT/../fumi/target/debug/fumi"
BUNSHIN_BIN="$ROOT/../bunshin/target/debug/bunshin"
SMOLBOID="$ROOT/target/debug/smolboid"
HOST=127.0.0.1
SMTP_PORT=19631
HTTP_PORT=18101
RIG="$(mktemp -d /tmp/smolboid-pentest.XXXXXX)"
DATA="$RIG/data"
DAEMON_ADDR="twtxt@$HOST:$SMTP_PORT"
FEED="$DATA/feeds/tobias.txt"
BUNSHIN_PID=""
SMOLBOID_PID=""
cleanup() {
[[ -n "$BUNSHIN_PID" ]] && kill "$BUNSHIN_PID" 2>/dev/null || true
[[ -n "$SMOLBOID_PID" ]] && kill "$SMOLBOID_PID" 2>/dev/null || true
}
trap cleanup EXIT
step() { printf '\n== %s ==\n' "$*"; }
fail() { echo "FAIL: $*" >&2; exit 1; }
probe() { # name, expected-status-prefix, raw request bytes (python literal)
local name=$1 want=$2 payload=$3
local got
got=$(python3 - "$HTTP_PORT" "$payload" <<'EOF'
import socket, sys
port, payload = int(sys.argv[1]), sys.argv[2].encode().decode("unicode_escape")
s = socket.create_connection(("127.0.0.1", port), timeout=15)
s.sendall(payload.encode())
s.settimeout(15)
# Accumulate until a full status line lands; a single recv may split.
data = b""
while b"\r\n" not in data:
try:
chunk = s.recv(4096)
except socket.timeout:
break
if not chunk:
break
data += chunk
if not data:
data = b"<no response in 15s>"
print(data.split(b"\r\n")[0].decode(errors="replace"))
EOF
)
if [[ "$got" == "$want"* ]]; then
echo "ok $name -> $got"
else
echo "got: $got"
fail "probe $name: wanted $want*, got $got"
fi
}
step "build"
cargo build --manifest-path "$ROOT/../bunshin/Cargo.toml" --quiet
cargo build --manifest-path "$ROOT/../fumi/Cargo.toml" --quiet
cargo build --manifest-path "$ROOT/Cargo.toml" --quiet
step "bootstrap: bunshin, tobias and a second identity mallory"
mkdir -p "$DATA"
"$BUNSHIN_BIN" keygen --key "$RIG/server.key" >"$RIG/keygen.log" 2>&1
SERVER_KEY="$(grep -oE '[a-z2-7]{52}' "$RIG/keygen.log" | head -1)"
"$BUNSHIN_BIN" serve --key "$RIG/server.key" --db "$RIG/server.db" \
--host "$HOST" --port "$SMTP_PORT" >"$RIG/bunshin.log" 2>&1 &
BUNSHIN_PID=$!
sleep 1
author() { "$FUMI" --key "$RIG/author.key" --db "$RIG/author.db" --timeout 10 "$@"; }
mallory() { "$FUMI" --key "$RIG/mallory.key" --db "$RIG/mallory.db" --timeout 10 "$@"; }
daemon_fumi() { "$FUMI" --key "$RIG/master.key" --db "$DATA/mail.db" --timeout 10 "$@"; }
author keygen --force >/dev/null
author trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
author register "tobias@$HOST:$SMTP_PORT" >/dev/null
"$SMOLBOID" keygen "$RIG/master.key" >/dev/null
daemon_fumi trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
daemon_fumi register "$DAEMON_ADDR" >/dev/null
step "admit tobias (never mallory)"
cat >"$RIG/smolboid.toml" <<EOF
listen = $HOST:$HTTP_PORT
base_url = http://$HOST:$HTTP_PORT
smolmail_host = $HOST
mailbox = twtxt
master = $RIG/master.key
data = $DATA
poll_secs = 1
timeout = 10
cap_bytes = 1048576
posts_per_day = 50
reposts_per_day = 50
# Open, so the probes below exercise the path an attacker would actually find.
# Silent would make most of them pass for the wrong reason.
registration = open
admins = tobias@$HOST:$SMTP_PORT
admin_notify = off
EOF
"$SMOLBOID" admit --config "$RIG/smolboid.toml" "tobias@$HOST:$SMTP_PORT" >"$RIG/admit.log" 2>&1 \
|| { cat "$RIG/admit.log"; fail "admit"; }
author fetch >/dev/null
"$SMOLBOID" serve --config "$RIG/smolboid.toml" >"$RIG/serve.log" 2>&1 &
SMOLBOID_PID=$!
sleep 2
# A legitimate post so the feed has content to protect.
author send "$DAEMON_ADDR" --body "real post" >/dev/null
for ((i = 0; i < 20; i++)); do
grep -q "real post" "$FEED" 2>/dev/null && break
sleep 1
done
grep -q "real post" "$FEED" || fail "setup: the real post never published"
step "http: path traversal and path abuse"
probe "traversal-etc-passwd" "HTTP/1.1 404" 'GET /twtxt/../../etc/passwd HTTP/1.1\r\n\r\n'
probe "encoded-traversal" "HTTP/1.1 404" 'GET /twtxt/..%2F..%2Fetc%2Fpasswd HTTP/1.1\r\n\r\n'
probe "dotdot-no-suffix" "HTTP/1.1 404" 'GET /twtxt/.. HTTP/1.1\r\n\r\n'
probe "absolute-path" "HTTP/1.1 404" 'GET //etc/passwd HTTP/1.1\r\n\r\n'
probe "uppercase-username" "HTTP/1.1 404" 'GET /twtxt/TOBIAS.txt HTTP/1.1\r\n\r\n'
probe "unicode-username" "HTTP/1.1 404" 'GET /twtxt/t\xc3\xb6bias.txt HTTP/1.1\r\n\r\n'
probe "oversized-username" "HTTP/1.1 404" "GET /twtxt/$(printf 'a%.0s' $(seq 1 200)).txt HTTP/1.1\r\n\r\n"
probe "null-byte-username" "HTTP/1.1 404" 'GET /twtxt/tob\x00ias.txt HTTP/1.1\r\n\r\n'
probe "dotfile-username" "HTTP/1.1 404" 'GET /twtxt/.ssh.txt HTTP/1.1\r\n\r\n'
# The traversal probes must never return file content.
curl -s "http://$HOST:$HTTP_PORT/twtxt/../../etc/passwd" | grep -q "root:" \
&& fail "traversal returned /etc/passwd"
step "http: request parsing abuse"
# Any 4xx is a hardened outcome; the server may read it as a method.
probe "garbage-request" "HTTP/1.1 40" 'not http at all\r\n\r\n'
probe "smuggled-second-request" "HTTP/1.1 200" 'GET /twtxt/tobias.txt HTTP/1.1\r\nX-Injected: 1\r\n\r\nGET /twtxt/tobias'
probe "null-in-request" "HTTP/1.1 40" 'GE\x00T /twtxt/tobias.txt HTTP/1.1\r\n\r\n'
probe "post-method" "HTTP/1.1 405" 'POST /twtxt/tobias.txt HTTP/1.1\r\nContent-Length: 0\r\n\r\n'
probe "huge-request-line" "HTTP/1.1 40" "GET /$(printf 'a%.0s' $(seq 1 9000)) HTTP/1.1\r\n\r\n"
probe "extra-headers-tolerated" "HTTP/1.1 200" 'GET /twtxt/tobias.txt HTTP/1.1\r\nHost: x\r\nEvil: 1\r\n\r\n'
# No header value from the request is ever reflected into the response.
curl -s -i "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" -H "X-Evil: $(printf 'injected-header')" \
| grep -qi "injected-header" && fail "request header reflected in response"
step "http: HEAD serves headers without a body"
python3 - "$HTTP_PORT" <<'PYEOF'
import socket, sys
s = socket.create_connection(("127.0.0.1", int(sys.argv[1])), timeout=15)
s.sendall(b"HEAD /twtxt/tobias.txt HTTP/1.1\r\n\r\n")
data = b""
while b"\r\n\r\n" not in data:
chunk = s.recv(4096)
if not chunk:
break
data += chunk
head, _, body = data.partition(b"\r\n\r\n")
assert head.startswith(b"HTTP/1.1 200"), head
assert b"Content-Length:" in head, head
assert body == b"", "HEAD must not emit a body"
print("ok HEAD answers 200 with headers and no body")
PYEOF
step "http: the connection cap sheds floods with 503"
python3 - "$HTTP_PORT" <<'PYEOF'
import socket, sys, time
port = int(sys.argv[1])
held = []
for _ in range(64):
s = socket.create_connection(("127.0.0.1", port), timeout=15)
s.sendall(b"G") # a partial request, as slowloris sends
held.append(s)
time.sleep(0.5)
extra = socket.create_connection(("127.0.0.1", port), timeout=15)
extra.sendall(b"GET /twtxt/tobias.txt HTTP/1.1\r\n\r\n")
data = b""
while b"\r\n" not in data:
chunk = extra.recv(512)
if not chunk:
break
data += chunk
assert data.startswith(b"HTTP/1.1 503"), f"flood absorbed: {data!r}"
for s in held:
s.close()
extra.close()
print("ok the 65th connection was shed with 503")
PYEOF
# The listener must recover once the flood closes.
for _ in $(seq 1 20); do
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null && break
sleep 1
done
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener never recovered after the flood"
step "http: slowloris is bounded by the read timeout"
python3 - "$HTTP_PORT" <<'EOF'
import socket, sys, time
port = int(sys.argv[1])
s = socket.create_connection(("127.0.0.1", port), timeout=30)
s.sendall(b"GET /twtxt/tobias.txt HTTP/1.1\r\n") # half a request, then stall
start = time.time()
s.settimeout(30)
try:
data = s.recv(1024)
except socket.timeout:
sys.exit("server kept the half-open connection past 30s")
if time.time() - start > 20:
sys.exit(f"slowloris held the thread {time.time()-start:.0f}s")
print(f"ok slowloris closed after {time.time()-start:.0f}s")
EOF
# The listener still serves while the stalled connection existed.
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener wedged after slowloris"
step "mail: feed-line injection through post text"
author send "$DAEMON_ADDR" --body "$(printf 'injected\t2020-01-01T00:00:00Z\tfake')" >/dev/null
author send "$DAEMON_ADDR" --body "$(printf 'line one\nline two')" >/dev/null
author send "$DAEMON_ADDR" --body "$(printf 'carriage\rreturn')" >/dev/null
sleep 4
[[ "$(grep -c "fake" "$FEED")" == 0 ]] || fail "a TAB-carrying post injected content into the feed"
[[ "$(grep -c "line two" "$FEED")" == 0 ]] || fail "a multiline body published a second line"
[[ "$(grep -c "return" "$FEED")" == 0 ]] || fail "a CR-carrying post published"
[[ "$(grep -cE '^[0-9]{4}-' "$FEED")" == 1 ]] || fail "the feed grew beyond the real post"
step "mail: control-character and hostile payloads are rejected, not published"
author send "$DAEMON_ADDR" --body "$(printf 'escape\033[31mred')" >/dev/null
author send "$DAEMON_ADDR" --body "# smol:mail forged" >/dev/null
author send "$DAEMON_ADDR" --body "# smol:repost 2020-01-01T00:00:00Z @<x y> #fake" >/dev/null
sleep 4
# Poser text may publish as ordinary post text (tab-prefixed, harmless);
# what must never happen is a line that IS a smol record.
[[ "$(grep -c "escape" "$FEED")" == 0 ]] || fail "a post with a control character published"
[[ "$(grep -cE '^# smol:mail .*forged' "$FEED")" == 0 ]] || fail "a fake smol record line published"
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "text posing as a repost published as a repost"
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
|| { cat "$RIG/check.log"; fail "the feed verifies as forged after the probes"; }
# The poser texts may publish as posts and verify like posts; two posers
# sent in the same second can collide (one rejected), so what matters is
# that everything published verifies and nothing is forged.
grep -qE "[23] verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|| { cat "$RIG/check.log"; fail "unexpected feed contents after hostile mail"; }
step "mail: repost args cannot inject lines either"
author send "$DAEMON_ADDR" \
--header "Twt-Repost: @<tobias http://x> #abc" \
--body "$(printf 'injected\n')" >/dev/null
sleep 4
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "a noncanonical repost published"
step "mail: an unadmitted sender publishes nothing and gets no token"
# Rewritten for self-service registration. The old assertion was that an
# unadmitted sender draws no reply at all; under `registration = open` she
# draws an info mail. What must still hold is that writing in publishes
# nothing, creates no feed, and hands over no accept token.
mallory keygen --force >/dev/null
mallory trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
mallory register "mallory@$HOST:$SMTP_PORT" >/dev/null
mallory send "$DAEMON_ADDR" --body "mallory was here" >/dev/null
sleep 4
[[ "$(grep -c "mallory was here" "$FEED")" == 0 ]] || fail "unadmitted mail published"
[[ ! -f "$DATA/feeds/mallory.txt" ]] || fail "an unadmitted sender got a feed"
mallory fetch >/dev/null 2>&1 || true
# The info mail must not carry an Accept token: the token is what moves her
# into the main tier, and it is only issued once she actually registers.
if mallory list --requests 2>/dev/null | grep -q "how to get a feed"; then
mallory read 1 >"$RIG/mallory-info.txt" 2>/dev/null || true
grep -qi "^Accept:" "$RIG/mallory-info.txt" \
&& fail "the info mail carried an accept token"
fi
step "mail: no signed Reply-To draws no reply at all"
# --anonymous omits the Reply-To, so there is no address to verify and nothing
# to answer. This is the anti-reflector case: a freshly minted keypair must not
# be able to make the daemon send mail anywhere.
BEFORE_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l)"
mallory send "$DAEMON_ADDR" --anonymous --body "no reply-to here" >/dev/null
sleep 4
AFTER_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l)"
[[ "$BEFORE_SENT" == "$AFTER_SENT" ]] \
|| fail "an anonymous sender drew a reply ($BEFORE_SENT -> $AFTER_SENT)"
step "mail: claiming someone else's address takes nothing and poisons nothing"
# The squatting and contact-poisoning probe, and the most important assertion
# in this file. Mallory signs a Reply-To naming tobias's address but carrying
# her own key. The key matches the signer, so the claim is self-consistent —
# which is exactly why the daemon must resolve the address and find tobias's
# key there instead.
#
# Two things must hold: she gets no feed at tobias's slug, and tobias's
# contact row must still hold tobias's key. If the daemon replied to the
# claimed smol:// URI it would pin her key as verified under his address, and
# his own registration would fail on KeyChanged forever after.
MALLORY_KEY="$(mallory whoami 2>/dev/null | grep -oE '[a-z2-7]{52}' | head -1)"
[[ -n "$MALLORY_KEY" ]] || fail "could not read mallory's key"
mallory send "$DAEMON_ADDR" --anonymous \
--header "Reply-To: smol://tobias@$HOST:$SMTP_PORT/$MALLORY_KEY" \
--header "Twt-Register:" --body "" >/dev/null
sleep 5
# tobias's feed is his own and still bound to his address.
grep -q "# smol:author = tobias@$HOST:$SMTP_PORT" "$FEED" \
|| fail "tobias's feed lost its author binding"
# And the daemon's contact for tobias must not have become mallory's key.
CONTACT="$(daemon_fumi contacts 2>/dev/null | grep "tobias@$HOST:$SMTP_PORT" || true)"
if [[ -n "$CONTACT" ]]; then
echo "$CONTACT" | grep -q "$MALLORY_KEY" \
&& fail "mallory's key got pinned under tobias's address"
fi
step "mail: a claim for an address nobody holds draws nothing"
BEFORE_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l)"
mallory send "$DAEMON_ADDR" --anonymous \
--header "Reply-To: smol://nobody@$HOST:$SMTP_PORT/$MALLORY_KEY" \
--header "Twt-Register:" --body "" >/dev/null
sleep 5
AFTER_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l)"
[[ "$BEFORE_SENT" == "$AFTER_SENT" ]] \
|| fail "a claim for a nonexistent address drew a reply"
[[ ! -f "$DATA/feeds/nobody.txt" ]] || fail "a nonexistent address got a feed"
step "mail: admin commands from a non-admin do nothing"
# And must not hint that admin commands exist: mallory gets the ordinary
# unknown-sender answer, never an ack.
mallory send "$DAEMON_ADDR" --header "Twt-Admit: mallory@$HOST:$SMTP_PORT" \
--body "" >/dev/null
sleep 4
[[ ! -f "$DATA/feeds/mallory.txt" ]] || fail "Twt-Admit from a non-admin created a feed"
mallory fetch >/dev/null 2>&1 || true
mallory list --requests 2>/dev/null | grep -qi "admitted" \
&& fail "a non-admin was told an admin command ran"
step "mail: a blocked address stays blocked"
# Order matters, and the first attempt at this probe got it wrong. Blocking an
# address that was never accepted is a no-op in the store — there is no row to
# deactivate — so `accepted_state` stays None and nothing is being tested. The
# sequence that matters is the real one: register, get blocked, come back.
#
# store.block only clears the active flag, so the check has to run before any
# network call or Twt-Block is a one-poll inconvenience.
mallory send "$DAEMON_ADDR" --header "Twt-Register:" --body "" >/dev/null
for ((i = 0; i < 20; i++)); do
[[ -f "$DATA/feeds/mallory.txt" ]] && break
sleep 1
done
[[ -f "$DATA/feeds/mallory.txt" ]] || fail "mallory could not register at all"
author send "$DAEMON_ADDR" --header "Twt-Block: mallory@$HOST:$SMTP_PORT" \
--body "" >/dev/null
sleep 5
# Blocked: her posts no longer publish.
mallory send "$DAEMON_ADDR" --body "after being blocked" >/dev/null
sleep 5
grep -q "after being blocked" "$DATA/feeds/mallory.txt" \
&& fail "a blocked author still published"
# And re-registering draws nothing at all, not even a reply.
BEFORE_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l || true)"
mallory send "$DAEMON_ADDR" --header "Twt-Register:" --body "" >/dev/null
sleep 5
AFTER_SENT="$(daemon_fumi list --sent 2>/dev/null | wc -l || true)"
[[ "$BEFORE_SENT" == "$AFTER_SENT" ]] \
|| fail "a blocked address drew a reply when re-registering"
step "mail: forged signing keys verify as invalid, never as verified"
# Tamper the published feed's own signature line: the record must flip to
# invalid, proving verification is load-bearing.
cp "$FEED" "$RIG/tampered.txt"
python3 - "$RIG/tampered.txt" <<'EOF'
import sys
path = sys.argv[1]
lines = open(path).read().split("\n")
for i, line in enumerate(lines):
if line.startswith("# smol:mail "):
parts = line.split(" ")
parts[3] = ("a" if parts[3][0] != "a" else "b") + parts[3][1:]
lines[i] = " ".join(parts)
break
open(path, "w").write("\n".join(lines))
EOF
if "$SMOLBOID" check "$RIG/tampered.txt" >/dev/null 2>&1; then
fail "a tampered signature still verified"
fi
step "mail: reply-flood is capped at 10 per hour"
for i in $(seq 1 15); do
author send "$DAEMON_ADDR" --body "$(printf 'y%.0s' $(seq 1 "$i"))$(printf '\tz')" >/dev/null
done
sleep 5
author fetch >/dev/null
# Fifteen distinct payloads carrying a TAB; at most ten answers, the rest silent.
# (Earlier probes may have drawn their own replies for other reasons.)
author list --requests 2>/dev/null | grep -c "twtxt: A post has to fit on one line" >"$RIG/replies.count" || true
REPLIES=$(grep -oE '^[0-9]+' "$RIG/replies.count" | head -1 || echo 0)
echo "flood answers: $REPLIES of 15"
[[ "$REPLIES" -le 10 ]] || fail "reply flood drew $REPLIES answers, over the hourly cap"
step "http: still healthy after everything"
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener unhealthy at the end"
"$SMOLBOID" check "$FEED" >/dev/null || fail "feed does not verify at the end"
step "pentest passed: every probe was repelled"
cleanup
trap - EXIT