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
+55
View File
@@ -3,6 +3,7 @@ config handling, device-flow polling (mocked HTTP). Stdlib unittest only."""
import json
import os
import shlex
import shutil
import subprocess
import sys
@@ -124,11 +125,65 @@ class TestConfig(unittest.TestCase):
self.assertEqual(st.st_mode & 0o777, 0o600)
self.assertEqual(client.load_config()["login"], "x")
def test_save_is_0600_even_with_permissive_umask(self):
"""Finding 4: the token file must be born 0600 (O_CREAT mode), not
chmod'ed after write -- a wide-open umask must not widen it."""
old = os.umask(0o000)
try:
client.save_config({"token": "sekrit", "folders": {}})
finally:
os.umask(old)
st = os.stat(client.CONFIG_PATH)
self.assertEqual(st.st_mode & 0o777, 0o600)
def test_save_never_calls_chmod(self):
"""The 0600 mode must come from creation, not a later chmod (which
would leave a window where the file is world-readable)."""
with mock.patch.object(client.os, "chmod",
side_effect=AssertionError(
"chmod used; file must be created 0600")):
client.save_config({"token": "sekrit", "folders": {}})
st = os.stat(client.CONFIG_PATH)
self.assertEqual(st.st_mode & 0o777, 0o600)
def test_load_missing_returns_empty(self):
with mock.patch.object(client, "CONFIG_PATH", "/nonexistent/nope.json"):
self.assertEqual(client.load_config(), {})
class TestCredentialHelperQuoting(unittest.TestCase):
"""Finding 5: helper command paths must be shlex-quoted."""
def test_paths_with_spaces_are_quoted(self):
with mock.patch.object(client.sys, "executable",
"/opt/py dir/bin/python3"), \
mock.patch.object(client, "__file__",
"/home/a user/granthi sync/client.py"):
val = client.credential_helper_value()
self.assertTrue(val.startswith("!"))
self.assertIn("'/opt/py dir/bin/python3'", val)
self.assertIn("'/home/a user/granthi sync/client.py'", val)
# shell round-trip yields exactly [python, script, subcommand]
parts = shlex.split(val[1:])
self.assertEqual(parts, ["/opt/py dir/bin/python3",
"/home/a user/granthi sync/client.py",
"git-credential"])
def test_metacharacters_do_not_inject(self):
evil = "/tmp/x; rm -rf ~; echo/client.py"
with mock.patch.object(client, "__file__", evil):
val = client.credential_helper_value()
parts = shlex.split(val[1:])
self.assertEqual(parts[1], os.path.abspath(evil))
self.assertEqual(len(parts), 3)
def test_plain_paths_still_work(self):
val = client.credential_helper_value()
parts = shlex.split(val[1:])
self.assertEqual(parts[0], sys.executable)
self.assertEqual(parts[2], "git-credential")
class TestDeviceFlow(unittest.TestCase):
def test_device_flow_polls_until_token(self):
calls = []