security: harden granthi-link + client against 7 codex findings

1. CRITICAL account-takeover by login collision: persist zitadel_sub ->
   gitea_login identity map (state.json, 0600, atomic); mapping wins,
   deleted logins re-created only if service-created, existing unmapped
   logins bind only on verified email match, else 409; token never
   minted before binding passes
2. test_mode now gated behind GRANTHI_LINK_ALLOW_TEST_MODE=1 env
3. refuse startup unless config.json is 0600/0400 and owned by service
4. client config created O_CREAT 0600 (no write-then-chmod window)
5. credential-helper command paths shlex-quoted
6. POST bodies capped at 64KB (413); missing/invalid Content-Length rejected
7. Gitea 409 on user create handled idempotently (re-fetch + verify email)

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nirav Patel
2026-08-19 09:17:26 -04:00
co-authored by Claude Fable 5
parent 1c8fcb23d8
commit c674db4746
8 changed files with 610 additions and 55 deletions
Binary file not shown.
+242 -24
View File
@@ -5,7 +5,7 @@ Bridges shre-id (Zitadel) identity to a Granthi (Gitea) forge:
POST /v1/link {zitadel_access_token, device_name}
-> validates the token against Zitadel userinfo
-> ensures a Gitea user exists (admin API)
-> applies the identity-binding rules (see below)
-> mints a scoped Gitea token for that user (admin basic
auth + `Sudo:` header -- the only mechanism that works
on Gitea 1.27; token-authenticated sudo returns 401)
@@ -29,11 +29,24 @@ Design decisions (documented per spec):
* Token minting NEEDS the admin password (basic auth + Sudo header).
The admin API token alone cannot mint user tokens on 1.27. The config
therefore carries admin_login/admin_password alongside admin_token;
config must be root-owned 0600.
* test_mode: when config "test_mode" is true, a /v1/link body may carry
config must be 0600/0400 and owned by the service user or startup is
REFUSED (fail closed).
* test_mode: when config "test_mode" is true AND the service environment
also sets GRANTHI_LINK_ALLOW_TEST_MODE=1, a /v1/link body may carry
"test_userinfo" (dict) instead of a Zitadel round-trip. This exists so
E2E can exercise the ensure-user+mint path without a human OAuth login.
NEVER enable in production config.
Config alone is NOT enough: without the env gate the flag is logged
loudly and ignored. NEVER enable in production.
* Identity binding: /v1/link persists a server-side map of Zitadel `sub`
-> Gitea login in state.json (0600, atomic writes). Rules:
(a) mapped sub -> always use the mapped login; if that login was
deleted it is re-created only when it was service-created,
otherwise the link is refused;
(b) unmapped sub + login free -> create user, record mapping;
(c) unmapped sub + login taken -> bind ONLY when the Gitea user's
primary email equals the Zitadel userinfo email AND
email_verified is true; otherwise 409.
A token is never minted before the binding rule passes.
Stdlib only. Python 3.9+.
"""
@@ -53,10 +66,16 @@ import urllib.request
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
VERSION = "1.0.0"
VERSION = "1.1.0"
LOG = logging.getLogger("granthi-link")
DEFAULT_CONFIG = "/opt/granthi-link/config.json"
DEFAULT_STATE = "/opt/granthi-link/state.json"
MAX_BODY_BYTES = 64 * 1024
TEST_MODE_ENV = "GRANTHI_LINK_ALLOW_TEST_MODE"
# Sentinel: create_user hit a 409 (someone else created the login first).
USER_CREATE_CONFLICT = object()
LOGIN_SAFE = re.compile(r"[^a-zA-Z0-9._-]+")
@@ -95,6 +114,80 @@ def _basic(login, password):
return f"Basic {tok}"
# --------------------------------------------------------------------------
# Startup hardening
# --------------------------------------------------------------------------
def check_config_perms(path, euid=None):
"""Fail-closed config check. Returns an error string or None.
The config carries the Gitea admin password; it must be 0600/0400 and
owned by the user the service runs as, or startup is refused.
"""
st = os.stat(path)
mode = st.st_mode & 0o777
if mode not in (0o600, 0o400):
return (f"config {path} has mode {oct(mode)}; refusing to start "
f"(must be 0600 or 0400)")
euid = os.geteuid() if euid is None else euid
if st.st_uid != euid:
return (f"config {path} is owned by uid {st.st_uid} but the service "
f"runs as uid {euid}; refusing to start")
return None
# --------------------------------------------------------------------------
# Identity map: zitadel sub -> gitea login (JSON, 0600, atomic writes)
# --------------------------------------------------------------------------
class IdentityStore:
"""Persistent map of Zitadel `sub` -> Gitea login binding records.
Record shape: {"login": str, "created_by_service": bool,
"email": str, "linked_at": iso8601}
"""
def __init__(self, path):
self.path = path
self.lock = threading.Lock()
def _load(self):
try:
with open(self.path) as f:
data = json.load(f)
except FileNotFoundError:
return {"identities": {}}
except (ValueError, OSError) as e:
# Corrupt/unreadable state must NOT silently fall back to an
# empty map -- that would re-open the takeover window.
raise RuntimeError(f"identity state {self.path} unreadable: {e}")
if not isinstance(data, dict) or not isinstance(
data.get("identities"), dict):
raise RuntimeError(f"identity state {self.path} malformed")
return data
def _write(self, data):
tmp = f"{self.path}.tmp.{os.getpid()}"
fd = os.open(tmp, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, self.path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def get(self, sub):
return self._load()["identities"].get(str(sub))
def set(self, sub, record):
data = self._load()
data["identities"][str(sub)] = record
self._write(data)
# --------------------------------------------------------------------------
# Core logic (class so tests can instantiate with a stub config)
# --------------------------------------------------------------------------
@@ -108,6 +201,20 @@ class LinkService:
self.public_gitea = config.get("public_gitea_base", self.gitea).rstrip("/")
self.userinfo_url = config.get(
"zitadel_userinfo", "https://id.shre.ai/oidc/v1/userinfo")
self.state = IdentityStore(config.get("state_path", DEFAULT_STATE))
def test_mode_enabled(self):
"""Config test_mode is honored ONLY with the env gate also set."""
if not self.cfg.get("test_mode"):
return False
if os.environ.get(TEST_MODE_ENV) != "1":
LOG.error(
"config sets test_mode=true but %s=1 is NOT set in the "
"service environment -- IGNORING test_mode (treating as "
"false). Remove test_mode from production config.",
TEST_MODE_ENV)
return False
return True
# -- identity ----------------------------------------------------------
def validate_token(self, access_token):
@@ -134,18 +241,30 @@ class LinkService:
def _admin_hdr(self):
return {"Authorization": f"token {self.cfg['admin_token']}"}
def user_exists(self, login):
status, _ = http_json(
def get_user(self, login):
"""Admin-view of a Gitea user (includes primary email) or None."""
status, resp = http_json(
"GET", f"{self.gitea}/api/v1/users/{login}", headers=self._admin_hdr())
return status == 200
if status == 200 and isinstance(resp, dict):
return resp
return None
def user_exists(self, login):
return self.get_user(login) is not None
@staticmethod
def intended_email(login, userinfo):
return userinfo.get("email") or f"{login}@users.noreply.granthi.shre.ai"
def create_user(self, login, userinfo):
"""Create the Gitea user. Returns None on success, the sentinel
USER_CREATE_CONFLICT on HTTP 409 (concurrent create -- caller
re-fetches and continues idempotently), or an error string."""
pw_alphabet = string.ascii_letters + string.digits
password = "".join(secrets.choice(pw_alphabet) for _ in range(30))
email = userinfo.get("email") or f"{login}@users.noreply.granthi.shre.ai"
body = {
"username": login,
"email": email,
"email": self.intended_email(login, userinfo),
"password": password,
"must_change_password": False,
"visibility": "private",
@@ -155,6 +274,8 @@ class LinkService:
status, resp = http_json(
"POST", f"{self.gitea}/api/v1/admin/users",
headers=self._admin_hdr(), body=body)
if status == 409:
return USER_CREATE_CONFLICT
if status != 201:
return f"gitea admin user create failed (HTTP {status}): {resp}"
return None
@@ -178,10 +299,85 @@ class LinkService:
return None, None, f"token mint failed (HTTP {status}): {resp}"
return resp.get("sha1"), token_name, None
# -- identity binding (finding 1: no minting before binding passes) ----
def _record_binding(self, sub, login, userinfo, created_by_service):
self.state.set(sub, {
"login": login,
"created_by_service": bool(created_by_service),
"email": userinfo.get("email") or "",
"linked_at": datetime.now(timezone.utc).isoformat(
timespec="seconds"),
})
def _bind_identity(self, sub, userinfo):
"""Apply the binding rules. Returns (status, error_resp, login).
login is None unless binding passed. Caller holds self.state.lock.
"""
rec = self.state.get(sub)
if rec: # rule (a): mapping wins, regardless of current userinfo
login = rec["login"]
if not self.user_exists(login):
if not rec.get("created_by_service"):
return 409, {"error":
f"mapped login {login} no longer exists and "
"was not created by this service; refusing "
"to re-create"}, None
err = self.create_user(login, userinfo)
if err and err is not USER_CREATE_CONFLICT:
return 502, {"error": err}, None
LOG.info("re-created service-managed gitea user %s", login)
return 200, None, login
login = self.derive_login(userinfo)
if not login:
return 422, {"error": "could not derive a login from userinfo"}, None
if not self.user_exists(login): # rule (b): fresh login
err = self.create_user(login, userinfo)
if err is USER_CREATE_CONFLICT:
# finding 7: concurrent first-link race. Re-fetch and continue
# idempotently -- but only if the user that won the race
# carries the email WE would have set; anything else is a
# foreign identity and must be refused.
user = self.get_user(login)
if not user:
return 502, {"error": "user create conflicted but user "
"not fetchable"}, None
want = self.intended_email(login, userinfo).lower()
if (user.get("email") or "").lower() != want:
return 409, {"error": "login exists and is not linked "
"to this identity"}, None
LOG.info("user %s created concurrently; continuing", login)
elif err:
return 502, {"error": err}, None
else:
LOG.info("created gitea user %s", login)
self._record_binding(sub, login, userinfo, created_by_service=True)
return 200, None, login
# rule (c): login taken by an unmapped Gitea user -- bind only on
# verified email match.
user = self.get_user(login)
if not user:
return 502, {"error": "gitea user lookup failed"}, None
zemail = (userinfo.get("email") or "").lower()
gemail = (user.get("email") or "").lower()
if zemail and userinfo.get("email_verified") is True and zemail == gemail:
self._record_binding(sub, login, userinfo, created_by_service=False)
LOG.info("bound existing gitea user %s to sub %s via verified "
"email match", login, sub)
return 200, None, login
LOG.warning("refused link: login %s exists, sub %s not mapped, "
"email match=%s verified=%s", login, sub,
zemail == gemail, userinfo.get("email_verified"))
return 409, {"error": "login exists and is not linked to this "
"identity"}, None
# -- endpoints ---------------------------------------------------------
def link(self, body):
device_name = body.get("device_name") or "device"
if self.cfg.get("test_mode") and isinstance(body.get("test_userinfo"), dict):
if self.test_mode_enabled() and isinstance(body.get("test_userinfo"), dict):
LOG.warning("TEST-MODE link request (stubbed userinfo)")
userinfo = body["test_userinfo"]
else:
@@ -191,18 +387,21 @@ class LinkService:
userinfo, err = self.validate_token(token)
if err:
return 401, {"error": err}
login = self.derive_login(userinfo)
if not login:
return 422, {"error": "could not derive a login from userinfo"}
if not self.user_exists(login):
err = self.create_user(login, userinfo)
if err:
return 502, {"error": err}
LOG.info("created gitea user %s", login)
sub = str(userinfo.get("sub") or "").strip()
if not sub:
return 422, {"error": "userinfo has no sub"}
try:
with self.state.lock:
status, err_resp, login = self._bind_identity(sub, userinfo)
except RuntimeError as e: # identity state unreadable: fail closed
LOG.error("%s", e)
return 500, {"error": "identity state unavailable"}
if login is None:
return status, err_resp
gitea_token, token_name, err = self.mint_token(login, device_name)
if err:
return 502, {"error": err}
LOG.info("minted token %s for %s", token_name, login)
LOG.info("minted token %s for %s (sub %s)", token_name, login, sub)
return 200, {"gitea_base": self.public_gitea, "login": login,
"token": gitea_token, "token_name": token_name}
@@ -253,11 +452,29 @@ class Handler(BaseHTTPRequestHandler):
self._send(404, {"error": "not found"})
def do_POST(self):
cl = self.headers.get("Content-Length")
if cl is None:
self.close_connection = True
return self._send(411, {"error": "Content-Length required"})
try:
length = int(cl)
except (ValueError, TypeError):
self.close_connection = True
return self._send(400, {"error": "invalid Content-Length"})
if length < 0:
self.close_connection = True
return self._send(400, {"error": "invalid Content-Length"})
if length > MAX_BODY_BYTES:
# body is not read; close so the peer can't stream it anyway
self.close_connection = True
return self._send(413, {"error": f"request body too large "
f"(max {MAX_BODY_BYTES} bytes)"})
try:
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length) or b"{}")
except (ValueError, TypeError):
return self._send(400, {"error": "invalid JSON body"})
if not isinstance(body, dict):
return self._send(400, {"error": "body must be a JSON object"})
if self.path == "/v1/link":
status, resp = self.service.link(body)
elif self.path == "/v1/repos":
@@ -298,14 +515,15 @@ def main():
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_CONFIG
perm_err = check_config_perms(path)
if perm_err: # fail CLOSED: a warning here would leak the admin password
LOG.error("%s", perm_err)
sys.exit(2)
with open(path) as f:
config = json.load(f)
for key in ("gitea_base", "admin_token", "admin_login", "admin_password"):
if key not in config:
sys.exit(f"config missing required key: {key}")
st = os.stat(path)
if st.st_mode & 0o077:
LOG.warning("config %s is group/world readable -- chmod 600 it", path)
serve(config)