#!/bin/bash # shellcheck disable=SC2317,SC2329 # cleanup() is invoked via trap, not direct call # shellcheck disable=SC2034 # SNAC_URL is unused but kept for consistency with other AP tests # Chat Sanitization Test for Fediverse Engagement Events # # This test verifies that malicious HTML and markdown in ActivityPub actor # display names is sanitized before being rendered in chat messages. # # The test: # 1. Starts snac2 + Owncast + HTTPS proxy (same as federation test) # 2. Creates snac2 users with malicious display names (HTML, markdown) # 3. Enables engagement display in chat # 4. Has the malicious users follow Owncast # 5. Queries the chat messages from the database # 6. Verifies no HTML tags or markdown artifacts appear in stored messages # # Prerequisites: # Add to /etc/hosts: 127.0.0.1 owncast.local snac.local set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(git rev-parse --show-toplevel)" # Configuration FOLLOW_DELAY="${FOLLOW_DELAY:-0.3}" CI="${CI:-false}" PROXY_PORT="${PROXY_PORT:-8443}" SNAC_PORT="${SNAC_PORT:-9080}" SNAC_HOSTNAME="snac.local" OWNCAST_PORT="${OWNCAST_PORT:-8080}" OWNCAST_HOSTNAME="owncast.local" ADMIN_USER="admin" ADMIN_PASS="abc123" FEDERATION_USERNAME="streamer" # URLs SNAC_URL="https://${SNAC_HOSTNAME}:${PROXY_PORT}" OWNCAST_URL="https://${OWNCAST_HOSTNAME}:${PROXY_PORT}" # Directories TEMP_DIR="" SNAC_DATA_DIR="" SNAC_BIN="" OWNCAST_DB="" # PIDs SNAC_PID="" OWNCAST_PID="" PROXY_PID="" # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NC='\033[0m' log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } log_test() { echo -e "${CYAN}[TEST]${NC} $1"; } # Test users: username -> malicious display name # These simulate real-world attack payloads from the security advisory declare -A MALICIOUS_USERS MALICIOUS_USERS=( ["htmlscript"]='' ["htmliframe"]='' ["htmlimg"]='' ["htmlform"]='
' ["htmlmeta"]='' ["htmlstyle"]='Visible' ["mdimage"]='![xss](https://evil.com/track.png)' ["mdlink"]='[Click me](https://evil.com)' ["mixedhtml"]='Alice Bob' ["cleanuser"]='Legitimate User' ) # Patterns that must NOT appear in stored chat message bodies FORBIDDEN_PATTERNS=( ' /dev/null; then log_error "Caddy is not installed." return 1 fi export PROXY_PORT OWNCAST_PORT SNAC_PORT export CERT_FILE="${CERT_DIR}/cert.pem" export KEY_FILE="${CERT_DIR}/key.pem" local caddy_log="${TEMP_DIR}/caddy.log" caddy run --config "${SCRIPT_DIR}/Caddyfile" --adapter caddyfile > "${caddy_log}" 2>&1 & PROXY_PID=$! sleep 2 if ! kill -0 "${PROXY_PID}" 2>/dev/null; then log_error "Caddy failed to start" return 1 fi local max_attempts=10 local attempt=0 while [[ ${attempt} -lt ${max_attempts} ]]; do if curl -sk "https://127.0.0.1:${PROXY_PORT}/" > /dev/null 2>&1; then log_info "Caddy proxy is ready" return 0 fi attempt=$((attempt + 1)) sleep 1 done log_error "Caddy proxy did not become ready" return 1 } start_snac2() { log_info "Starting snac2 server..." local snac_log="${TEMP_DIR}/snac2.log" DEBUG=0 "${SNAC_BIN}" httpd "${SNAC_DATA_DIR}" > "${snac_log}" 2>&1 & SNAC_PID=$! local max_attempts=30 local attempt=0 while [[ ${attempt} -lt ${max_attempts} ]]; do if curl -s "http://127.0.0.1:${SNAC_PORT}/" > /dev/null 2>&1; then log_info "snac2 is ready" return 0 fi attempt=$((attempt + 1)) sleep 1 done log_error "snac2 did not become ready" return 1 } build_owncast() { log_info "Building Owncast..." pushd "${REPO_ROOT}" > /dev/null CGO_ENABLED=1 go build -o owncast main.go popd > /dev/null log_info "Owncast built" } start_owncast() { log_info "Starting Owncast..." OWNCAST_ALLOW_INTERNAL_FEDERATION=true \ OWNCAST_INSECURE_SKIP_VERIFY=true \ "${REPO_ROOT}/owncast" -database "${OWNCAST_DB}" & OWNCAST_PID=$! local max_attempts=30 local attempt=0 while [[ ${attempt} -lt ${max_attempts} ]]; do if curl -s "http://localhost:${OWNCAST_PORT}/api/status" > /dev/null 2>&1; then log_info "Owncast is ready" return 0 fi attempt=$((attempt + 1)) sleep 1 done log_error "Owncast did not become ready" return 1 } configure_owncast() { log_info "Configuring Owncast..." local base_url="http://localhost:${OWNCAST_PORT}" local auth auth=$(echo -n "${ADMIN_USER}:${ADMIN_PASS}" | base64) # Set server URL curl -s -X POST "${base_url}/api/admin/config/serverurl" \ -H "Authorization: Basic ${auth}" \ -H "Content-Type: application/json" \ -d "{\"value\": \"${OWNCAST_URL}\"}" > /dev/null # Set federation username curl -s -X POST "${base_url}/api/admin/config/federation/username" \ -H "Authorization: Basic ${auth}" \ -H "Content-Type: application/json" \ -d "{\"value\": \"${FEDERATION_USERNAME}\"}" > /dev/null # Enable federation curl -s -X POST "${base_url}/api/admin/config/federation/enable" \ -H "Authorization: Basic ${auth}" \ -H "Content-Type: application/json" \ -d '{"value": true}' > /dev/null # Disable private mode curl -s -X POST "${base_url}/api/admin/config/federation/private" \ -H "Authorization: Basic ${auth}" \ -H "Content-Type: application/json" \ -d '{"value": false}' > /dev/null # Enable engagement display in chat curl -s -X POST "${base_url}/api/admin/config/federation/showengagement" \ -H "Authorization: Basic ${auth}" \ -H "Content-Type: application/json" \ -d '{"value": true}' > /dev/null log_info "Owncast configured (engagement display enabled)" } send_follow_requests() { log_info "Sending follow requests from malicious users..." local owncast_actor="${OWNCAST_URL}/federation/user/${FEDERATION_USERNAME}" local successful=0 for username in "${!MALICIOUS_USERS[@]}"; do local full_username="${SNAC_FULL_USERNAMES[$username]}" local follow_output follow_output=$("${SNAC_BIN}" follow "${SNAC_DATA_DIR}" "${full_username}" "${owncast_actor}" 2>&1) local follow_exit=$? if [[ ${follow_exit} -eq 0 ]] && [[ ! "${follow_output}" =~ "cannot" ]]; then successful=$((successful + 1)) log_info " ${full_username} followed Owncast" else log_warn " ${full_username} follow failed: ${follow_output}" fi sleep "${FOLLOW_DELAY}" done log_test "${successful}/${#MALICIOUS_USERS[@]} follow requests sent" # Wait for follow requests to be processed local user_count=${#MALICIOUS_USERS[@]} local wait_time=$((15 + user_count)) log_info "Waiting ${wait_time}s for engagement events to be processed..." sleep "${wait_time}" } verify_chat_sanitization() { log_info "Verifying chat message sanitization..." local passed=true local tests_run=0 local tests_passed=0 # Query fediverse engagement messages from the database local messages messages=$(sqlite3 "${OWNCAST_DB}" \ "SELECT body FROM messages WHERE eventType IN ('FEDIVERSE_ENGAGEMENT_FOLLOW', 'FEDIVERSE_ENGAGEMENT_LIKE', 'FEDIVERSE_ENGAGEMENT_REPOST');" 2>/dev/null) if [[ -z "${messages}" ]]; then log_error "No fediverse engagement messages found in database" return 1 fi local message_count message_count=$(echo "${messages}" | wc -l | tr -d ' ') log_info "Found ${message_count} engagement messages in chat" # Test 1: No forbidden HTML patterns in any message for pattern in "${FORBIDDEN_PATTERNS[@]}"; do tests_run=$((tests_run + 1)) if echo "${messages}" | grep -qi "${pattern}"; then log_error "FAIL: Found forbidden pattern '${pattern}' in chat messages:" echo "${messages}" | grep -i "${pattern}" | while read -r line; do log_error " Body: ${line}" done passed=false else tests_passed=$((tests_passed + 1)) log_test "PASS: No '${pattern}' found in messages" fi done # Test 2: Verify the clean user's message is present # Note: snac2 may not serve the display name in actor objects, so we check # for either the display name or the username fallback. tests_run=$((tests_run + 1)) local clean_username="${SNAC_FULL_USERNAMES[cleanuser]}" if echo "${messages}" | grep -q "Legitimate User"; then tests_passed=$((tests_passed + 1)) log_test "PASS: Clean display name 'Legitimate User' preserved correctly" elif echo "${messages}" | grep -q "${clean_username}"; then tests_passed=$((tests_passed + 1)) log_test "PASS: Clean user present via username fallback '${clean_username}'" else log_error "FAIL: Clean user message not found in chat" passed=false fi # Test 3: Verify that messages with stripped HTML fell back to expected content # Messages from users whose display names were entirely HTML should show # the follow action text but not the HTML tests_run=$((tests_run + 1)) if echo "${messages}" | grep -q "followed this stream"; then tests_passed=$((tests_passed + 1)) log_test "PASS: Follow action text present in messages" else log_error "FAIL: No 'followed this stream' text found in any message" passed=false fi # Test 4: No markdown image syntax rendered as HTML img tags tests_run=$((tests_run + 1)) if echo "${messages}" | grep -qi '