diff --git a/services/gitea/gitea.nix b/services/gitea/gitea.nix index 1e497b2..ec1e204 100644 --- a/services/gitea/gitea.nix +++ b/services/gitea/gitea.nix @@ -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 } diff --git a/tests/gitea-hide-actions.nix b/tests/gitea-hide-actions.nix index 6afc9b6..5f07a7d 100644 --- a/tests/gitea-hide-actions.nix +++ b/tests/gitea-hide-actions.nix @@ -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"):