check / check (push) Waiting to run
Every requirement is stated once, where it belongs, and linked from elsewhere. The notes on gitea move to docs/gitea.md, which script/gitea-requests points at. The list of settings that take off is exact, the non-goal that contradicted the planned item is gone, and the sentences the review named are split or reordered. The package comments in internal/waf and internal/anomaly point at the renamed sections. Model: fable-5-1
337 lines
13 KiB
Bash
Executable File
337 lines
13 KiB
Bash
Executable File
#!/bin/sh
|
|
# script/gitea-requests: build the image and run gitea 1.27.3 behind it,
|
|
# with every setting of smallwebwaf at its default but SWWAF_UPSTREAM_URL,
|
|
# which names gitea's container. Then send through it the gitea requests
|
|
# that reading gitea's source found the Core Rule Set refuses, which
|
|
# docs/gitea.md records, 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 smallwebwaf 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"
|
|
SMALLWEBWAF="$NAME-smallwebwaf"
|
|
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 files the script makes, and smallwebwaf'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 "$SMALLWEBWAF" "$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() {
|
|
status="$(docker inspect --format '{{.State.Health.Status}}' "$SMALLWEBWAF")"
|
|
[ "$status" = healthy ]
|
|
}
|
|
|
|
# logged <text>...: a line of smallwebwaf's output holds every text, in
|
|
# any order.
|
|
logged() {
|
|
lines="$(docker logs "$SMALLWEBWAF" 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
|
|
# smallwebwaf, with label as its X-Request-ID, 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 "X-Request-ID: $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=X-Request-ID: 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/&/\&/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 X-Request-ID, so its requests have the ids
|
|
# smallwebwaf gives them. 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=X-Request-ID: 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=X-Request-ID: 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 smallwebwaf logged, but its
|
|
# health checks: what it did with the request, the ids of the Core Rule
|
|
# Set's rules that matched it, its X-Request-ID, 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 "$SMALLWEBWAF" 2>/dev/null | grep '"type":"request"' |
|
|
grep -v '"action":"admin"' | 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")"
|
|
printf '%-11s %-13s %-17s %s %s%s\n' "$(field action "$line")" "${ids:--}" \
|
|
"$(field request_id "$line")" "$(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" .
|
|
|
|
# smallwebwaf 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 smallwebwaf's health check waits for it.
|
|
docker pull --quiet "$GITEA_IMAGE" >/dev/null
|
|
docker network create "$NETWORK" >/dev/null
|
|
docker run --detach --name "$SMALLWEBWAF" --network "$NETWORK" \
|
|
--publish 127.0.0.1::8080 \
|
|
--env SWWAF_UPSTREAM_URL="http://$GITEA:3000" \
|
|
"$IMAGE" >/dev/null
|
|
published="$(docker port "$SMALLWEBWAF" 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
|
|
# smallwebwaf's health check passes once gitea takes connections.
|
|
wait_for "the health check did not pass" healthy
|
|
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 "smallwebwaf logged no line for the fetch" \
|
|
logged '"request_id":"git-fetch"' '"method":"POST"'
|
|
report
|
|
}
|
|
|
|
main "$@"
|