FossilRepo

Fix OAuth CSRF, git mirror secret exposure, open redirect OAuth: state parameter now includes a cryptographic nonce stored in the session. Callbacks validate nonce before accepting tokens — prevents session poisoning with attacker-controlled OAuth tokens. Git mirror: tokens no longer embedded in autopush URLs (visible in ps and process output). Auth passed via Git credential helper environment variables. Token scrubbed from all output before storing in SyncLog. Login: next parameter validated with url_has_allowed_host_and_scheme() to prevent external redirect after authentication. 53 security regression tests total across both commits.

lmata 2026-04-07 14:48 trunk
Commit 747858abc22a4fbfdcf0f756d6dab4ade6dcc588f4d0d09b4a5e31b6800434c3
+26 -4
--- config/urls.py
+++ config/urls.py
@@ -9,14 +9,25 @@
99
from django.views.generic import RedirectView
1010
1111
1212
def _oauth_github_callback(request):
1313
"""Global GitHub OAuth callback. Extracts slug from state param and delegates."""
14
+ from django.contrib import messages
15
+
1416
state = request.GET.get("state", "")
15
- slug = state.split(":")[0] if ":" in state else ""
16
- if not slug:
17
+ parts = state.split(":")
18
+ if len(parts) < 3:
1719
return _redirect("/dashboard/")
20
+
21
+ slug = parts[0]
22
+ nonce = parts[2]
23
+
24
+ expected_nonce = request.session.pop("oauth_state_nonce", "")
25
+ if not nonce or nonce != expected_nonce:
26
+ messages.error(request, "OAuth state mismatch. Please try again.")
27
+ return _redirect(f"/projects/{slug}/fossil/sync/git/")
28
+
1829
from fossil.oauth import github_exchange_token
1930
2031
result = github_exchange_token(request, slug)
2132
if result.get("token"):
2233
request.session["github_oauth_token"] = result["token"]
@@ -24,14 +35,25 @@
2435
return _redirect(f"/projects/{slug}/fossil/sync/git/")
2536
2637
2738
def _oauth_gitlab_callback(request):
2839
"""Global GitLab OAuth callback. Extracts slug from state param and delegates."""
40
+ from django.contrib import messages
41
+
2942
state = request.GET.get("state", "")
30
- slug = state.split(":")[0] if ":" in state else ""
31
- if not slug:
43
+ parts = state.split(":")
44
+ if len(parts) < 3:
3245
return _redirect("/dashboard/")
46
+
47
+ slug = parts[0]
48
+ nonce = parts[2]
49
+
50
+ expected_nonce = request.session.pop("oauth_state_nonce", "")
51
+ if not nonce or nonce != expected_nonce:
52
+ messages.error(request, "OAuth state mismatch. Please try again.")
53
+ return _redirect(f"/projects/{slug}/fossil/sync/git/")
54
+
3355
from fossil.oauth import gitlab_exchange_token
3456
3557
result = gitlab_exchange_token(request, slug)
3658
if result.get("token"):
3759
request.session["gitlab_oauth_token"] = result["token"]
3860
--- config/urls.py
+++ config/urls.py
@@ -9,14 +9,25 @@
9 from django.views.generic import RedirectView
10
11
12 def _oauth_github_callback(request):
13 """Global GitHub OAuth callback. Extracts slug from state param and delegates."""
 
 
14 state = request.GET.get("state", "")
15 slug = state.split(":")[0] if ":" in state else ""
16 if not slug:
17 return _redirect("/dashboard/")
 
 
 
 
 
 
 
 
 
18 from fossil.oauth import github_exchange_token
19
20 result = github_exchange_token(request, slug)
21 if result.get("token"):
22 request.session["github_oauth_token"] = result["token"]
@@ -24,14 +35,25 @@
24 return _redirect(f"/projects/{slug}/fossil/sync/git/")
25
26
27 def _oauth_gitlab_callback(request):
28 """Global GitLab OAuth callback. Extracts slug from state param and delegates."""
 
 
29 state = request.GET.get("state", "")
30 slug = state.split(":")[0] if ":" in state else ""
31 if not slug:
32 return _redirect("/dashboard/")
 
 
 
 
 
 
 
 
 
33 from fossil.oauth import gitlab_exchange_token
34
35 result = gitlab_exchange_token(request, slug)
36 if result.get("token"):
37 request.session["gitlab_oauth_token"] = result["token"]
38
--- config/urls.py
+++ config/urls.py
@@ -9,14 +9,25 @@
9 from django.views.generic import RedirectView
10
11
12 def _oauth_github_callback(request):
13 """Global GitHub OAuth callback. Extracts slug from state param and delegates."""
14 from django.contrib import messages
15
16 state = request.GET.get("state", "")
17 parts = state.split(":")
18 if len(parts) < 3:
19 return _redirect("/dashboard/")
20
21 slug = parts[0]
22 nonce = parts[2]
23
24 expected_nonce = request.session.pop("oauth_state_nonce", "")
25 if not nonce or nonce != expected_nonce:
26 messages.error(request, "OAuth state mismatch. Please try again.")
27 return _redirect(f"/projects/{slug}/fossil/sync/git/")
28
29 from fossil.oauth import github_exchange_token
30
31 result = github_exchange_token(request, slug)
32 if result.get("token"):
33 request.session["github_oauth_token"] = result["token"]
@@ -24,14 +35,25 @@
35 return _redirect(f"/projects/{slug}/fossil/sync/git/")
36
37
38 def _oauth_gitlab_callback(request):
39 """Global GitLab OAuth callback. Extracts slug from state param and delegates."""
40 from django.contrib import messages
41
42 state = request.GET.get("state", "")
43 parts = state.split(":")
44 if len(parts) < 3:
45 return _redirect("/dashboard/")
46
47 slug = parts[0]
48 nonce = parts[2]
49
50 expected_nonce = request.session.pop("oauth_state_nonce", "")
51 if not nonce or nonce != expected_nonce:
52 messages.error(request, "OAuth state mismatch. Please try again.")
53 return _redirect(f"/projects/{slug}/fossil/sync/git/")
54
55 from fossil.oauth import gitlab_exchange_token
56
57 result = gitlab_exchange_token(request, slug)
58 if result.get("token"):
59 request.session["gitlab_oauth_token"] = result["token"]
60
+19 -3
--- fossil/cli.py
+++ fossil/cli.py
@@ -202,22 +202,38 @@
202202
cmd.append(f"{key}")
203203
cmd.append(f"{value}")
204204
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env)
205205
return result.returncode == 0
206206
207
- def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "") -> dict:
207
+ def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict:
208208
"""Export Fossil repo to a Git mirror directory. Incremental.
209
+
210
+ When auth_token is provided, credentials are passed via Git environment
211
+ variables instead of being embedded in the URL (avoids exposure in
212
+ process args and command output).
209213
210214
Returns {success, message}.
211215
"""
212216
mirror_dir.mkdir(parents=True, exist_ok=True)
213217
cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)]
218
+
219
+ env = dict(self._env)
220
+
214221
if autopush_url:
215222
cmd.extend(["--autopush", autopush_url])
223
+ if auth_token:
224
+ env["GIT_TERMINAL_PROMPT"] = "0"
225
+ env["GIT_CONFIG_COUNT"] = "1"
226
+ env["GIT_CONFIG_KEY_0"] = "credential.helper"
227
+ env["GIT_CONFIG_VALUE_0"] = f"!echo password={auth_token}"
228
+
216229
try:
217
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=self._env)
218
- return {"success": result.returncode == 0, "message": (result.stdout + result.stderr).strip()}
230
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env)
231
+ output = (result.stdout + result.stderr).strip()
232
+ if auth_token:
233
+ output = output.replace(auth_token, "[REDACTED]")
234
+ return {"success": result.returncode == 0, "message": output}
219235
except subprocess.TimeoutExpired:
220236
return {"success": False, "message": "Export timed out after 5 minutes"}
221237
222238
def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict:
223239
"""Generate an SSH key pair for Git authentication.
224240
--- fossil/cli.py
+++ fossil/cli.py
@@ -202,22 +202,38 @@
202 cmd.append(f"{key}")
203 cmd.append(f"{value}")
204 result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env)
205 return result.returncode == 0
206
207 def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "") -> dict:
208 """Export Fossil repo to a Git mirror directory. Incremental.
 
 
 
 
209
210 Returns {success, message}.
211 """
212 mirror_dir.mkdir(parents=True, exist_ok=True)
213 cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)]
 
 
 
214 if autopush_url:
215 cmd.extend(["--autopush", autopush_url])
 
 
 
 
 
 
216 try:
217 result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=self._env)
218 return {"success": result.returncode == 0, "message": (result.stdout + result.stderr).strip()}
 
 
 
219 except subprocess.TimeoutExpired:
220 return {"success": False, "message": "Export timed out after 5 minutes"}
221
222 def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict:
223 """Generate an SSH key pair for Git authentication.
224
--- fossil/cli.py
+++ fossil/cli.py
@@ -202,22 +202,38 @@
202 cmd.append(f"{key}")
203 cmd.append(f"{value}")
204 result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env)
205 return result.returncode == 0
206
207 def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict:
208 """Export Fossil repo to a Git mirror directory. Incremental.
209
210 When auth_token is provided, credentials are passed via Git environment
211 variables instead of being embedded in the URL (avoids exposure in
212 process args and command output).
213
214 Returns {success, message}.
215 """
216 mirror_dir.mkdir(parents=True, exist_ok=True)
217 cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)]
218
219 env = dict(self._env)
220
221 if autopush_url:
222 cmd.extend(["--autopush", autopush_url])
223 if auth_token:
224 env["GIT_TERMINAL_PROMPT"] = "0"
225 env["GIT_CONFIG_COUNT"] = "1"
226 env["GIT_CONFIG_KEY_0"] = "credential.helper"
227 env["GIT_CONFIG_VALUE_0"] = f"!echo password={auth_token}"
228
229 try:
230 result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env)
231 output = (result.stdout + result.stderr).strip()
232 if auth_token:
233 output = output.replace(auth_token, "[REDACTED]")
234 return {"success": result.returncode == 0, "message": output}
235 except subprocess.TimeoutExpired:
236 return {"success": False, "message": "Export timed out after 5 minutes"}
237
238 def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict:
239 """Generate an SSH key pair for Git authentication.
240
+7 -2
--- fossil/oauth.py
+++ fossil/oauth.py
@@ -3,10 +3,11 @@
33
No dependency on django-allauth — just requests + constance config.
44
Stores tokens on GitMirror.auth_credential.
55
"""
66
77
import logging
8
+import secrets
89
910
import requests
1011
1112
logger = logging.getLogger(__name__)
1213
@@ -25,11 +26,13 @@
2526
client_id = config.GITHUB_OAUTH_CLIENT_ID
2627
if not client_id:
2728
return None
2829
2930
callback = request.build_absolute_uri("/oauth/callback/github/")
30
- state = f"{slug}:{mirror_id or 'new'}"
31
+ nonce = secrets.token_urlsafe(32)
32
+ state = f"{slug}:{mirror_id or 'new'}:{nonce}"
33
+ request.session["oauth_state_nonce"] = nonce
3134
3235
return f"{GITHUB_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={callback}&scope=repo&state={state}"
3336
3437
3538
def github_exchange_token(request, slug):
@@ -72,11 +75,13 @@
7275
client_id = config.GITLAB_OAUTH_CLIENT_ID
7376
if not client_id:
7477
return None
7578
7679
callback = request.build_absolute_uri("/oauth/callback/gitlab/")
77
- state = f"{slug}:{mirror_id or 'new'}"
80
+ nonce = secrets.token_urlsafe(32)
81
+ state = f"{slug}:{mirror_id or 'new'}:{nonce}"
82
+ request.session["oauth_state_nonce"] = nonce
7883
7984
return f"{GITLAB_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={callback}&response_type=code&scope=api&state={state}"
8085
8186
8287
def gitlab_exchange_token(request, slug):
8388
--- fossil/oauth.py
+++ fossil/oauth.py
@@ -3,10 +3,11 @@
3 No dependency on django-allauth — just requests + constance config.
4 Stores tokens on GitMirror.auth_credential.
5 """
6
7 import logging
 
8
9 import requests
10
11 logger = logging.getLogger(__name__)
12
@@ -25,11 +26,13 @@
25 client_id = config.GITHUB_OAUTH_CLIENT_ID
26 if not client_id:
27 return None
28
29 callback = request.build_absolute_uri("/oauth/callback/github/")
30 state = f"{slug}:{mirror_id or 'new'}"
 
 
31
32 return f"{GITHUB_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={callback}&scope=repo&state={state}"
33
34
35 def github_exchange_token(request, slug):
@@ -72,11 +75,13 @@
72 client_id = config.GITLAB_OAUTH_CLIENT_ID
73 if not client_id:
74 return None
75
76 callback = request.build_absolute_uri("/oauth/callback/gitlab/")
77 state = f"{slug}:{mirror_id or 'new'}"
 
 
78
79 return f"{GITLAB_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={callback}&response_type=code&scope=api&state={state}"
80
81
82 def gitlab_exchange_token(request, slug):
83
--- fossil/oauth.py
+++ fossil/oauth.py
@@ -3,10 +3,11 @@
3 No dependency on django-allauth — just requests + constance config.
4 Stores tokens on GitMirror.auth_credential.
5 """
6
7 import logging
8 import secrets
9
10 import requests
11
12 logger = logging.getLogger(__name__)
13
@@ -25,11 +26,13 @@
26 client_id = config.GITHUB_OAUTH_CLIENT_ID
27 if not client_id:
28 return None
29
30 callback = request.build_absolute_uri("/oauth/callback/github/")
31 nonce = secrets.token_urlsafe(32)
32 state = f"{slug}:{mirror_id or 'new'}:{nonce}"
33 request.session["oauth_state_nonce"] = nonce
34
35 return f"{GITHUB_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={callback}&scope=repo&state={state}"
36
37
38 def github_exchange_token(request, slug):
@@ -72,11 +75,13 @@
75 client_id = config.GITLAB_OAUTH_CLIENT_ID
76 if not client_id:
77 return None
78
79 callback = request.build_absolute_uri("/oauth/callback/gitlab/")
80 nonce = secrets.token_urlsafe(32)
81 state = f"{slug}:{mirror_id or 'new'}:{nonce}"
82 request.session["oauth_state_nonce"] = nonce
83
84 return f"{GITLAB_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={callback}&response_type=code&scope=api&state={state}"
85
86
87 def gitlab_exchange_token(request, slug):
88
+11 -5
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -150,25 +150,31 @@
150150
cli.ensure_default_user(repo.full_path)
151151
152152
# Git export directory for this mirror
153153
export_dir = mirror_dir / f"{repo.filename.replace('.fossil', '')}-git"
154154
155
- # Build autopush URL with credentials if token auth
155
+ # Pass clean URL and token separately -- never embed credentials in URLs
156156
push_url = mirror.git_remote_url
157
+ auth_token = ""
157158
if mirror.auth_method == "token" and mirror.auth_credential and push_url.startswith("https://"):
158
- push_url = push_url.replace("https://", f"https://{mirror.auth_credential}@")
159
+ auth_token = mirror.auth_credential
160
+
161
+ result = cli.git_export(repo.full_path, export_dir, autopush_url=push_url, auth_token=auth_token)
159162
160
- result = cli.git_export(repo.full_path, export_dir, autopush_url=push_url)
163
+ # Scrub any credential from output before persisting
164
+ message = result.get("message", "")
165
+ if mirror.auth_credential:
166
+ message = message.replace(mirror.auth_credential, "[REDACTED]")
161167
162168
log.status = "success" if result["success"] else "failed"
163
- log.message = result["message"]
169
+ log.message = message
164170
log.completed_at = timezone.now()
165171
log.save()
166172
167173
mirror.last_sync_at = timezone.now()
168174
mirror.last_sync_status = log.status
169
- mirror.last_sync_message = result["message"][:500]
175
+ mirror.last_sync_message = message[:500]
170176
mirror.total_syncs += 1
171177
mirror.save(
172178
update_fields=[
173179
"last_sync_at",
174180
"last_sync_status",
175181
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -150,25 +150,31 @@
150 cli.ensure_default_user(repo.full_path)
151
152 # Git export directory for this mirror
153 export_dir = mirror_dir / f"{repo.filename.replace('.fossil', '')}-git"
154
155 # Build autopush URL with credentials if token auth
156 push_url = mirror.git_remote_url
 
157 if mirror.auth_method == "token" and mirror.auth_credential and push_url.startswith("https://"):
158 push_url = push_url.replace("https://", f"https://{mirror.auth_credential}@")
 
 
159
160 result = cli.git_export(repo.full_path, export_dir, autopush_url=push_url)
 
 
 
161
162 log.status = "success" if result["success"] else "failed"
163 log.message = result["message"]
164 log.completed_at = timezone.now()
165 log.save()
166
167 mirror.last_sync_at = timezone.now()
168 mirror.last_sync_status = log.status
169 mirror.last_sync_message = result["message"][:500]
170 mirror.total_syncs += 1
171 mirror.save(
172 update_fields=[
173 "last_sync_at",
174 "last_sync_status",
175
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -150,25 +150,31 @@
150 cli.ensure_default_user(repo.full_path)
151
152 # Git export directory for this mirror
153 export_dir = mirror_dir / f"{repo.filename.replace('.fossil', '')}-git"
154
155 # Pass clean URL and token separately -- never embed credentials in URLs
156 push_url = mirror.git_remote_url
157 auth_token = ""
158 if mirror.auth_method == "token" and mirror.auth_credential and push_url.startswith("https://"):
159 auth_token = mirror.auth_credential
160
161 result = cli.git_export(repo.full_path, export_dir, autopush_url=push_url, auth_token=auth_token)
162
163 # Scrub any credential from output before persisting
164 message = result.get("message", "")
165 if mirror.auth_credential:
166 message = message.replace(mirror.auth_credential, "[REDACTED]")
167
168 log.status = "success" if result["success"] else "failed"
169 log.message = message
170 log.completed_at = timezone.now()
171 log.save()
172
173 mirror.last_sync_at = timezone.now()
174 mirror.last_sync_status = log.status
175 mirror.last_sync_message = message[:500]
176 mirror.total_syncs += 1
177 mirror.save(
178 update_fields=[
179 "last_sync_at",
180 "last_sync_status",
181

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button