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:
co-authored by
Claude Fable 5
parent
1c8fcb23d8
commit
c674db4746
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user