Sanitize actor displaynames (#4864)

* Sanitize AP name possible XSS

* Pull out sanitizing method + remove rendering markdown for AP chat messages

* Add tests around AP message rendering and usernames

* Add sanitization tests to AP integration test
This commit is contained in:
Gabe Kangas
2026-03-31 20:33:05 -07:00
committed by GitHub
parent a2981903c2
commit e738156fd7
7 changed files with 783 additions and 109 deletions
@@ -1,57 +0,0 @@
name: ActivityPub Federation Tests
on:
push:
paths:
- "activitypub/**"
- "webserver/**"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-federation-tests.yaml"
pull_request:
paths:
- "activitypub/**"
- "webserver/**"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-federation-tests.yaml"
jobs:
federation-test:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: "same_content_newer"
- name: Check out repository code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "^1.23"
cache: true
- name: Install Caddy
run: |
sudo apt-get update
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update
sudo apt-get install -y caddy
- name: Install dependencies
run: |
sudo apt-get install -y snac2 ffmpeg
- name: Run setup
run: |
cd test/automated/activitypub
sudo ./setup.sh
- name: Run federation test
run: |
cd test/automated/activitypub
CI=true USER_COUNT=20 ./run.sh
@@ -1,47 +0,0 @@
name: ActivityPub Follower Validation Tests
on:
push:
paths:
- "activitypub/**"
- "config/**"
- "main.go"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-follower-validation-tests.yaml"
pull_request:
paths:
- "activitypub/**"
- "config/**"
- "main.go"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-follower-validation-tests.yaml"
jobs:
follower-validation-test:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: "same_content_newer"
- name: Check out repository code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "^1.23"
cache: true
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y sqlite3 ffmpeg jq
- name: Run follower validation test
timeout-minutes: 15
run: |
cd test/automated/activitypub
./test-follower-validation.sh
+130
View File
@@ -0,0 +1,130 @@
name: ActivityPub Tests
on:
push:
paths:
- "activitypub/**"
- "core/chat/**"
- "config/**"
- "main.go"
- "webserver/**"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-tests.yaml"
pull_request:
paths:
- "activitypub/**"
- "core/chat/**"
- "config/**"
- "main.go"
- "webserver/**"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-tests.yaml"
jobs:
federation-test:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: "same_content_newer"
- name: Check out repository code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "^1.23"
cache: true
- name: Install Caddy
run: |
sudo apt-get update
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update
sudo apt-get install -y caddy
- name: Install dependencies
run: |
sudo apt-get install -y snac2 ffmpeg
- name: Run setup
run: |
cd test/automated/activitypub
sudo ./setup.sh
- name: Run federation test
run: |
cd test/automated/activitypub
CI=true USER_COUNT=20 ./test-federation.sh
chat-sanitization-test:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: "same_content_newer"
- name: Check out repository code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "^1.23"
cache: true
- name: Install Caddy
run: |
sudo apt-get update
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update
sudo apt-get install -y caddy
- name: Install dependencies
run: |
sudo apt-get install -y snac2 ffmpeg
- name: Run setup
run: |
cd test/automated/activitypub
sudo ./setup.sh
- name: Run chat sanitization test
run: |
cd test/automated/activitypub
CI=true ./test-chat-sanitization.sh
follower-validation-test:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: "same_content_newer"
- name: Check out repository code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "^1.23"
cache: true
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y sqlite3 ffmpeg jq
- name: Run follower validation test
timeout-minutes: 15
run: |
cd test/automated/activitypub
./test-follower-validation.sh
+13 -4
View File
@@ -4,12 +4,24 @@ import (
"fmt" "fmt"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/microcosm-cc/bluemonday"
"github.com/owncast/owncast/activitypub/resolvers" "github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/persistence/configrepository"
) )
// sanitizeActorName strips HTML tags from the ActivityPub actor display name.
// Falls back to the username if the display name is empty or entirely HTML.
func sanitizeActorName(displayName, username string) string {
strict := bluemonday.StrictPolicy()
name := strict.Sanitize(displayName)
if name == "" {
name = strict.Sanitize(username)
}
return name
}
func handleEngagementActivity(eventType events.EventType, isLiveNotification bool, actorReference vocab.ActivityStreamsActorProperty, action string) error { func handleEngagementActivity(eventType events.EventType, isLiveNotification bool, actorReference vocab.ActivityStreamsActorProperty, action string) error {
configRepository := configrepository.Get() configRepository := configrepository.Get()
@@ -30,10 +42,7 @@ func handleEngagementActivity(eventType events.EventType, isLiveNotification boo
} }
// Send chat message // Send chat message
actorName := actor.Name actorName := sanitizeActorName(actor.Name, actor.Username)
if actorName == "" {
actorName = actor.Username
}
actorIRI := actor.ActorIriString() actorIRI := actor.ActorIriString()
userPrefix := fmt.Sprintf("%s ", actorName) userPrefix := fmt.Sprintf("%s ", actorName)
+120
View File
@@ -0,0 +1,120 @@
package inbox
import (
"testing"
)
func TestSanitizeActorName(t *testing.T) {
tests := []struct {
name string
displayName string
username string
expected string
}{
{
name: "plain display name",
displayName: "Alice",
username: "alice",
expected: "Alice",
},
{
name: "display name with emoji",
displayName: "Alice 🦊",
username: "alice",
expected: "Alice 🦊",
},
{
name: "display name with unicode",
displayName: "Ålice Böb",
username: "alice",
expected: "Ålice Böb",
},
{
name: "empty display name falls back to username",
displayName: "",
username: "alice",
expected: "alice",
},
{
name: "script tag in display name",
displayName: `<script>alert("xss")</script>`,
username: "alice",
expected: "alice",
},
{
name: "iframe injection in display name",
displayName: `<iframe src="https://evil.com" style="position:fixed;top:0;left:0;width:100%;height:100%"></iframe>`,
username: "alice",
expected: "alice",
},
{
name: "img tag in display name",
displayName: `<img src="https://evil.com/track.png">`,
username: "alice",
expected: "alice",
},
{
name: "form injection in display name",
displayName: `<form action="https://evil.com/steal"><input name="pw" type="password"></form>`,
username: "alice",
expected: "alice",
},
{
name: "meta refresh in display name",
displayName: `<meta http-equiv="refresh" content="0;url=https://evil.com">`,
username: "alice",
expected: "alice",
},
{
name: "mixed text and HTML in display name",
displayName: `Alice <script>alert(1)</script> Bob`,
username: "alice",
expected: "Alice Bob",
},
{
name: "custom emoji HTML in display name",
displayName: `Alice :blobcat: <img src="https://instance.com/emoji/blobcat.png" class="custom-emoji">`,
username: "alice",
expected: "Alice :blobcat: ",
},
{
name: "HTML in both display name and username",
displayName: `<script>alert(1)</script>`,
username: `<b>alice</b>`,
expected: "alice",
},
{
name: "entirely HTML display name falls back to username",
displayName: `<div></div>`,
username: "alice",
expected: "alice",
},
{
name: "style tag in display name",
displayName: `<style>body{display:none}</style>Alice`,
username: "alice",
expected: "Alice",
},
{
name: "nested HTML tags",
displayName: `<div><span><a href="https://evil.com">Click me</a></span></div>`,
username: "alice",
expected: "Click me",
},
{
name: "event handler attributes",
displayName: `<img src=x onerror="alert(1)">Alice`,
username: "alice",
expected: "Alice",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sanitizeActorName(tt.displayName, tt.username)
if result != tt.expected {
t.Errorf("sanitizeActorName(%q, %q) = %q, want %q", tt.displayName, tt.username, result, tt.expected)
}
})
}
}
-1
View File
@@ -126,7 +126,6 @@ func SendFediverseAction(eventType string, userAccountName string, image *string
} }
message.SetDefaults() message.SetDefaults()
message.RenderBody()
if err := Broadcast(&message); err != nil { if err := Broadcast(&message); err != nil {
log.Errorln("error sending system message", err) log.Errorln("error sending system message", err)
+520
View File
@@ -0,0 +1,520 @@
#!/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"]='<script>alert("xss")</script>'
["htmliframe"]='<iframe src="https://evil.com"></iframe>'
["htmlimg"]='<img src="https://evil.com/track.png">'
["htmlform"]='<form action="https://evil.com"><input type="password"></form>'
["htmlmeta"]='<meta http-equiv="refresh" content="0;url=https://evil.com">'
["htmlstyle"]='<style>body{display:none}</style>Visible'
["mdimage"]='![xss](https://evil.com/track.png)'
["mdlink"]='[Click me](https://evil.com)'
["mixedhtml"]='Alice <script>alert(1)</script> Bob'
["cleanuser"]='Legitimate User'
)
# Patterns that must NOT appear in stored chat message bodies
FORBIDDEN_PATTERNS=(
'<script'
'<iframe'
'<img'
'<form'
'<meta'
'<style'
'<input'
'src="https://evil'
'action="https://evil'
'onerror='
'onload='
)
kill_leftover_processes() {
local killed=false
if pkill -f "snac httpd /tmp" 2>/dev/null; then killed=true; fi
if pkill -f "local-proxy.js" 2>/dev/null; then killed=true; fi
local proxy_pid
proxy_pid=$(lsof -ti :"${PROXY_PORT}" 2>/dev/null) || true
if [[ -n "${proxy_pid}" ]]; then
kill "${proxy_pid}" 2>/dev/null || true
killed=true
fi
local snac_pid
snac_pid=$(lsof -ti :"${SNAC_PORT}" 2>/dev/null) || true
if [[ -n "${snac_pid}" ]]; then
kill "${snac_pid}" 2>/dev/null || true
killed=true
fi
if [[ "${killed}" == "true" ]]; then
log_info "Killed leftover processes from previous run"
sleep 1
fi
}
cleanup() {
log_info "Cleaning up..."
if [[ -n "${PROXY_PID}" ]] && kill -0 "${PROXY_PID}" 2>/dev/null; then
kill "${PROXY_PID}" 2>/dev/null || true
wait "${PROXY_PID}" 2>/dev/null || true
fi
if [[ -n "${OWNCAST_PID}" ]] && kill -0 "${OWNCAST_PID}" 2>/dev/null; then
kill "${OWNCAST_PID}" 2>/dev/null || true
wait "${OWNCAST_PID}" 2>/dev/null || true
fi
if [[ -n "${SNAC_PID}" ]] && kill -0 "${SNAC_PID}" 2>/dev/null; then
kill "${SNAC_PID}" 2>/dev/null || true
wait "${SNAC_PID}" 2>/dev/null || true
fi
if [[ -n "${TEMP_DIR}" ]] && [[ -d "${TEMP_DIR}" ]]; then
rm -rf "${TEMP_DIR}"
fi
log_info "Cleanup complete."
}
trap cleanup EXIT
setup_temp_dir() {
TEMP_DIR=$(mktemp -d)
SNAC_DATA_DIR="${TEMP_DIR}/snac-data"
OWNCAST_DB="${TEMP_DIR}/owncast.db"
log_info "Temp directory: ${TEMP_DIR}"
}
check_hosts_entry() {
if ! grep -q "${OWNCAST_HOSTNAME}" /etc/hosts 2>/dev/null || ! grep -q "${SNAC_HOSTNAME}" /etc/hosts 2>/dev/null; then
log_warn "Required /etc/hosts entries not found."
log_warn "Please add: 127.0.0.1 ${OWNCAST_HOSTNAME} ${SNAC_HOSTNAME}"
exit 1
fi
log_info "Hosts entries verified"
}
install_snac2() {
log_info "Setting up snac2..."
if command -v snac &> /dev/null; then
SNAC_BIN=$(command -v snac)
log_info "Using system snac2: ${SNAC_BIN}"
return
fi
local snac_src="${TEMP_DIR}/snac2-src"
log_info "Cloning snac2..."
git clone --depth 1 https://codeberg.org/grunfink/snac2.git "${snac_src}" 2>/dev/null
log_info "Building snac2..."
pushd "${snac_src}" > /dev/null
make
SNAC_BIN="${snac_src}/snac"
popd > /dev/null
log_info "snac2 built: ${SNAC_BIN}"
}
check_certs() {
# CERT_DIR may be set by the Docker entrypoint; fall back to local certs/
CERT_DIR="${CERT_DIR:-${SCRIPT_DIR}/certs}"
if [[ ! -f "${CERT_DIR}/cert.pem" ]] || [[ ! -f "${CERT_DIR}/key.pem" ]]; then
log_error "Certificates not found in ${CERT_DIR}. See README.md for setup."
exit 1
fi
log_info "Using certificates from ${CERT_DIR}"
}
init_snac2() {
log_info "Initializing snac2..."
local snac_host_port="${SNAC_HOSTNAME}:${PROXY_PORT}"
printf "127.0.0.1\n%s\n%s\n\ntest@test.local\n" "${SNAC_PORT}" "${snac_host_port}" | \
"${SNAC_BIN}" init "${SNAC_DATA_DIR}" > /dev/null 2>&1
log_info "snac2 initialized"
}
create_malicious_users() {
log_info "Creating snac2 users with malicious display names..."
local run_id
run_id=$(date +%s%N | sha256sum | head -c 8)
local created=0
for username in "${!MALICIOUS_USERS[@]}"; do
local displayname="${MALICIOUS_USERS[$username]}"
local full_username="${username}${run_id}"
if printf "%s\n%s\n" "${full_username}" "${displayname}" | "${SNAC_BIN}" adduser "${SNAC_DATA_DIR}" > /dev/null 2>&1; then
# Update the key to include the run_id so we can find them later
MALICIOUS_USERS["${username}"]="${displayname}"
# Store the full username for follow requests
SNAC_FULL_USERNAMES["${username}"]="${full_username}"
created=$((created + 1))
log_info " Created user '${full_username}' with display name: ${displayname}"
else
log_error " Failed to create user '${full_username}'"
fi
done
log_info "Created ${created} users with malicious display names"
}
start_proxy() {
log_info "Starting HTTPS reverse proxy (Caddy)..."
if ! command -v caddy &> /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 '<img.*src=.*evil'; then
log_error "FAIL: Markdown image syntax was rendered as HTML img tag"
passed=false
else
tests_passed=$((tests_passed + 1))
log_test "PASS: No markdown-rendered img tags found"
fi
# Test 5: No markdown link syntax rendered as HTML anchor tags with evil URLs
tests_run=$((tests_run + 1))
if echo "${messages}" | grep -qi '<a.*href=.*evil'; then
log_error "FAIL: Markdown link syntax was rendered as HTML anchor tag"
passed=false
else
tests_passed=$((tests_passed + 1))
log_test "PASS: No markdown-rendered anchor tags with evil URLs found"
fi
# Print all stored messages for inspection
echo ""
log_info "All stored engagement messages:"
echo "${messages}" | while read -r line; do
log_info " ${line}"
done
echo ""
echo "========================================"
echo "Chat Sanitization Test Results"
echo "========================================"
echo "Tests run: ${tests_run}"
echo "Tests passed: ${tests_passed}"
echo "========================================"
if [[ "${passed}" == "true" ]]; then
echo -e "${GREEN}TEST PASSED${NC}"
return 0
else
echo -e "${RED}TEST FAILED${NC}"
return 1
fi
}
# We need an associative array for the full usernames
declare -A SNAC_FULL_USERNAMES
main() {
kill_leftover_processes
echo ""
echo "========================================"
echo "Chat Sanitization Test"
echo "========================================"
echo ""
# Setup infrastructure
setup_temp_dir
check_hosts_entry
install_snac2
check_certs
init_snac2
create_malicious_users
start_proxy
start_snac2
build_owncast
start_owncast
configure_owncast
sleep 2
# Send follows from malicious users
send_follow_requests
# Verify results
if verify_chat_sanitization; then
exit 0
else
exit 1
fi
}
main "$@"