#!/bin/sh
# script/gitea-requests: build the image and run gitea 1.27.3 behind rproxy
# and servewaf, three containers on one network. Every setting is at its
# default but these: SWRP_UPSTREAM_URL names gitea's container, SWRP_WAF_BACKEND
# names servewaf's, and SWWAF_LOG_REQUEST_HEADERS adds the header that labels a
# request. Then send through rproxy the gitea requests that reading gitea's
# source found the Core Rule Set refuses without the exclusions in
# deploy/gitea/crs-exclusions.conf, and a
# short sample of gitea's ordinary use: signing in, pages of files and commits,
# a diff, the API, a push, clone and fetch over HTTP, and two container image
# pushes. Last, print each request servewaf logged, with what it did with it and
# the ids of the Core Rule Set's rules that matched it.
# Every request comes from one client, docker's gateway, so more than
# SWWAF_ERROR_BURST_THRESHOLD, 30, refusals in a minute would ban it, and
# the requests after would show as banned. The containers, the network,
# the images and the files the script makes are removed however it ends;
# gitea's own image stays, as the base images of a build do. It needs
# network access, and a docker daemon that pushes to a registry on
# localhost over plain HTTP, as docker does by default. script/check does
# not run this.
set -eu

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"

# gitea 1.27.3, 2026-08-29
GITEA_IMAGE="gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef"

# Named after this run, so that runs in other clones on the same host
# never touch each other's.
NAME="$("$SCRIPT_DIR/projectname")-gitea-$$"
IMAGE="$NAME-image"
NETWORK="$NAME"
RPROXY="$NAME-rproxy"
SERVEWAF="$NAME-servewaf"
GITEA="$NAME-gitea"

# gitea's one user, who owns the repository and the container images.
PASSWORD="owner-password"

BROWSER="Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0"

# The header that labels a request in the report. rproxy makes every request's
# id itself, so a label cannot be the X-Request-ID.
LABEL_HEADER="X-Case"

# The default of SWWAF_LOG_REQUEST_HEADERS, and the header above.
LOGGED_HEADERS="accept,accept-language,accept-encoding,content-type,origin,range,x-case"

# The files the script makes, and rproxy's address once it runs, localhost and
# the port docker publishes it on: a host name, since the Core Rule Set takes a
# Host that is an IP address for a warning (920350).
WORK=""
address=""

cleanup() {
    # So that a signal cannot stop it partway through.
    trap '' HUP INT TERM
    docker rm --force --volumes "$RPROXY" "$SERVEWAF" "$GITEA" >/dev/null 2>&1 || true
    docker network rm "$NETWORK" >/dev/null 2>&1 || true
    docker rmi --force "$IMAGE" >/dev/null 2>&1 || true
    if [ -n "$address" ]; then
        docker rmi --force "$address/owner/python3-app:1" "$address/owner/app:1" \
            >/dev/null 2>&1 || true
    fi
    rm -rf "$WORK"
}

fail() {
    echo "gitea-requests: $*" >&2
    report
    exit 1
}

# wait_for <what fails> <command>...: run the command every second until
# it succeeds, for at most a minute.
wait_for() {
    failure="$1"
    shift
    tries=0
    until "$@"; do
        tries=$((tries + 1))
        [ "$tries" -lt 60 ] || fail "$failure"
        sleep 1
    done
}

# healthy <container>: docker says the container's health check passes.
healthy() {
    status="$(docker inspect --format '{{.State.Health.Status}}' "$1")"
    [ "$status" = healthy ]
}

# logged <text>...: a line of servewaf's output holds every text, in any order.
logged() {
    lines="$(docker logs "$SERVEWAF" 2>&1)"
    for text in "$@"; do
        lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1
    done
}

# send <label> <path> <curl option>...: send a request for path to rproxy, with
# label in LABEL_HEADER, which the report gives, and keep the body of the answer
# in $WORK/answer. The Core Rule Set reads that header too, so a label names no
# file the cases name.
send() {
    label="$1"
    path="$2"
    shift 2
    curl --silent --show-error --max-time 30 --output "$WORK/answer" \
        --header "$LABEL_HEADER: $label" "$@" "http://$address$path"
}

# page <label> <path> <curl option>...: send the request as a browser
# does, with the cookies gitea has set. Its Referer is the repository's
# page, which links to the others, and never a page the Core Rule Set
# refused: its address would have the next request refused too.
page() {
    label="$1"
    path="$2"
    shift 2
    send "$label" "$path" --compressed --user-agent "$BROWSER" \
        --header "Accept: text/html,application/xhtml+xml,*/*;q=0.8" \
        --header "Accept-Language: en-US,en;q=0.5" \
        --cookie "$WORK/cookies" --cookie-jar "$WORK/cookies" \
        --referer "http://$address/owner/repo" "$@"
}

# api <label> <path> <curl option>...: send the request as a client of
# gitea's API does, as the owner.
api() {
    label="$1"
    path="$2"
    shift 2
    send "$label" "$path" --user "owner:$PASSWORD" \
        --header "Accept: application/json" "$@"
}

# repo_git <git argument>...: run git as the owner, without the host's
# own git settings, so that nothing it does is kept outside $WORK.
repo_git() {
    GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git \
        -c user.name=owner -c user.email=owner@example.invalid "$@"
}

# lines: forty lines of text, enough for a diff to hide some of them.
lines() {
    i=1
    while [ "$i" -le 40 ]; do
        echo "line $i"
        i=$((i + 1))
    done
}

# make_repository: create the repository owner/repo, holding a file of
# each name the cases name, and push it, which is the push over HTTP of
# the sample. The second commit adds a line at the end of Runtime.java,
# so that its diff hides the lines above.
make_repository() {
    status="$(api create-repository /api/v1/user/repos \
        --header "Content-Type: application/json" --data '{"name":"repo"}' \
        --write-out '%{http_code}')"
    [ "$status" = 201 ] || fail "creating the repository was answered $status"

    dir="$WORK/repo"
    repo_git init --quiet --initial-branch=main "$dir"
    for file in \
        src/java.base/share/classes/java/lang/Runtime.java \
        java.lang.String.md \
        META-INF/services/org.apache.commons.logging.LogFactory \
        document.write.js \
        process.env.d.ts \
        test/fixtures/__proto__.json \
        install-script.sh \
        src/backend/catalog/information_schema.sql \
        .../notes.txt \
        README.md; do
        mkdir -p "$dir/$(dirname "$file")"
        lines >"$dir/$file"
        repo_git -C "$dir" add "$file"
    done
    repo_git -C "$dir" commit --quiet --message "Add the files"
    echo "line 41" >>"$dir/src/java.base/share/classes/java/lang/Runtime.java"
    repo_git -C "$dir" commit --quiet --all --message "Add a line"

    repo_git -C "$dir" -c "http.extraHeader=$LABEL_HEADER: git-push" push --quiet \
        "http://owner:$PASSWORD@$address/owner/repo.git" main ||
        fail "the push of the repository failed"
}

# browse: sign in, then open pages as a person does. The cases are those
# of https://git.eeqj.de/sneak/smallwebwaf/issues/30.
browse() {
    # Case: gitea sends a visitor who is not signed in to the sign-in
    # page, with the page asked for in redirect_to, when that page needs
    # signing in, as a file's edit page does.
    page issue-30-case /owner/repo/_edit/main/document.write.js --location

    page sign-in /user/login
    status="$(page sign-in /user/login --header "Origin: http://$address" \
        --data-urlencode user_name=owner --data-urlencode "password=$PASSWORD" \
        --write-out '%{http_code}')"
    [ "$status" = 303 ] || fail "signing in was answered $status"

    page browse /
    page browse /owner/repo
    page browse /owner/repo/src/branch/main/README.md
    page browse /owner/repo/commits/branch/main
    page browse "/owner/repo/commit/$(repo_git -C "$WORK/repo" rev-parse HEAD)"

    # Case: expanding the lines the diff hides, by the link the diff's
    # page gives for it.
    excerpt="$(sed -n 's/.*data-fetch-url="\([^"]*blob_excerpt[^"]*\)".*/\1/p' \
        "$WORK/answer" | head -n 1 | sed 's/&amp;/\&/g')"
    [ -n "$excerpt" ] || fail "the diff's page has no link to its hidden lines"
    page issue-30-case "$excerpt"

    # Cases: the commit graph of one file, and the pages of files whose
    # path the Core Rule Set takes for an attack.
    page issue-30-case "/owner/repo/graph?file=src/java.base/share/classes/java/lang/Runtime.java"
    page issue-30-case /owner/repo/src/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
    page issue-30-case /owner/repo/src/branch/main/java.lang.String.md
    page issue-30-case /owner/repo/src/branch/main/process.env.d.ts
    page issue-30-case /owner/repo/src/branch/main/.../notes.txt
}

# call_api: the API's calls the cases name: the commits of a file, for a
# file of each name the Core Rule Set takes for an attack in the query
# parameter path, and the file-contents call, which takes its list of
# files as JSON in the query parameter body.
call_api() {
    for file in \
        src/java.base/share/classes/java/lang/Runtime.java \
        java.lang.String.md \
        document.write.js \
        process.env.d.ts \
        test/fixtures/__proto__.json \
        install-script.sh \
        src/backend/catalog/information_schema.sql; do
        api issue-30-case /api/v1/repos/owner/repo/commits --get --data-urlencode "path=$file"
    done
    api issue-30-case /api/v1/repos/owner/repo/file-contents --get \
        --data-urlencode 'body={"files":["src/java.base/share/classes/java/lang/Runtime.java"]}'
}

# push_images: push a container image to owner/python3-app, then the same
# image to owner/app. For the second, the case, docker asks gitea to take
# its layer from owner/python3-app, naming it in the query parameter
# from. docker sends no label, so the report gives its requests none. The image
# is one small file, imported rather than built.
push_images() {
    echo "$NAME" >"$WORK/layer.txt"
    tar -cf - -C "$WORK" layer.txt |
        docker import - "$address/owner/python3-app:1" >/dev/null
    docker tag "$address/owner/python3-app:1" "$address/owner/app:1"

    # The login is written here rather than made with docker login, which
    # would keep it in the host's credential helper when it has one.
    mkdir "$WORK/docker"
    printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$address" \
        "$(printf 'owner:%s' "$PASSWORD" | base64)" >"$WORK/docker/config.json"
    for image in "$address/owner/python3-app:1" "$address/owner/app:1"; do
        DOCKER_CONFIG="$WORK/docker" docker push --quiet "$image" >/dev/null ||
            echo "gitea-requests: the push of $image failed" >&2
    done
}

# clone_and_fetch: the clone and fetch over HTTP of the sample.
clone_and_fetch() {
    repo_git -c "http.extraHeader=$LABEL_HEADER: git-clone" clone --quiet \
        "http://owner:$PASSWORD@$address/owner/repo.git" "$WORK/clone" ||
        fail "the clone failed"
    repo_git -C "$WORK/clone" -c "http.extraHeader=$LABEL_HEADER: git-fetch" fetch --quiet ||
        fail "the fetch failed"
}

# field <name> <line>: the text of the field name in line, a line of the
# request log, or nothing when it has none.
field() {
    printf '%s\n' "$2" | sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p"
}

# report: print a line for each request rproxy reported to servewaf: what
# servewaf did with the request, the ids of the Core Rule Set's rules that
# matched it, its label, and the request. waf_blocked is a refusal by the Core
# Rule Set, and forward a request passed on to gitea, whatever gitea answered.
report() {
    docker logs "$SERVEWAF" 2>/dev/null | grep '"servewaf_line":"report"' |
        sed 's/\\u0026/\&/g' >"$WORK/requests"
    while IFS= read -r line; do
        ids="$(printf '%s\n' "$line" | sed -n 's/.*"waf_rule_ids":\[\([0-9,]*\)\].*/\1/p')"
        query="$(field query "$line")"
        label="$(field x-case "$line")"
        printf '%-11s %-13s %-17s %s %s%s\n' "$(field action "$line")" "${ids:--}" \
            "${label:--}" "$(field method "$line")" \
            "$(field path "$line")" "${query:+?$query}"
    done <"$WORK/requests"
    echo "gitea-requests: $(grep -c . "$WORK/requests") requests," \
        "$(grep -c '"action":"waf_blocked"' "$WORK/requests") refused by the Core Rule Set"
}

main() {
    cd "$ROOT"
    trap cleanup EXIT
    trap 'exit 1' HUP INT TERM
    WORK="$(mktemp -d)"

    docker build --no-cache -t "$IMAGE" .

    # rproxy first, so that gitea can be given the address it is reached at,
    # and gitea's image pulled before, so that gitea starts within the minute
    # rproxy's health check waits for it.
    docker pull --quiet "$GITEA_IMAGE" >/dev/null
    docker network create "$NETWORK" >/dev/null
    docker run --detach --name "$SERVEWAF" --network "$NETWORK" \
        --env SMALLWEBWAF_COMMAND=servewaf \
        --env SWWAF_LOG_REQUEST_HEADERS="$LOGGED_HEADERS" \
        "$IMAGE" >/dev/null
    wait_for "servewaf's health check did not pass" healthy "$SERVEWAF"
    docker run --detach --name "$RPROXY" --network "$NETWORK" \
        --publish 127.0.0.1::8080 \
        --env SWRP_UPSTREAM_URL="http://$GITEA:3000" \
        --env SWRP_WAF_BACKEND="http://$SERVEWAF:8080" \
        "$IMAGE" >/dev/null
    published="$(docker port "$RPROXY" 8080/tcp)"
    address="localhost:${published##*:}"

    docker run --detach --name "$GITEA" --network "$NETWORK" \
        --env GITEA__security__INSTALL_LOCK=true \
        --env GITEA__server__ROOT_URL="http://$address/" \
        "$GITEA_IMAGE" >/dev/null
    # rproxy's health check passes once gitea takes connections.
    wait_for "rproxy's health check did not pass" healthy "$RPROXY"
    docker exec --user git "$GITEA" gitea admin user create --admin \
        --username owner --password "$PASSWORD" --email owner@example.invalid \
        --must-change-password=false >/dev/null

    make_repository
    browse
    call_api
    push_images
    clone_and_fetch

    # The fetch's last request is the last of all; once it is logged, so
    # is every other.
    wait_for "servewaf logged no report of the fetch" \
        logged '"x-case":"git-fetch"' '"method":"POST"' '"servewaf_line":"report"'
    report
}

main "$@"
