#!/usr/bin/env bash
#
# smuggle-probe — HTTP request smuggling across five protocol surfaces.
#
# The companion to https://pqcrypta.com/http-smuggling/. Every claim on that
# page is produced by this script, and it runs against any origin you point it
# at, not just ours:
#
#     ./smuggle-probe your-proxy.example.com
#
# It sends the CL.TE / TE.CL / TE.TE payloads the article describes and reports,
# per surface, what it sent, what came back, what the RFC requires, and a
# verdict. Payloads are benign — a "SMUGGLED" marker and a single 'A' byte, no
# injection, no flood — so it is safe to run against a server you operate.
#
#   AUTHORIZATION. Run this only against systems you own or are authorized to
#   test. Sending malformed framing to someone else's proxy is an attack whether
#   or not the payload is benign.
#
# ── The one thing this script exists to get right ────────────────────────────
#
# A smuggling test's failure mode is the SILENT PASS: the tool never delivered
# the payload, the server had nothing to reject, and "no finding" is recorded
# for a surface that was never touched. curl in HTTP/2 mode strips
# Transfer-Encoding before the wire; a curl with no HTTP/3 support cannot probe
# QUIC at all; a TCP connect to a UDP-only port fails whether the service is
# absent or simply not on TCP. Each of those looks identical to a clean result.
#
# So this script refuses to score a surface it could not actually exercise. It
# prints its own capabilities up front (the positive controls), and marks a
# surface it could not reach as NOT-EXERCISED — never as a pass. "Absent from
# the output" and "not present" are different conditions, and the difference is
# the whole point of the article.
#
# Requires: bash, curl. Optional: nc (port-80 surface), a curl built with
# --http3 (the QUIC surface). Without the optional tools the relevant surfaces
# report NOT-EXERCISED rather than a false pass.

set -u

HOST="${1:-pqcrypta.com}"
HOST="${HOST#http://}"; HOST="${HOST#https://}"; HOST="${HOST%%/*}"
TARGET="https://${HOST}"
QUIC_PORT="${QUIC_PORT:-4433}"
TIMEOUT="${TIMEOUT:-10}"

ok=0; findings=0; not_exercised=0; notes=0
c_reset=$'\033[0m'; c_ok=$'\033[32m'; c_warn=$'\033[33m'; c_dim=$'\033[2m'; c_bold=$'\033[1m'
if [ ! -t 1 ]; then c_reset=; c_ok=; c_warn=; c_dim=; c_bold=; fi

say()  { printf '%s\n' "$*"; }
head2(){ printf '\n%s── %s %s\n' "$c_bold" "$1" "$c_reset"; }
OKV()  { ok=$((ok+1));                     printf '  %s[ OK ]%s %s\n'          "$c_ok"   "$c_reset" "$*"; }
FIND() { findings=$((findings+1));         printf '  %s[FIND]%s %s\n'         "$c_warn" "$c_reset" "$*"; }
NX()   { not_exercised=$((not_exercised+1));printf '  %s[ -- ]%s %s (surface not exercised — this is not a pass)\n' "$c_dim" "$c_reset" "$*"; }
NOTE() { notes=$((notes+1)); printf "        %s[note]%s %s\n" "$c_dim" "$c_reset" "$*"; }
sent() { printf '        %ssent:%s %s\n' "$c_dim" "$c_reset" "$*"; }

# Response code for a request, or 000 if no HTTP response was obtained.
code() { curl -sk -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" "$@" 2>/dev/null; }

say "${c_bold}HTTP smuggling probe — ${TARGET}${c_reset}"
say "${c_dim}$(date -u '+%Y-%m-%dT%H:%M:%SZ') · payloads are benign · authorized targets only${c_dim}${c_reset}"

# ── Positive controls: what can this run actually test? ───────────────────────
#
# Reported first, because a verdict below is only readable if the control here
# passed. A surface whose tool is missing is measured as NOT-EXERCISED, exactly
# so a capability gap cannot masquerade as a clean result.
head2 "Controls — what this run can and cannot reach"

BASELINE="$(code --http2 "$TARGET/")"
if [ "$BASELINE" = "000" ]; then
  say "  ${c_warn}Origin did not complete an HTTPS handshake (code 000).${c_reset}"
  say "  Nothing below is readable without a reachable origin. Stopping."
  exit 2
fi
say "  baseline GET / (HTTP/2)      → ${BASELINE}   (reference for every 'differs from baseline' test)"

HAVE_NC=no;   command -v nc  >/dev/null 2>&1 && HAVE_NC=yes
HAVE_H11=no;  curl --http1.1 -o /dev/null -s --max-time 3 "$TARGET/" >/dev/null 2>&1 && HAVE_H11=yes
HAVE_H3=no;   curl --version 2>/dev/null | grep -qiE 'HTTP3|h3|quic' && HAVE_H3=yes
say "  nc present (port-80 surface) → ${HAVE_NC}"
say "  curl --http1.1 usable        → ${HAVE_H11}   (required to deliver TE to a backend at all)"
say "  curl --http3 usable          → ${HAVE_H3}   (required to exercise the QUIC surface)"

# ── Surface 1 — HTTP/1.1 plain, port 80 — CL.TE via nc ───────────────────────
head2 "Surface 1 — HTTP/1.1 plain (port 80) — CL.TE"
if [ "$HAVE_NC" = yes ]; then
  R="$(printf 'POST / HTTP/1.1\r\nHost: %s\r\nContent-Length: 13\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nSMUGGLED' "$HOST" \
        | nc -w 5 "$HOST" 80 2>&1 | head -1)"
  sent 'POST / … Content-Length: 13 + Transfer-Encoding: chunked  (CL.TE, nc → :80)'
  if [ -z "$R" ]; then
    OKV "port 80 accepted no plain HTTP (connection refused or dropped) — no CL.TE surface here"
  elif printf '%s' "$R" | grep -q '30[128]'; then
    OKV "port 80 redirects to HTTPS without parsing the body — no CL.TE surface here (${R})"
  else
    FIND "port 80 answered a plain-HTTP POST with a body: ${R}"
  fi
else
  NX "port 80 CL.TE — nc not installed"
fi

# ── Surface 2 — HTTP/1.1 over TLS, forced — CL.TE and TE.CL ───────────────────
head2 "Surface 2 — HTTP/1.1 over TLS (--http1.1 forced) — CL.TE / TE.CL"
if [ "$HAVE_H11" = yes ]; then
  C_CLTE="$(code --http1.1 -X POST "$TARGET/" -H 'Content-Length: 13' -H 'Transfer-Encoding: chunked' --data-raw $'0\r\n\r\nSMUGGLED')"
  sent 'CL.TE  Content-Length: 13 + Transfer-Encoding: chunked'
  case "$C_CLTE" in
    400|403|422|426|501) OKV  "CL.TE rejected [$C_CLTE] — the affirmatively safe outcome" ;;
    000)                 NX   "CL.TE — no response (timeout/reset)" ;;
    *)                   NOTE "CL.TE accepted [$C_CLTE] — NOT a finding by itself: a status code cannot show a desync. Safe iff your proxy re-frames (decodes the body and rewrites Content-Length) rather than forwarding the client's framing. Confirm that separately." ;;
  esac
  C_TECL="$(code --http1.1 -X POST "$TARGET/" -H 'Transfer-Encoding: chunked' -H 'Content-Length: 3' --data-raw $'1\r\nA\r\n0\r\n\r\n')"
  sent 'TE.CL  Transfer-Encoding: chunked + Content-Length: 3'
  case "$C_TECL" in
    400|403|422|426|501) OKV  "TE.CL rejected [$C_TECL] — the affirmatively safe outcome" ;;
    000)                 NX   "TE.CL — no response (timeout/reset)" ;;
    *)                   NOTE "TE.CL accepted [$C_TECL] — same caveat as CL.TE: acceptance is safe only if the proxy re-frames; the code alone proves nothing" ;;
  esac
else
  NX "HTTP/1.1-over-TLS CL.TE/TE.CL — this curl cannot force --http1.1, so the TE payload never leaves the wire"
fi

# ── Surface 3 — HTTP/2 — the structural TE prohibition (RFC 9113 §8.2.2) ──────
head2 "Surface 3 — HTTP/2 over TLS — RFC 9113 §8.2.2 (TE MUST be rejected)"
C_H2="$(code --http2 -X POST "$TARGET/" -H 'Transfer-Encoding: chunked' -H 'Content-Length: 3' --data-raw $'1\r\nA\r\n0\r\n\r\n')"
sent 'CL.TE over HTTP/2 (curl code: '"$C_H2"')'
# curl's HTTP/2 stack (nghttp2) refuses or strips a Transfer-Encoding header
# before the wire, so this cannot deliver the payload and the result is not a
# server verdict either way. The article's own warning; do not read it as clean.
case "$C_H2" in
  400|403|422)  OKV "HTTP/2 rejected the request [$C_H2] — consistent with §8.2.2 enforcement, though curl may have refused it first" ;;
  *)            NX  "HTTP/2 CL.TE [$C_H2] — curl cannot reliably put Transfer-Encoding on an h2 stream; a raw-frame client is needed to test this surface" ;;
esac

# ── Surface 4 — TE.TE obfuscation, five variants ─────────────────────────────
#
# The article's sharpest test: five non-canonical TE encodings that a correct
# server must reject identically. It is not the absolute codes that matter but
# whether they AGREE — a single divergent variant is a parser that normalizes
# TE differently from its neighbour, which is the whole game.
head2 "Surface 4 — TE.TE obfuscation (five variants must agree)"
declare -a V_LABEL V_CODE
probe_te() {
  local label="$1"; shift
  local c; c="$(code --http2 -X POST "$TARGET/" "$@" --data-raw 'test')"
  V_LABEL+=("$label"); V_CODE+=("$c")
  printf '        %-34s → [%s]\n' "$label" "$c"
}
probe_te 'TE: xchunked'                 -H 'Transfer-Encoding: xchunked'
probe_te 'TE: chunked, identity'        -H 'Transfer-Encoding: chunked, identity'
probe_te 'TE: ["chunked"]'              -H 'Transfer-Encoding: ["chunked"]'
probe_te 'TE: chunked (x2 header)'      -H 'Transfer-Encoding: chunked' -H 'Transfer-Encoding: identity'
probe_te 'TE:<tab>chunked'             -H $'Transfer-Encoding:\tchunked'
uniq_codes="$(printf '%s\n' "${V_CODE[@]}" | sort -u | tr '\n' ' ')"
if [ "$(printf '%s\n' "${V_CODE[@]}" | sort -u | wc -l)" -eq 1 ]; then
  if printf '%s' "${V_CODE[0]}" | grep -q '000\|200'; then
    NX  "all five agree at [${V_CODE[0]}] — but that code means curl stripped TE; agreement here is not evidence of enforcement"
  else
    OKV "all five variants rejected identically [${V_CODE[0]}] — no parser disagreement exposed"
  fi
else
  FIND "variants diverge: { ${uniq_codes}} — at least two TE encodings are parsed differently, which is a desync surface"
fi

# ── Surface 5 — HTTP/3 / QUIC (RFC 9114 §4.2) ────────────────────────────────
head2 "Surface 5 — HTTP/3 / QUIC — RFC 9114 §4.2 (TE MUST NOT appear)"
if [ "$HAVE_H3" = yes ]; then
  C_H3="$(code --http3 -X POST "$TARGET/" -H 'Transfer-Encoding: chunked' -H 'Content-Length: 3' --data-raw $'1\r\nA\r\n0\r\n\r\n')"
  sent 'CL.TE over QUIC'
  case "$C_H3" in
    400|403|422)  OKV "HTTP/3 rejected the TE-bearing request [$C_H3] — RFC 9114 §4.2 enforced (a request with Transfer-Encoding is malformed)" ;;
    000)          NX  "HTTP/3 CL.TE — QUIC handshake did not complete" ;;
    *)            NOTE "HTTP/3 returned [$C_H3] — inconclusive: nghttp3 may have dropped Transfer-Encoding before sending, so this does not prove the server accepted it. A raw-frame QUIC client is needed to test §4.2 directly." ;;
  esac
else
  ALT="$(curl -sk --http2 -I -D - --max-time "$TIMEOUT" "$TARGET/" 2>/dev/null | tr -d '\r' | grep -i '^alt-svc:' | head -1)"
  if printf '%s' "$ALT" | grep -qi 'h3'; then
    NX "origin advertises HTTP/3 (${ALT#*: }) but this curl cannot speak QUIC — an advertised, UNTESTED surface"
  else
    NX "HTTP/3 — no --http3 in curl and no h3 in Alt-Svc; cannot confirm the QUIC surface either way"
  fi
fi

# ── Surface 6 — WebTransport isolation (QUIC :4433) ──────────────────────────
head2 "Surface 6 — WebTransport port ${QUIC_PORT} (UDP/QUIC only)"
TCP_STATE=closed
if command -v timeout >/dev/null 2>&1; then
  timeout 4 bash -c "exec 3<>/dev/tcp/${HOST}/${QUIC_PORT}" 2>/dev/null && TCP_STATE=open
fi
if [ "$TCP_STATE" = closed ]; then
  OKV "TCP :${QUIC_PORT} closed — correct; a naive port scan reads this as 'nothing here', which is the trap"
else
  FIND "TCP :${QUIC_PORT} is OPEN — something is accepting TCP on a UDP service port; investigate"
fi
C_WT="$(code "https://${HOST}:${QUIC_PORT}/")"
sent "HTTPS-over-TCP to :${QUIC_PORT}/"
if [ "$C_WT" = 000 ]; then
  OKV "HTTPS/TCP to :${QUIC_PORT} → 000 — no TCP HTTP service, as expected for a QUIC-only port"
else
  FIND "HTTPS/TCP to :${QUIC_PORT} → [$C_WT] — a TCP HTTP responder on the QUIC port"
fi

# ── Surface 7 — HTTP/2 rapid-reconnect stability (CVE-2023-44487 shape) ───────
#
# Ten requests is a liveness check, not a Rapid Reset flood — enough to catch a
# server already mishandling the HTTP/2 lifecycle, deliberately not enough to be
# an attack. A real Rapid Reset test is not something to fire at an origin you do
# not own.
head2 "Surface 7 — HTTP/2 rapid reconnect (stability, not a flood)"
codes=""
for _ in $(seq 1 10); do codes="$codes $(code --http2 "$TARGET/")"; done
sent '10 sequential HTTP/2 GET /'
if printf '%s' "$codes" | grep -qvE '(^| )(200|403|429)( |$)'; then
  FIND "unexpected codes under rapid reconnect:${codes}"
else
  OKV "stable under rapid reconnect:${codes}"
fi

# ── Surface 8 — adjacent: trusted-header injection (baseline diff) ────────────
#
# Not smuggling, the same disease: a header one side trusts and the other lets
# through. Any code that differs from the baseline is a header the origin acted
# on. IPv6 loopback forms are included because ASCII filters miss them.
head2 "Surface 8 — trusted-header injection (any change from baseline is a finding)"
diverged=0
try_hdr() {
  local label="$1"; shift
  local c; c="$(code --http2 "$TARGET/" "$@")"
  if [ "$c" != "$BASELINE" ] && [ "$c" != 000 ]; then
    FIND "${label} → [$c], differs from baseline [$BASELINE] — origin acted on the header"
    diverged=$((diverged+1))
  else
    printf '        %-40s → [%s] (same as baseline)\n' "$label" "$c"
  fi
}
try_hdr 'X-Forwarded-For: 127.0.0.1'          -H 'X-Forwarded-For: 127.0.0.1'
try_hdr 'X-Forwarded-For: ::ffff:127.0.0.1'   -H 'X-Forwarded-For: ::ffff:127.0.0.1'
try_hdr 'X-Original-URL: /admin'              -H 'X-Original-URL: /admin'
try_hdr 'X-Custom-IP-Authorization: 127.0.0.1' -H 'X-Custom-IP-Authorization: 127.0.0.1'
[ "$diverged" -eq 0 ] && OKV "no injected forwarding header changed the response"

# ── Summary ──────────────────────────────────────────────────────────────────
head2 "Summary"
say "  ${c_ok}${ok} ok${c_reset}   ${c_warn}${findings} finding(s)${c_reset}   ${notes} note(s)   ${not_exercised} not-exercised"
say "  ${c_dim}A finding is a diagnostic signal a status code can carry on its own: variant"
say "  divergence, an open TCP port on a UDP service, a reflected forwarding header, a body"
say "  parsed on port 80. A note is a request that was accepted — not a vuln by itself, since"
say "  a status code cannot show a desync; it is safe only if the proxy re-frames the request."
say "  'not-exercised' means this run could not reach that surface — install nc, or a curl"
say "  with --http3, and run again before concluding anything about it.${c_reset}"
[ "$findings" -gt 0 ] && exit 1 || exit 0
