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

View File

@@ -67,10 +67,15 @@ pkgs.testers.runNixOSTest {
# `service_configs.gitea.domain` string, which here is the full URL # `service_configs.gitea.domain` string, which here is the full URL
# `http://server`. Override to valid bare values so Gitea doesn't # `http://server`. Override to valid bare values so Gitea doesn't
# get a malformed ROOT_URL like `https://http://server`. # get a malformed ROOT_URL like `https://http://server`.
services.gitea.settings.server = { services.gitea.settings = {
server = {
DOMAIN = lib.mkForce "server"; DOMAIN = lib.mkForce "server";
ROOT_URL = lib.mkForce "http://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 = { services.caddy = {
enable = true; enable = true;
# No DNS / ACME in the VM test network — serve plain HTTP. # No DNS / ACME in the VM test network — serve plain HTTP.
@@ -103,6 +108,8 @@ pkgs.testers.runNixOSTest {
}; };
testScript = '' testScript = ''
import re
start_all() start_all()
server.wait_for_unit("postgresql.service") server.wait_for_unit("postgresql.service")
server.wait_for_unit("gitea.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(3000)
server.wait_for_open_port(80) server.wait_for_open_port(80)
# Admin user used to exercise the authenticated path via Basic Auth.
server.succeed( server.succeed(
"su -l gitea -s /bin/sh -c '${pkgs.gitea}/bin/gitea admin user create " "su -l gitea -s /bin/sh -c '${pkgs.gitea}/bin/gitea admin user create "
"--username testuser --password testpassword " "--username testuser --password testpassword "
@@ -118,13 +124,44 @@ pkgs.testers.runNixOSTest {
"--work-path /var/lib/gitea'" "--work-path /var/lib/gitea'"
) )
def curl(args): def curl(args, cookies=None):
cookie_args = f"-b {cookies} " if cookies else ""
cmd = ( cmd = (
"curl -4 -s -o /dev/null " "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() 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"): with subtest("Anonymous /{user}/{repo}/actions redirects to login"):
result = curl("http://server/foo/bar/actions") result = curl("http://server/foo/bar/actions")
code, _, redir = result.partition("|") code, _, redir = result.partition("|")
@@ -132,6 +169,9 @@ pkgs.testers.runNixOSTest {
assert code == "302", f"expected 302, got {code!r} (full: {result!r})" 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 "/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 "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"): 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"]: 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}" assert "/user/login" in redir, f"{path}: expected login redirect, got {redir!r}"
with subtest("Anonymous workflow badge stays public"): 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") result = curl("http://server/foo/bar/actions/workflows/ci.yaml/badge.svg")
code, _, redir = result.partition("|") code, _, redir = result.partition("|")
print(f"anon badge -> {result!r}") print(f"anon badge -> {result!r}")
@@ -151,17 +189,18 @@ pkgs.testers.runNixOSTest {
f"badge path should not redirect to login, got {result!r}" 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( result = curl(
"-u testuser:testpassword " "http://server/testuser/nonexistent/actions", cookies=cookies
"http://server/testuser/nonexistent/actions"
) )
code, _, redir = result.partition("|") code, _, redir = result.partition("|")
print(f"auth /testuser/nonexistent/actions -> {result!r}") print(f"auth /testuser/nonexistent/actions -> {result!r}")
# Gitea will 404 the missing repo the key assertion is that the # Gitea returns 404 for the missing repo the key assertion is that
# Caddy gate let the request through instead of redirecting to login. # Caddy's gate forwarded the request instead of redirecting to login.
assert not (code == "302" and "/user/login" in redir), ( 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"): with subtest("Anonymous /explore/repos is served without gating"):