gitea: hide actions when not logged in
All checks were successful
Build and Deploy / mreow (push) Successful in 56s
Build and Deploy / yarn (push) Successful in 52s
Build and Deploy / muffin (push) Successful in 1m1s

This commit is contained in:
2026-04-22 21:23:47 -04:00
parent d00ff42e8e
commit fdd5c5fba0
3 changed files with 210 additions and 0 deletions

View File

@@ -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 = [

View File

@@ -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}"
)
'';
}

View File

@@ -40,4 +40,7 @@ in
# gitea runner test
giteaRunnerTest = handleTest ./gitea-runner.nix;
# gitea actions visibility gate test
giteaHideActionsTest = handleTest ./gitea-hide-actions.nix;
}