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:
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"]=''
|
||||||
|
["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 "$@"
|
||||||
Reference in New Issue
Block a user