gitea: fix actions visibility
All checks were successful
Build and Deploy / mreow (push) Successful in 2m39s
Build and Deploy / yarn (push) Successful in 1m48s
Build and Deploy / muffin (push) Successful in 1m14s

This commit is contained in:
2026-04-22 23:02:53 -04:00
parent 0901f5edf0
commit 0a8b863e4b
2 changed files with 63 additions and 24 deletions

View File

@@ -50,14 +50,14 @@
};
# Hide repo Actions/workflow details from anonymous visitors. Gitea's own
# REQUIRE_SIGNIN_VIEW=expensive mode does not cover /{user}/{repo}/actions,
# so we gate the path at Caddy: forward_auth probes Gitea's /api/v1/user
# with the incoming request's Cookie/Authorization headers. A logged-in
# session answers 200 and the original request falls through to the
# reverse_proxy from mkCaddyReverseProxy; a 401 is turned into a redirect
# to the login page so the browser shows the login form instead of the
# workflow list. Workflow status badges stay public so README links keep
# rendering.
# REQUIRE_SIGNIN_VIEW=expensive does not cover /{user}/{repo}/actions, and
# the API auth chain (routers/api/v1/api.go buildAuthGroup) deliberately
# omits `auth_service.Session`, so an /api/v1/user probe would 401 even
# for logged-in browser sessions. We gate at Caddy instead: forward_auth
# probes a lightweight *web-UI* endpoint that does accept session cookies,
# and Gitea's own reqSignIn middleware answers 303 to /user/login for
# anonymous callers which we rewrite to preserve the original URL.
# Workflow status badges stay public so README links keep rendering.
services.caddy.virtualHosts.${service_configs.gitea.domain}.extraConfig = ''
@repoActionsNotBadge {
path_regexp ^/[^/]+/[^/]+/actions(/.*)?$
@@ -65,9 +65,9 @@
}
handle @repoActionsNotBadge {
forward_auth :${toString service_configs.ports.private.gitea.port} {
uri /api/v1/user
uri /user/stopwatches
@unauthorized status 401
@unauthorized status 302 303
handle_response @unauthorized {
redir * /user/login?redirect_to={uri} 302
}

View File

@@ -67,9 +67,14 @@ pkgs.testers.runNixOSTest {
# `service_configs.gitea.domain` string, which here is the full URL
# `http://server`. Override to valid bare values so Gitea doesn't
# get a malformed ROOT_URL like `https://http://server`.
services.gitea.settings.server = {
DOMAIN = lib.mkForce "server";
ROOT_URL = lib.mkForce "http://server/";
services.gitea.settings = {
server = {
DOMAIN = lib.mkForce "server";
ROOT_URL = lib.mkForce "http://server/";
};
# Tests talk HTTP, so drop the Secure flag — otherwise curl's cookie
# jar holds the session cookie but never sends it back.
session.COOKIE_SECURE = lib.mkForce false;
};
services.caddy = {
enable = true;
@@ -103,6 +108,8 @@ pkgs.testers.runNixOSTest {
};
testScript = ''
import re
start_all()
server.wait_for_unit("postgresql.service")
server.wait_for_unit("gitea.service")
@@ -110,7 +117,6 @@ pkgs.testers.runNixOSTest {
server.wait_for_open_port(3000)
server.wait_for_open_port(80)
# Admin user used to exercise the authenticated path via Basic Auth.
server.succeed(
"su -l gitea -s /bin/sh -c '${pkgs.gitea}/bin/gitea admin user create "
"--username testuser --password testpassword "
@@ -118,13 +124,44 @@ pkgs.testers.runNixOSTest {
"--work-path /var/lib/gitea'"
)
def curl(args):
def curl(args, cookies=None):
cookie_args = f"-b {cookies} " if cookies else ""
cmd = (
"curl -4 -s -o /dev/null "
"-w '%{http_code}|%{redirect_url}' " + args
f"-w '%{{http_code}}|%{{redirect_url}}' {cookie_args}{args}"
)
return client.succeed(cmd).strip()
def login():
# Gitea's POST /user/login requires a _csrf token and expects the
# matching session cookie already set. Fetch the login form first
# to harvest both, then submit credentials with the same cookie jar.
client.succeed("rm -f /tmp/cookies.txt")
html = client.succeed(
"curl -4 -s -c /tmp/cookies.txt http://server/user/login"
)
match = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
assert match, f"CSRF token not found in login form: {html[:500]!r}"
csrf = match.group(1)
# -L so we follow the post-login redirect; the session cookie is
# rewritten by Gitea on successful login to carry uid.
client.succeed(
"curl -4 -s -L -o /dev/null "
"-b /tmp/cookies.txt -c /tmp/cookies.txt "
f"--data-urlencode '_csrf={csrf}' "
"--data-urlencode 'user_name=testuser' "
"--data-urlencode 'password=testpassword' "
"http://server/user/login"
)
# Sanity-check the session by hitting the gated probe directly
# the post-login cookie jar MUST drive /user/stopwatches to 200.
probe = client.succeed(
"curl -4 -s -o /dev/null -w '%{http_code}' "
"-b /tmp/cookies.txt http://server/user/stopwatches"
).strip()
assert probe == "200", f"session auth probe expected 200, got {probe!r}"
return "/tmp/cookies.txt"
with subtest("Anonymous /{user}/{repo}/actions redirects to login"):
result = curl("http://server/foo/bar/actions")
code, _, redir = result.partition("|")
@@ -132,6 +169,9 @@ pkgs.testers.runNixOSTest {
assert code == "302", f"expected 302, got {code!r} (full: {result!r})"
assert "/user/login" in redir, f"expected login redirect, got {redir!r}"
assert "redirect_to=" in redir, f"expected redirect_to param, got {redir!r}"
assert "/foo/bar/actions" in redir, (
f"expected original URL preserved in redirect_to, got {redir!r}"
)
with subtest("Anonymous deep /actions paths also redirect"):
for path in ["/foo/bar/actions/", "/foo/bar/actions/runs/1", "/foo/bar/actions/workflows/build.yaml"]:
@@ -142,8 +182,6 @@ pkgs.testers.runNixOSTest {
assert "/user/login" in redir, f"{path}: expected login redirect, got {redir!r}"
with subtest("Anonymous workflow badge stays public"):
# Repo doesn't exist Gitea answers 404, but Caddy does NOT intercept
# with a login redirect so README badges keep rendering.
result = curl("http://server/foo/bar/actions/workflows/ci.yaml/badge.svg")
code, _, redir = result.partition("|")
print(f"anon badge -> {result!r}")
@@ -151,17 +189,18 @@ pkgs.testers.runNixOSTest {
f"badge path should not redirect to login, got {result!r}"
)
with subtest("Authenticated /{user}/{repo}/actions does not redirect to login"):
cookies = login()
with subtest("Session-authenticated /{user}/{repo}/actions reaches Gitea"):
result = curl(
"-u testuser:testpassword "
"http://server/testuser/nonexistent/actions"
"http://server/testuser/nonexistent/actions", cookies=cookies
)
code, _, redir = result.partition("|")
print(f"auth /testuser/nonexistent/actions -> {result!r}")
# Gitea will 404 the missing repo the key assertion is that the
# Caddy gate let the request through instead of redirecting to login.
# Gitea returns 404 for the missing repo the key assertion is that
# Caddy's gate forwarded the request instead of redirecting to login.
assert not (code == "302" and "/user/login" in redir), (
f"authenticated actions request was intercepted by login gate: {result!r}"
f"session-authed actions request was intercepted by login gate: {result!r}"
)
with subtest("Anonymous /explore/repos is served without gating"):