feat: harden OAuth state secret validation, DCR file permissions, and policy defaults
docker / test (pull_request) Successful in 24s
lint / lint (pull_request) Successful in 37s
lint / lint (push) Successful in 1m26s
test / test (push) Successful in 1m40s
test / test (pull_request) Successful in 34s
docker / lint (pull_request) Successful in 1m59s
docker / docker-test (pull_request) Successful in 14s
docker / docker-publish (pull_request) Has been skipped

- Enforce 32-char minimum on OAUTH_STATE_SECRET at startup (config.py)
- Write DCR client registry with owner-only (0o600) permissions before atomic replace
- Flip policy.yaml default write action from allow → deny
- Add CLAUDE.md with architecture, commands, and AGENTS.md contract summary
- Add .pre-commit-config.yaml mirroring `make lint` checks
- Update .gitignore: add .venv, .claude, .mypy_cache, .ruff_cache, .coverage.*
- Extend docs: audit log rotation guidance, OAUTH_STATE_SECRET and DCR_STORAGE_PATH notes
- Tests: short-secret rejection, 32-char acceptance, POSIX permission check for DCR store

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 14:13:22 +02:00
parent b275f5c0c2
commit b8217dce8a
11 changed files with 269 additions and 4 deletions
+6
View File
@@ -323,6 +323,12 @@ class Settings(BaseSettings):
"OAUTH_STATE_SECRET is required when OAUTH_MODE=true so the OAuth "
"proxy state parameter can be HMAC-signed and verified."
)
# A short secret weakens the HMAC; require the same 32-char floor as API keys.
if len(self.oauth_state_secret.strip()) < 32:
raise ValueError(
"OAUTH_STATE_SECRET must be at least 32 characters long "
"(e.g. `openssl rand -hex 32`)."
)
else:
# Standard API key mode: require bot token and at least one API key.
if not self.gitea_token.strip():
+8
View File
@@ -6,6 +6,7 @@ import base64
import hashlib
import hmac
import json
import os
import secrets
import time
from fnmatch import fnmatchcase
@@ -282,6 +283,13 @@ class OAuthClientRegistry:
}
tmp_path = self.storage_path.with_suffix(self.storage_path.suffix + ".tmp")
tmp_path.write_text(json.dumps(payload, sort_keys=True, indent=2), encoding="utf-8")
# Registration records hold client-secret hashes and metadata; restrict to the
# owning user before the atomic replace so the file is never briefly world-readable.
# chmod is a no-op on platforms without POSIX permission bits (e.g. Windows).
try:
os.chmod(tmp_path, 0o600)
except (OSError, NotImplementedError):
pass
tmp_path.replace(self.storage_path)
def get(self, client_id: str) -> OAuthClientRecord | None: