gitea: hide actions when not logged in
This commit is contained in:
@@ -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 = [
|
||||
|
||||
181
tests/gitea-hide-actions.nix
Normal file
181
tests/gitea-hide-actions.nix
Normal 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}"
|
||||
)
|
||||
'';
|
||||
}
|
||||
@@ -40,4 +40,7 @@ in
|
||||
|
||||
# gitea runner test
|
||||
giteaRunnerTest = handleTest ./gitea-runner.nix;
|
||||
|
||||
# gitea actions visibility gate test
|
||||
giteaHideActionsTest = handleTest ./gitea-hide-actions.nix;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user