189 lines
7.5 KiB
Bash
Executable File
189 lines
7.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Exercises the plugin's nginx config generation against a fake dokku layout,
|
|
# asserts the important directives are present, and (if docker is available)
|
|
# validates the result with a real nginx binary.
|
|
set -eo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
WORK="$(mktemp -d)"
|
|
trap 'rm -rf "$WORK"' EXIT
|
|
|
|
# Fake dokku host layout.
|
|
export DOKKU_ROOT="$WORK/dokku-root"
|
|
export DOKKU_LIB_ROOT="$WORK/dokku-lib"
|
|
export PLUGIN_CORE_AVAILABLE_PATH="$WORK/nonexistent" # force built-in fallbacks
|
|
APP="myapp"
|
|
mkdir -p "$DOKKU_ROOT/$APP" "$DOKKU_LIB_ROOT/data/google-auth/global"
|
|
|
|
# A minimal nginx.conf as dokku's template would generate it.
|
|
cat >"$DOKKU_ROOT/$APP/nginx.conf" <<'EOF'
|
|
upstream myapp-5000 {
|
|
server 172.17.0.3:5000;
|
|
}
|
|
server {
|
|
listen 80;
|
|
server_name myapp.example.com;
|
|
}
|
|
EOF
|
|
|
|
# shellcheck disable=SC1091
|
|
source "$ROOT/functions"
|
|
|
|
fail() {
|
|
echo "FAIL: $*" 1>&2
|
|
exit 1
|
|
}
|
|
|
|
# --- pattern validation ---
|
|
fn-ga-validate-pattern "/api/webhooks" || fail "prefix pattern should be valid"
|
|
fn-ga-validate-pattern "re:^/v[0-9]+/public/" || fail "regex pattern should be valid"
|
|
fn-ga-validate-pattern "api/webhooks" && fail "pattern without leading / should be invalid"
|
|
fn-ga-validate-pattern "/x; }" && fail "pattern with injection chars should be invalid"
|
|
fn-ga-validate-pattern '/x{2}' && fail "pattern with braces should be invalid"
|
|
fn-ga-validate-pattern "" && fail "empty pattern should be invalid"
|
|
echo "ok: pattern validation"
|
|
|
|
# --- conf generation ---
|
|
fn-ga-app-set-enabled "$APP" true
|
|
fn-ga-exclude-add "$APP" "/api/webhooks"
|
|
fn-ga-exclude-add "$APP" "re:^/healthz$"
|
|
|
|
CONF="$WORK/google-auth.conf"
|
|
fn-ga-generate-conf "$APP" >"$CONF" || fail "conf generation failed"
|
|
|
|
grep -q 'proxy_pass http://myapp-5000;' "$CONF" || fail "conf should proxy to the app upstream"
|
|
grep -q 'auth_request /_google-auth/verify;' "$CONF" || fail "conf should gate with auth_request"
|
|
grep -q 'location ^~ /api/webhooks {' "$CONF" || fail "conf should contain prefix exclusion"
|
|
grep -q 'location ~ ^/healthz$ {' "$CONF" || fail "conf should contain regex exclusion"
|
|
grep -q 'error_page 401 = @google_auth_signin;' "$CONF" || fail "conf should redirect 401s to signin"
|
|
grep -q 'proxy_set_header X-Forwarded-Email \$google_auth_email;' "$CONF" || fail "conf should forward the email header"
|
|
grep -q 'proxy_set_header X-Forwarded-Email "";' "$CONF" || fail "excluded paths should strip identity headers"
|
|
echo "ok: conf contents"
|
|
|
|
# --- the app name must be stamped on every request that reaches the service ---
|
|
# Checking the invariant rather than a fixed list of locations: any location
|
|
# that proxies to the auth service must set X-Google-Auth-App to this app, or
|
|
# the service would fall back to the global lists (silently widening access for
|
|
# an app whose own allow list is narrower). Any location that proxies to the
|
|
# app must blank it, so clients cannot pass one through.
|
|
awk -v app="$APP" '
|
|
/^location/ { block = $0; inside = 1; to_service = 0; to_app = 0; stamped = 0; blanked = 0; next }
|
|
inside && /^}/ {
|
|
if (to_service && !stamped) { printf "location reaching the auth service without the app header: %s\n", block; bad = 1 }
|
|
if (to_app && !blanked) { printf "location reaching the app without blanking the app header: %s\n", block; bad = 1 }
|
|
inside = 0; next
|
|
}
|
|
inside {
|
|
if ($0 ~ /proxy_pass http:\/\/127\.0\.0\.1:/) to_service = 1
|
|
if ($0 ~ /proxy_pass http:\/\/myapp-5000;/) to_app = 1
|
|
if ($0 == sprintf(" proxy_set_header X-Google-Auth-App \"%s\";", app)) stamped = 1
|
|
if ($0 == " proxy_set_header X-Google-Auth-App \"\";") blanked = 1
|
|
}
|
|
END { exit bad }
|
|
' "$CONF" || fail "X-Google-Auth-App is not handled consistently across locations"
|
|
# Guard the guard: the awk above must actually see both kinds of location.
|
|
[[ "$(grep -c 'proxy_set_header X-Google-Auth-App "myapp";' "$CONF")" -eq 3 ]] ||
|
|
fail "expected the app header on the three auth-service locations"
|
|
echo "ok: app name stamped for the auth service, blanked for the app"
|
|
|
|
# --- write/remove behavior ---
|
|
[[ "$(fn-ga-write-conf "$APP")" == "changed" ]] || fail "first write should report changed"
|
|
[[ "$(fn-ga-write-conf "$APP")" == "unchanged" ]] || fail "second write should report unchanged"
|
|
[[ -f "$DOKKU_ROOT/$APP/nginx.conf.d/google-auth.conf" ]] || fail "conf file should exist"
|
|
fn-ga-app-set-enabled "$APP" false
|
|
[[ "$(fn-ga-write-conf "$APP")" == "changed" ]] || fail "disable should remove the conf"
|
|
[[ ! -f "$DOKKU_ROOT/$APP/nginx.conf.d/google-auth.conf" ]] || fail "conf file should be gone"
|
|
fn-ga-app-set-enabled "$APP" true
|
|
fn-ga-write-conf "$APP" >/dev/null
|
|
echo "ok: write/remove behavior"
|
|
|
|
# --- undeployed app is skipped ---
|
|
mkdir -p "$DOKKU_ROOT/fresh-app"
|
|
fn-ga-app-set-enabled "fresh-app" true
|
|
[[ "$(fn-ga-write-conf "fresh-app")" == "skipped" ]] || fail "app without nginx.conf should be skipped"
|
|
echo "ok: undeployed app skipped"
|
|
|
|
# --- validate with real nginx if docker is around ---
|
|
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
|
cat >"$WORK/nginx-test.conf" <<EOF
|
|
events {}
|
|
http {
|
|
upstream myapp-5000 {
|
|
server 127.0.0.1:65000;
|
|
}
|
|
server {
|
|
listen 8080;
|
|
server_name myapp.example.com;
|
|
location / {
|
|
proxy_pass http://myapp-5000;
|
|
}
|
|
include /work/google-auth.conf;
|
|
}
|
|
}
|
|
EOF
|
|
if docker run --rm -v "$WORK:/work:ro" nginx:alpine nginx -t -c /work/nginx-test.conf; then
|
|
echo "ok: real nginx accepted the generated config"
|
|
else
|
|
fail "nginx -t rejected the generated config"
|
|
fi
|
|
|
|
# --- run it for real, and try to spoof the app name ---
|
|
# Per-app access lists are only as trustworthy as X-Google-Auth-App, so prove
|
|
# with a live nginx that a client-supplied value never reaches the auth
|
|
# service. Stand-ins for the two backends run inside the same nginx:
|
|
# :2999 pretends to be the auth service and reports the app name it saw,
|
|
# :8081 pretends to be the app and echoes what it was forwarded.
|
|
cat >"$WORK/nginx-runtime.conf" <<EOF
|
|
events {}
|
|
http {
|
|
access_log off;
|
|
upstream myapp-5000 {
|
|
server 127.0.0.1:8081;
|
|
}
|
|
server {
|
|
listen 8080 default_server;
|
|
include /work/google-auth.conf;
|
|
}
|
|
server {
|
|
listen 2999;
|
|
location ${GOOGLE_AUTH_ROUTE_PREFIX}/verify {
|
|
add_header X-Auth-Request-Email "app-seen=\$http_x_google_auth_app" always;
|
|
return 204;
|
|
}
|
|
location / { return 404; }
|
|
}
|
|
server {
|
|
listen 8081;
|
|
location / {
|
|
default_type text/plain;
|
|
return 200 "forwarded-email=\$http_x_forwarded_email\n";
|
|
}
|
|
}
|
|
}
|
|
EOF
|
|
runtime_out="$(docker run --rm -v "$WORK:/work:ro" nginx:alpine sh -c '
|
|
nginx -c /work/nginx-runtime.conf -g "daemon off;" &
|
|
i=0
|
|
while [ $i -lt 40 ]; do
|
|
wget -q -O /dev/null http://127.0.0.1:8081/ 2>/dev/null && break
|
|
i=$((i + 1)); sleep 0.1
|
|
done
|
|
echo "--- plain ---"
|
|
wget -q -O - http://127.0.0.1:8080/some/page 2>&1
|
|
echo "--- spoofed ---"
|
|
wget -q -O - --header "X-Google-Auth-App: spoofed-app" http://127.0.0.1:8080/some/page 2>&1
|
|
' 2>/dev/null)" || fail "could not run the live nginx spoofing check: $runtime_out"
|
|
|
|
if [[ "$(grep -c 'forwarded-email=app-seen=myapp' <<<"$runtime_out")" -ne 2 ]]; then
|
|
fail "nginx should have told the auth service the app name on both requests, got: $runtime_out"
|
|
fi
|
|
if grep -q "spoofed-app" <<<"$runtime_out"; then
|
|
fail "a client-supplied X-Google-Auth-App reached the auth service: $runtime_out"
|
|
fi
|
|
echo "ok: live nginx sends the real app name and discards a spoofed one"
|
|
else
|
|
echo "skip: docker unavailable, skipped real nginx validation"
|
|
fi
|
|
|
|
echo "ALL NGINX CONF TESTS PASSED"
|