diff --git a/services/gitea/gitea.nix b/services/gitea/gitea.nix index c82fe19..1e497b2 100644 --- a/services/gitea/gitea.nix +++ b/services/gitea/gitea.nix @@ -49,6 +49,32 @@ }; }; + # 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. + services.caddy.virtualHosts.${service_configs.gitea.domain}.extraConfig = '' + @repoActionsNotBadge { + path_regexp ^/[^/]+/[^/]+/actions(/.*)?$ + not path_regexp ^/[^/]+/[^/]+/actions/workflows/[^/]+/badge\.svg$ + } + handle @repoActionsNotBadge { + forward_auth :${toString service_configs.ports.private.gitea.port} { + uri /api/v1/user + + @unauthorized status 401 + handle_response @unauthorized { + redir * /user/login?redirect_to={uri} 302 + } + } + } + ''; + services.postgresql = { ensureDatabases = [ config.services.gitea.user ]; ensureUsers = [ diff --git a/tests/gitea-hide-actions.nix b/tests/gitea-hide-actions.nix new file mode 100644 index 0000000..6afc9b6 --- /dev/null +++ b/tests/gitea-hide-actions.nix @@ -0,0 +1,181 @@ +{ + config, + lib, + pkgs, + ... +}: +let + baseSiteConfig = import ../site-config.nix; + baseServiceConfigs = import ../hosts/muffin/service-configs.nix { + site_config = baseSiteConfig; + }; + testServiceConfigs = lib.recursiveUpdate baseServiceConfigs { + zpool_ssds = ""; + gitea = { + dir = "/var/lib/gitea"; + # `:80` makes Caddy bind all hosts on HTTP port 80 with no Host-header + # matching — simplest path to a reachable vhost inside the test VM + # where there is no ACME / DNS and no TLS terminator. + domain = ":80"; + }; + ports.private.gitea = { + port = 3000; + proto = "tcp"; + }; + }; + + testLib = lib.extend ( + final: prev: { + serviceMountWithZpool = + serviceName: zpool: dirs: + { ... }: + { }; + serviceFilePerms = serviceName: tmpfilesRules: { ... }: { }; + } + ); + + giteaModule = + { config, pkgs, ... }: + { + imports = [ + (import ../services/gitea/gitea.nix { + inherit config pkgs; + lib = testLib; + service_configs = testServiceConfigs; + }) + ]; + }; +in +pkgs.testers.runNixOSTest { + name = "gitea-hide-actions"; + + nodes = { + server = + { + config, + lib, + pkgs, + ... + }: + { + imports = [ + ../modules/server-security.nix + giteaModule + ]; + + # The shared gitea.nix module derives DOMAIN/ROOT_URL from the + # `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.caddy = { + enable = true; + # No DNS / ACME in the VM test network — serve plain HTTP. + globalConfig = '' + auto_https off + ''; + }; + + services.postgresql.enable = true; + + # Stub out zfs/mount ordering added by the real serviceMountWithZpool. + systemd.services."gitea-mounts".enable = lib.mkForce false; + systemd.services.gitea = { + wants = lib.mkForce [ ]; + after = lib.mkForce [ "postgresql.service" ]; + requires = lib.mkForce [ ]; + }; + + networking.firewall.allowedTCPPorts = [ + 80 + 3000 + ]; + }; + + client = + { pkgs, ... }: + { + environment.systemPackages = [ pkgs.curl ]; + }; + }; + + testScript = '' + start_all() + server.wait_for_unit("postgresql.service") + server.wait_for_unit("gitea.service") + server.wait_for_unit("caddy.service") + 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 " + "--email test@test.local --must-change-password=false " + "--work-path /var/lib/gitea'" + ) + + def curl(args): + cmd = ( + "curl -4 -s -o /dev/null " + "-w '%{http_code}|%{redirect_url}' " + args + ) + return client.succeed(cmd).strip() + + with subtest("Anonymous /{user}/{repo}/actions redirects to login"): + result = curl("http://server/foo/bar/actions") + code, _, redir = result.partition("|") + print(f"anon /foo/bar/actions -> {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 "redirect_to=" in redir, f"expected redirect_to param, 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"]: + result = curl(f"http://server{path}") + code, _, redir = result.partition("|") + print(f"anon {path} -> {result!r}") + assert code == "302", f"{path}: expected 302, got {code!r}" + 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}") + assert code != "302" or "/user/login" not in redir, ( + f"badge path should not redirect to login, got {result!r}" + ) + + with subtest("Authenticated /{user}/{repo}/actions does not redirect to login"): + result = curl( + "-u testuser:testpassword " + "http://server/testuser/nonexistent/actions" + ) + 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. + assert not (code == "302" and "/user/login" in redir), ( + f"authenticated actions request was intercepted by login gate: {result!r}" + ) + + with subtest("Anonymous /explore/repos is served without gating"): + result = curl("http://server/explore/repos") + code, _, _ = result.partition("|") + print(f"anon /explore/repos -> {result!r}") + assert code == "200", f"expected 200 for public explore page, got {result!r}" + + with subtest("Anonymous /{user}/{repo} (non-actions) is not login-gated"): + result = curl("http://server/foo/bar") + code, _, redir = result.partition("|") + print(f"anon /foo/bar -> {result!r}") + assert not (code == "302" and "/user/login" in redir), ( + f"non-actions repo path should not redirect to login: {result!r}" + ) + ''; +} diff --git a/tests/tests.nix b/tests/tests.nix index 5d77b31..3c2dc20 100644 --- a/tests/tests.nix +++ b/tests/tests.nix @@ -40,4 +40,7 @@ in # gitea runner test giteaRunnerTest = handleTest ./gitea-runner.nix; + + # gitea actions visibility gate test + giteaHideActionsTest = handleTest ./gitea-hide-actions.nix; }