FossilRepo

Fix SSH key injection, stored XSS, and forum thread IDOR SSH key injection: Validate and sanitize public key input on upload -- reject keys with newlines/CR/null that could inject extra authorized_keys entries outside the forced-command wrapper. Added defense-in-depth sanitization in _regenerate_authorized_keys(), changed fossil user shell to /usr/sbin/nologin, and added global ForceCommand in sshd_config. Stored XSS: Added core/sanitize.py to strip <script>, <style>, <iframe>, event handlers, and dangerous URL protocols from HTML before mark_safe(). Applied sanitization to all 12 mark_safe() call sites in fossil/views.py and pages/views.py that render Fossil wiki, Markdown, forum posts, release bodies, ticket comments, and doc pages. Forum IDOR: Scoped ForumPost queries in forum_thread and forum_reply views to require repository=fossil_repo, preventing cross-project thread viewing and reply injection.

lmata 2026-04-07 14:47 trunk
Commit 911b0a4f791c4d49f5363fd34e410feca384157db4fc986c03761de33cb3edfe
+1 -1
--- Dockerfile
+++ Dockerfile
@@ -46,11 +46,11 @@
4646
4747
# Create data directories
4848
RUN mkdir -p /data/repos /data/trash /data/ssh
4949
5050
# SSH setup — restricted fossil user + sshd for clone/push
51
-RUN useradd -r -m -d /home/fossil -s /bin/bash fossil \
51
+RUN useradd -r -m -d /home/fossil -s /usr/sbin/nologin fossil \
5252
&& mkdir -p /run/sshd /home/fossil/.ssh \
5353
&& chown fossil:fossil /home/fossil/.ssh \
5454
&& chmod 700 /home/fossil/.ssh
5555
5656
COPY docker/sshd_config /etc/ssh/sshd_config
5757
--- Dockerfile
+++ Dockerfile
@@ -46,11 +46,11 @@
46
47 # Create data directories
48 RUN mkdir -p /data/repos /data/trash /data/ssh
49
50 # SSH setup — restricted fossil user + sshd for clone/push
51 RUN useradd -r -m -d /home/fossil -s /bin/bash fossil \
52 && mkdir -p /run/sshd /home/fossil/.ssh \
53 && chown fossil:fossil /home/fossil/.ssh \
54 && chmod 700 /home/fossil/.ssh
55
56 COPY docker/sshd_config /etc/ssh/sshd_config
57
--- Dockerfile
+++ Dockerfile
@@ -46,11 +46,11 @@
46
47 # Create data directories
48 RUN mkdir -p /data/repos /data/trash /data/ssh
49
50 # SSH setup — restricted fossil user + sshd for clone/push
51 RUN useradd -r -m -d /home/fossil -s /usr/sbin/nologin fossil \
52 && mkdir -p /run/sshd /home/fossil/.ssh \
53 && chown fossil:fossil /home/fossil/.ssh \
54 && chmod 700 /home/fossil/.ssh
55
56 COPY docker/sshd_config /etc/ssh/sshd_config
57
--- accounts/views.py
+++ accounts/views.py
@@ -1,14 +1,53 @@
1
+import re
2
+
13
from django.contrib import messages
24
from django.contrib.auth import login, logout
35
from django.contrib.auth.decorators import login_required
46
from django.http import HttpResponse
57
from django.shortcuts import get_object_or_404, redirect, render
8
+from django.utils.http import url_has_allowed_host_and_scheme
69
from django.views.decorators.http import require_POST
710
from django_ratelimit.decorators import ratelimit
811
912
from .forms import LoginForm
13
+
14
+# Allowed SSH key type prefixes
15
+_SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss")
16
+
17
+
18
+def _sanitize_ssh_key(public_key: str) -> tuple[str | None, str]:
19
+ """Validate and sanitize an SSH public key.
20
+
21
+ Returns (sanitized_key, error_message). On success error_message is "".
22
+ Rejects keys containing newlines, carriage returns, or null bytes (which
23
+ would allow injection of extra authorized_keys entries). Validates format:
24
+ known type prefix, 2-3 space-separated parts.
25
+ """
26
+ # Strip dangerous injection characters -- newlines let an attacker add
27
+ # a second authorized_keys line outside the forced-command wrapper
28
+ if "\n" in public_key or "\r" in public_key or "\x00" in public_key:
29
+ return None, "SSH key must be a single line. Newlines, carriage returns, and null bytes are not allowed."
30
+
31
+ key = public_key.strip()
32
+ if not key:
33
+ return None, "SSH key cannot be empty."
34
+
35
+ # SSH keys are: <type> <base64-data> [optional comment]
36
+ parts = key.split()
37
+ if len(parts) < 2 or len(parts) > 3:
38
+ return None, "Invalid SSH key format. Expected: <key-type> <key-data> [comment]"
39
+
40
+ key_type = parts[0]
41
+ if not any(key_type.startswith(prefix) for prefix in _SSH_KEY_PREFIXES):
42
+ return None, f"Unsupported key type '{key_type}'. Allowed: ssh-ed25519, ssh-rsa, ecdsa-sha2-*, ssh-dss."
43
+
44
+ # Validate base64 data is plausible (only base64 chars + padding)
45
+ if not re.match(r"^[A-Za-z0-9+/=]+$", parts[1]):
46
+ return None, "Invalid SSH key data encoding."
47
+
48
+ return key, ""
1049
1150
1251
@ratelimit(key="ip", rate="10/m", block=True)
1352
def login_view(request):
1453
if request.user.is_authenticated:
@@ -16,12 +55,14 @@
1655
1756
if request.method == "POST":
1857
form = LoginForm(request, data=request.POST)
1958
if form.is_valid():
2059
login(request, form.get_user())
21
- next_url = request.GET.get("next", "dashboard")
22
- return redirect(next_url)
60
+ next_url = request.GET.get("next", "")
61
+ if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
62
+ return redirect(next_url)
63
+ return redirect("dashboard")
2364
else:
2465
form = LoginForm()
2566
2667
return render(request, "accounts/login.html", {"form": form})
2768
@@ -84,15 +125,20 @@
84125
85126
keys = UserSSHKey.objects.filter(deleted_at__isnull=True).select_related("user")
86127
87128
lines = []
88129
for key in keys:
130
+ # Defense in depth: strip newlines/CR/null from stored keys so a
131
+ # compromised DB value cannot inject extra authorized_keys entries.
132
+ clean_key = key.public_key.strip().replace("\n", "").replace("\r", "").replace("\x00", "")
133
+ if not clean_key:
134
+ continue
89135
# Each key gets a forced command that identifies the user
90136
forced_cmd = (
91137
f'command="/usr/local/bin/fossil-shell {key.user.username}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
92138
)
93
- lines.append(f"{forced_cmd} {key.public_key.strip()}")
139
+ lines.append(f"{forced_cmd} {clean_key}")
94140
95141
authorized_keys_path.write_text("\n".join(lines) + "\n" if lines else "")
96142
authorized_keys_path.chmod(0o600)
97143
98144
@@ -106,17 +152,22 @@
106152
if request.method == "POST":
107153
title = request.POST.get("title", "").strip()
108154
public_key = request.POST.get("public_key", "").strip()
109155
110156
if title and public_key:
111
- key_type = _parse_key_type(public_key)
112
- fingerprint = _compute_fingerprint(public_key)
157
+ sanitized_key, error = _sanitize_ssh_key(public_key)
158
+ if error:
159
+ messages.error(request, error)
160
+ return render(request, "accounts/ssh_keys.html", {"keys": keys})
161
+
162
+ key_type = _parse_key_type(sanitized_key)
163
+ fingerprint = _compute_fingerprint(sanitized_key)
113164
114165
UserSSHKey.objects.create(
115166
user=request.user,
116167
title=title,
117
- public_key=public_key,
168
+ public_key=sanitized_key,
118169
key_type=key_type,
119170
fingerprint=fingerprint,
120171
created_by=request.user,
121172
)
122173
123174
124175
ADDED core/sanitize.py
--- accounts/views.py
+++ accounts/views.py
@@ -1,14 +1,53 @@
 
 
1 from django.contrib import messages
2 from django.contrib.auth import login, logout
3 from django.contrib.auth.decorators import login_required
4 from django.http import HttpResponse
5 from django.shortcuts import get_object_or_404, redirect, render
 
6 from django.views.decorators.http import require_POST
7 from django_ratelimit.decorators import ratelimit
8
9 from .forms import LoginForm
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
11
12 @ratelimit(key="ip", rate="10/m", block=True)
13 def login_view(request):
14 if request.user.is_authenticated:
@@ -16,12 +55,14 @@
16
17 if request.method == "POST":
18 form = LoginForm(request, data=request.POST)
19 if form.is_valid():
20 login(request, form.get_user())
21 next_url = request.GET.get("next", "dashboard")
22 return redirect(next_url)
 
 
23 else:
24 form = LoginForm()
25
26 return render(request, "accounts/login.html", {"form": form})
27
@@ -84,15 +125,20 @@
84
85 keys = UserSSHKey.objects.filter(deleted_at__isnull=True).select_related("user")
86
87 lines = []
88 for key in keys:
 
 
 
 
 
89 # Each key gets a forced command that identifies the user
90 forced_cmd = (
91 f'command="/usr/local/bin/fossil-shell {key.user.username}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
92 )
93 lines.append(f"{forced_cmd} {key.public_key.strip()}")
94
95 authorized_keys_path.write_text("\n".join(lines) + "\n" if lines else "")
96 authorized_keys_path.chmod(0o600)
97
98
@@ -106,17 +152,22 @@
106 if request.method == "POST":
107 title = request.POST.get("title", "").strip()
108 public_key = request.POST.get("public_key", "").strip()
109
110 if title and public_key:
111 key_type = _parse_key_type(public_key)
112 fingerprint = _compute_fingerprint(public_key)
 
 
 
 
 
113
114 UserSSHKey.objects.create(
115 user=request.user,
116 title=title,
117 public_key=public_key,
118 key_type=key_type,
119 fingerprint=fingerprint,
120 created_by=request.user,
121 )
122
123
124 DDED core/sanitize.py
--- accounts/views.py
+++ accounts/views.py
@@ -1,14 +1,53 @@
1 import re
2
3 from django.contrib import messages
4 from django.contrib.auth import login, logout
5 from django.contrib.auth.decorators import login_required
6 from django.http import HttpResponse
7 from django.shortcuts import get_object_or_404, redirect, render
8 from django.utils.http import url_has_allowed_host_and_scheme
9 from django.views.decorators.http import require_POST
10 from django_ratelimit.decorators import ratelimit
11
12 from .forms import LoginForm
13
14 # Allowed SSH key type prefixes
15 _SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss")
16
17
18 def _sanitize_ssh_key(public_key: str) -> tuple[str | None, str]:
19 """Validate and sanitize an SSH public key.
20
21 Returns (sanitized_key, error_message). On success error_message is "".
22 Rejects keys containing newlines, carriage returns, or null bytes (which
23 would allow injection of extra authorized_keys entries). Validates format:
24 known type prefix, 2-3 space-separated parts.
25 """
26 # Strip dangerous injection characters -- newlines let an attacker add
27 # a second authorized_keys line outside the forced-command wrapper
28 if "\n" in public_key or "\r" in public_key or "\x00" in public_key:
29 return None, "SSH key must be a single line. Newlines, carriage returns, and null bytes are not allowed."
30
31 key = public_key.strip()
32 if not key:
33 return None, "SSH key cannot be empty."
34
35 # SSH keys are: <type> <base64-data> [optional comment]
36 parts = key.split()
37 if len(parts) < 2 or len(parts) > 3:
38 return None, "Invalid SSH key format. Expected: <key-type> <key-data> [comment]"
39
40 key_type = parts[0]
41 if not any(key_type.startswith(prefix) for prefix in _SSH_KEY_PREFIXES):
42 return None, f"Unsupported key type '{key_type}'. Allowed: ssh-ed25519, ssh-rsa, ecdsa-sha2-*, ssh-dss."
43
44 # Validate base64 data is plausible (only base64 chars + padding)
45 if not re.match(r"^[A-Za-z0-9+/=]+$", parts[1]):
46 return None, "Invalid SSH key data encoding."
47
48 return key, ""
49
50
51 @ratelimit(key="ip", rate="10/m", block=True)
52 def login_view(request):
53 if request.user.is_authenticated:
@@ -16,12 +55,14 @@
55
56 if request.method == "POST":
57 form = LoginForm(request, data=request.POST)
58 if form.is_valid():
59 login(request, form.get_user())
60 next_url = request.GET.get("next", "")
61 if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
62 return redirect(next_url)
63 return redirect("dashboard")
64 else:
65 form = LoginForm()
66
67 return render(request, "accounts/login.html", {"form": form})
68
@@ -84,15 +125,20 @@
125
126 keys = UserSSHKey.objects.filter(deleted_at__isnull=True).select_related("user")
127
128 lines = []
129 for key in keys:
130 # Defense in depth: strip newlines/CR/null from stored keys so a
131 # compromised DB value cannot inject extra authorized_keys entries.
132 clean_key = key.public_key.strip().replace("\n", "").replace("\r", "").replace("\x00", "")
133 if not clean_key:
134 continue
135 # Each key gets a forced command that identifies the user
136 forced_cmd = (
137 f'command="/usr/local/bin/fossil-shell {key.user.username}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
138 )
139 lines.append(f"{forced_cmd} {clean_key}")
140
141 authorized_keys_path.write_text("\n".join(lines) + "\n" if lines else "")
142 authorized_keys_path.chmod(0o600)
143
144
@@ -106,17 +152,22 @@
152 if request.method == "POST":
153 title = request.POST.get("title", "").strip()
154 public_key = request.POST.get("public_key", "").strip()
155
156 if title and public_key:
157 sanitized_key, error = _sanitize_ssh_key(public_key)
158 if error:
159 messages.error(request, error)
160 return render(request, "accounts/ssh_keys.html", {"keys": keys})
161
162 key_type = _parse_key_type(sanitized_key)
163 fingerprint = _compute_fingerprint(sanitized_key)
164
165 UserSSHKey.objects.create(
166 user=request.user,
167 title=title,
168 public_key=sanitized_key,
169 key_type=key_type,
170 fingerprint=fingerprint,
171 created_by=request.user,
172 )
173
174
175 DDED core/sanitize.py
--- a/core/sanitize.py
+++ b/core/sanitize.py
@@ -0,0 +1,135 @@
1
+"""HTML sanitization for user-generated content.
2
+
3
+Strips dangerous tags (<script>, <style>, <iframe>, etc.), event handlers (on*),
4
+and dangerous URL protocols (javascript:, data:, vbscript:) while preserving
5
+safe formatting tags used by Fossil wiki, Markdown, and Pikchr diagrams.
6
+"""
7
+
8
+import re
9
+
10
+# Tags that are safe to render -- covers Markdown/wiki formatting and Pikchr SVG
11
+ALLOWED_TAGS = {
12
+ "a",
13
+ "abbr",
14
+ "acronym",
15
+ "b",
16
+ "blockquote",
17
+ "br",
18
+ "code",
19
+ "dd",
20
+ "del",
21
+ "details",
22
+ "div",
23
+ "dl",
24
+ "dt",
25
+ "em",
26
+ "h1",
27
+ "h2",
28
+ "h3",
29
+ "h4",
30
+ "h5",
31
+ "h6",
32
+ "hr",
33
+ "i",
34
+ "img",
35
+ "ins",
36
+ "kbd",
37
+ "li",
38
+ "mark",
39
+ "ol",
40
+ "p",
41
+ "pre",
42
+ "q",
43
+ "s",
44
+ "samp",
45
+ "small",
46
+ "span",
47
+ "strong",
48
+ "sub",
49
+ "summary",
50
+ "sup",
51
+ "table",
52
+ "tbody",
53
+ "td",
54
+ "tfoot",
55
+ "th",
56
+ "thead",
57
+ "tr",
58
+ "tt",
59
+ "u",
60
+ "ul",
61
+ "var",
62
+ # SVG elements for Pikchr diagrams
63
+ "svg",
64
+ "path",
65
+ "circle",
66
+ "rect",
67
+ "line",
68
+ "polyline",
69
+ "polygon",
70
+ "g",
71
+ "text",
72
+ "defs",
73
+ "use",
74
+ "symbol",
75
+}
76
+
77
+# Tags whose entire content (not just the tag) must be removed
78
+_DANGEROUS_CONTENT_TAGS = re.compile(
79
+ r"<\s*(script|style|iframe|object|embed|form|base|meta|link)\b[^>]*>.*?</\s*\1\s*>",
80
+ re.IGNORECASE | re.DOTALL,
81
+)
82
+
83
+# Self-closing / unclosed dangerous tags
84
+_DANGEROUS_SELF_CLOSING = re.compile(
85
+ r"<\s*/?\s*(script|style|iframe|object|embed|form|base|meta|link)\b[^>]*/?\s*>",
86
+ re.IGNORECASE,
87
+)
88
+
89
+# Event handler attributes (onclick, onload, onerror, etc.)
90
+_EVENT_HANDLERS = re.compile(
91
+ r"""\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)""",
92
+ re.IGNORECASE,
93
+)
94
+
95
+# Dangerous protocols in href/src values
96
+_DANGEROUS_PROTOCOL = re.compile(r"^\s*(?:javascript|vbscript|data):", re.IGNORECASE)
97
+
98
+# href="..." and src="..." attribute pattern
99
+_URL_ATTR = re.compile(r"""(href|src)\s*=\s*(["']?)([^"'>\s]+)\2""", re.IGNORECASE)
100
+
101
+
102
+def _clean_url_attr(match: re.Match) -> str:
103
+ """Replace dangerous protocol URLs with a safe '#' anchor."""
104
+ attr_name = match.group(1)
105
+ quote = match.group(2) or ""
106
+ url = match.group(3)
107
+ if _DANGEROUS_PROTOCOL.match(url):
108
+ return f"{attr_name}={quote}#{quote}"
109
+ return match.group(0)
110
+
111
+
112
+def sanitize_html(html: str) -> str:
113
+ """Remove dangerous HTML tags and attributes while preserving safe formatting.
114
+
115
+ Strips <script>, <style>, <iframe>, <object>, <embed>, <form>, <base>,
116
+ <meta>, <link> tags and their content. Removes event handler attributes
117
+ (on*) and replaces dangerous URL protocols (javascript:, data:, vbscript:)
118
+ in href/src with '#'.
119
+ """
120
+ if not html:
121
+ return html
122
+
123
+ # 1. Remove dangerous tags WITH their content (e.g. <script>...</script>)
124
+ html = _DANGEROUS_CONTENT_TAGS.sub("", html)
125
+
126
+ # 2. Remove any remaining self-closing or orphaned dangerous tags
127
+ html = _DANGEROUS_SELF_CLOSING.sub("", html)
128
+
129
+ # 3. Remove event handler attributes (onclick, onload, onerror, etc.)
130
+ html = _EVENT_HANDLERS.sub("", html)
131
+
132
+ # 4. Neutralize dangerous URL protocols in href and src attributes
133
+ html = _URL_ATTR.sub(_clean_url_attr, html)
134
+
135
+ return html
--- a/core/sanitize.py
+++ b/core/sanitize.py
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/core/sanitize.py
+++ b/core/sanitize.py
@@ -0,0 +1,135 @@
1 """HTML sanitization for user-generated content.
2
3 Strips dangerous tags (<script>, <style>, <iframe>, etc.), event handlers (on*),
4 and dangerous URL protocols (javascript:, data:, vbscript:) while preserving
5 safe formatting tags used by Fossil wiki, Markdown, and Pikchr diagrams.
6 """
7
8 import re
9
10 # Tags that are safe to render -- covers Markdown/wiki formatting and Pikchr SVG
11 ALLOWED_TAGS = {
12 "a",
13 "abbr",
14 "acronym",
15 "b",
16 "blockquote",
17 "br",
18 "code",
19 "dd",
20 "del",
21 "details",
22 "div",
23 "dl",
24 "dt",
25 "em",
26 "h1",
27 "h2",
28 "h3",
29 "h4",
30 "h5",
31 "h6",
32 "hr",
33 "i",
34 "img",
35 "ins",
36 "kbd",
37 "li",
38 "mark",
39 "ol",
40 "p",
41 "pre",
42 "q",
43 "s",
44 "samp",
45 "small",
46 "span",
47 "strong",
48 "sub",
49 "summary",
50 "sup",
51 "table",
52 "tbody",
53 "td",
54 "tfoot",
55 "th",
56 "thead",
57 "tr",
58 "tt",
59 "u",
60 "ul",
61 "var",
62 # SVG elements for Pikchr diagrams
63 "svg",
64 "path",
65 "circle",
66 "rect",
67 "line",
68 "polyline",
69 "polygon",
70 "g",
71 "text",
72 "defs",
73 "use",
74 "symbol",
75 }
76
77 # Tags whose entire content (not just the tag) must be removed
78 _DANGEROUS_CONTENT_TAGS = re.compile(
79 r"<\s*(script|style|iframe|object|embed|form|base|meta|link)\b[^>]*>.*?</\s*\1\s*>",
80 re.IGNORECASE | re.DOTALL,
81 )
82
83 # Self-closing / unclosed dangerous tags
84 _DANGEROUS_SELF_CLOSING = re.compile(
85 r"<\s*/?\s*(script|style|iframe|object|embed|form|base|meta|link)\b[^>]*/?\s*>",
86 re.IGNORECASE,
87 )
88
89 # Event handler attributes (onclick, onload, onerror, etc.)
90 _EVENT_HANDLERS = re.compile(
91 r"""\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)""",
92 re.IGNORECASE,
93 )
94
95 # Dangerous protocols in href/src values
96 _DANGEROUS_PROTOCOL = re.compile(r"^\s*(?:javascript|vbscript|data):", re.IGNORECASE)
97
98 # href="..." and src="..." attribute pattern
99 _URL_ATTR = re.compile(r"""(href|src)\s*=\s*(["']?)([^"'>\s]+)\2""", re.IGNORECASE)
100
101
102 def _clean_url_attr(match: re.Match) -> str:
103 """Replace dangerous protocol URLs with a safe '#' anchor."""
104 attr_name = match.group(1)
105 quote = match.group(2) or ""
106 url = match.group(3)
107 if _DANGEROUS_PROTOCOL.match(url):
108 return f"{attr_name}={quote}#{quote}"
109 return match.group(0)
110
111
112 def sanitize_html(html: str) -> str:
113 """Remove dangerous HTML tags and attributes while preserving safe formatting.
114
115 Strips <script>, <style>, <iframe>, <object>, <embed>, <form>, <base>,
116 <meta>, <link> tags and their content. Removes event handler attributes
117 (on*) and replaces dangerous URL protocols (javascript:, data:, vbscript:)
118 in href/src with '#'.
119 """
120 if not html:
121 return html
122
123 # 1. Remove dangerous tags WITH their content (e.g. <script>...</script>)
124 html = _DANGEROUS_CONTENT_TAGS.sub("", html)
125
126 # 2. Remove any remaining self-closing or orphaned dangerous tags
127 html = _DANGEROUS_SELF_CLOSING.sub("", html)
128
129 # 3. Remove event handler attributes (onclick, onload, onerror, etc.)
130 html = _EVENT_HANDLERS.sub("", html)
131
132 # 4. Neutralize dangerous URL protocols in href and src attributes
133 html = _URL_ATTR.sub(_clean_url_attr, html)
134
135 return html
--- docker/sshd_config
+++ docker/sshd_config
@@ -16,10 +16,14 @@
1616
PubkeyAuthentication yes
1717
AuthorizedKeysFile /data/ssh/authorized_keys
1818
1919
# Only allow the fossil user
2020
AllowUsers fossil
21
+
22
+# Force all fossil-user connections through the restricted shell,
23
+# even if an authorized_keys entry is missing the command= directive.
24
+ForceCommand /usr/local/bin/fossil-shell
2125
2226
# Disable everything except the sync protocol
2327
PermitTunnel no
2428
AllowTcpForwarding no
2529
X11Forwarding no
2630
--- docker/sshd_config
+++ docker/sshd_config
@@ -16,10 +16,14 @@
16 PubkeyAuthentication yes
17 AuthorizedKeysFile /data/ssh/authorized_keys
18
19 # Only allow the fossil user
20 AllowUsers fossil
 
 
 
 
21
22 # Disable everything except the sync protocol
23 PermitTunnel no
24 AllowTcpForwarding no
25 X11Forwarding no
26
--- docker/sshd_config
+++ docker/sshd_config
@@ -16,10 +16,14 @@
16 PubkeyAuthentication yes
17 AuthorizedKeysFile /data/ssh/authorized_keys
18
19 # Only allow the fossil user
20 AllowUsers fossil
21
22 # Force all fossil-user connections through the restricted shell,
23 # even if an authorized_keys entry is missing the command= directive.
24 ForceCommand /usr/local/bin/fossil-shell
25
26 # Disable everything except the sync protocol
27 PermitTunnel no
28 AllowTcpForwarding no
29 X11Forwarding no
30
+14 -13
--- fossil/views.py
+++ fossil/views.py
@@ -7,10 +7,11 @@
77
from django.http import Http404, HttpResponse
88
from django.shortcuts import get_object_or_404, redirect, render
99
from django.utils.safestring import mark_safe
1010
from django.views.decorators.csrf import csrf_exempt
1111
12
+from core.sanitize import sanitize_html
1213
from projects.models import Project
1314
1415
from .models import FossilRepository
1516
from .reader import FossilReader
1617
@@ -337,11 +338,11 @@
337338
with reader:
338339
content_bytes = reader.get_file_content(f.uuid)
339340
try:
340341
readme_content = content_bytes.decode("utf-8")
341342
doc_base = prefix if prefix else ""
342
- readme_html = mark_safe(_render_fossil_content(readme_content, project_slug=slug, base_path=doc_base))
343
+ readme_html = mark_safe(sanitize_html(_render_fossil_content(readme_content, project_slug=slug, base_path=doc_base)))
343344
except (UnicodeDecodeError, Exception):
344345
pass
345346
break
346347
if readme_html:
347348
break
@@ -420,11 +421,11 @@
420421
rendered_html = ""
421422
if can_render and view_mode == "rendered" and not is_binary:
422423
doc_base = "/".join(filepath.split("/")[:-1])
423424
if doc_base:
424425
doc_base += "/"
425
- rendered_html = mark_safe(_render_fossil_content(content, project_slug=slug, base_path=doc_base))
426
+ rendered_html = mark_safe(sanitize_html(_render_fossil_content(content, project_slug=slug, base_path=doc_base)))
426427
427428
return render(
428429
request,
429430
"fossil/code_file.html",
430431
{
@@ -717,18 +718,18 @@
717718
comments = reader.get_ticket_comments(ticket_uuid) if ticket else []
718719
719720
if not ticket:
720721
raise Http404("Ticket not found")
721722
722
- body_html = mark_safe(_render_fossil_content(ticket.body, project_slug=slug)) if ticket.body else ""
723
+ body_html = mark_safe(sanitize_html(_render_fossil_content(ticket.body, project_slug=slug))) if ticket.body else ""
723724
rendered_comments = []
724725
for c in comments:
725726
rendered_comments.append(
726727
{
727728
"user": c["user"],
728729
"timestamp": c["timestamp"],
729
- "html": mark_safe(_render_fossil_content(c["comment"], project_slug=slug)),
730
+ "html": mark_safe(sanitize_html(_render_fossil_content(c["comment"], project_slug=slug))),
730731
}
731732
)
732733
733734
return render(
734735
request,
@@ -754,11 +755,11 @@
754755
pages = reader.get_wiki_pages()
755756
home_page = reader.get_wiki_page("Home")
756757
757758
home_content_html = ""
758759
if home_page:
759
- home_content_html = mark_safe(_render_fossil_content(home_page.content, project_slug=slug))
760
+ home_content_html = mark_safe(sanitize_html(_render_fossil_content(home_page.content, project_slug=slug)))
760761
761762
return render(
762763
request,
763764
"fossil/wiki_list.html",
764765
{
@@ -780,11 +781,11 @@
780781
all_pages = reader.get_wiki_pages()
781782
782783
if not page:
783784
raise Http404(f"Wiki page not found: {page_name}")
784785
785
- content_html = mark_safe(_render_fossil_content(page.content, project_slug=slug))
786
+ content_html = mark_safe(sanitize_html(_render_fossil_content(page.content, project_slug=slug)))
786787
787788
return render(
788789
request,
789790
"fossil/wiki_page.html",
790791
{
@@ -862,21 +863,21 @@
862863
# Check if this is a Fossil-native thread or a Django-backed thread
863864
is_django_thread = False
864865
from fossil.forum import ForumPost as DjangoForumPost
865866
866867
try:
867
- django_root = DjangoForumPost.objects.get(pk=int(thread_uuid))
868
+ django_root = DjangoForumPost.objects.get(pk=int(thread_uuid), repository=fossil_repo)
868869
is_django_thread = True
869870
except (ValueError, DjangoForumPost.DoesNotExist):
870871
django_root = None
871872
872873
rendered_posts = []
873874
874875
if is_django_thread:
875876
# Django-backed thread: root + replies
876877
root = django_root
877
- body_html = mark_safe(md.markdown(root.body, extensions=["fenced_code", "tables"])) if root.body else ""
878
+ body_html = mark_safe(sanitize_html(md.markdown(root.body, extensions=["fenced_code", "tables"]))) if root.body else ""
878879
rendered_posts.append(
879880
{
880881
"post": {
881882
"user": root.created_by.username if root.created_by else "",
882883
"title": root.title,
@@ -885,11 +886,11 @@
885886
},
886887
"body_html": body_html,
887888
}
888889
)
889890
for reply in DjangoForumPost.objects.filter(thread_root=root).exclude(pk=root.pk).select_related("created_by"):
890
- reply_html = mark_safe(md.markdown(reply.body, extensions=["fenced_code", "tables"])) if reply.body else ""
891
+ reply_html = mark_safe(sanitize_html(md.markdown(reply.body, extensions=["fenced_code", "tables"]))) if reply.body else ""
891892
rendered_posts.append(
892893
{
893894
"post": {
894895
"user": reply.created_by.username if reply.created_by else "",
895896
"title": "",
@@ -909,11 +910,11 @@
909910
910911
if not posts:
911912
raise Http404("Forum thread not found")
912913
913914
for post in posts:
914
- body_html = mark_safe(_render_fossil_content(post.body, project_slug=slug)) if post.body else ""
915
+ body_html = mark_safe(sanitize_html(_render_fossil_content(post.body, project_slug=slug))) if post.body else ""
915916
rendered_posts.append({"post": post, "body_html": body_html})
916917
917918
has_write = can_write_project(request.user, project)
918919
919920
return render(
@@ -975,11 +976,11 @@
975976
976977
project, fossil_repo = _get_project_and_repo(slug, request, "write")
977978
978979
from fossil.forum import ForumPost as DjangoForumPost
979980
980
- parent = get_object_or_404(DjangoForumPost, pk=post_id, deleted_at__isnull=True)
981
+ parent = get_object_or_404(DjangoForumPost, pk=post_id, repository=fossil_repo, deleted_at__isnull=True)
981982
982983
# Determine the thread root
983984
thread_root = parent.thread_root if parent.thread_root else parent
984985
985986
if request.method == "POST":
@@ -2233,11 +2234,11 @@
22332234
22342235
# Compute base_path for relative link resolution (e.g. "www/" for "www/concepts.wiki")
22352236
doc_base = "/".join(doc_path.split("/")[:-1])
22362237
if doc_base:
22372238
doc_base += "/"
2238
- content_html = mark_safe(_render_fossil_content(content, project_slug=slug, base_path=doc_base))
2239
+ content_html = mark_safe(sanitize_html(_render_fossil_content(content, project_slug=slug, base_path=doc_base)))
22392240
22402241
return render(
22412242
request,
22422243
"fossil/doc_page.html",
22432244
{"project": project, "doc_path": doc_path, "content_html": content_html, "active_tab": "wiki"},
@@ -2533,11 +2534,11 @@
25332534
25342535
require_project_write(request, project)
25352536
25362537
body_html = ""
25372538
if release.body:
2538
- body_html = mark_safe(md.markdown(release.body, extensions=["footnotes", "tables", "fenced_code"]))
2539
+ body_html = mark_safe(sanitize_html(md.markdown(release.body, extensions=["footnotes", "tables", "fenced_code"])))
25392540
25402541
assets = release.assets.filter(deleted_at__isnull=True)
25412542
has_write = can_write_project(request.user, project)
25422543
has_admin = can_admin_project(request.user, project)
25432544
25442545
--- fossil/views.py
+++ fossil/views.py
@@ -7,10 +7,11 @@
7 from django.http import Http404, HttpResponse
8 from django.shortcuts import get_object_or_404, redirect, render
9 from django.utils.safestring import mark_safe
10 from django.views.decorators.csrf import csrf_exempt
11
 
12 from projects.models import Project
13
14 from .models import FossilRepository
15 from .reader import FossilReader
16
@@ -337,11 +338,11 @@
337 with reader:
338 content_bytes = reader.get_file_content(f.uuid)
339 try:
340 readme_content = content_bytes.decode("utf-8")
341 doc_base = prefix if prefix else ""
342 readme_html = mark_safe(_render_fossil_content(readme_content, project_slug=slug, base_path=doc_base))
343 except (UnicodeDecodeError, Exception):
344 pass
345 break
346 if readme_html:
347 break
@@ -420,11 +421,11 @@
420 rendered_html = ""
421 if can_render and view_mode == "rendered" and not is_binary:
422 doc_base = "/".join(filepath.split("/")[:-1])
423 if doc_base:
424 doc_base += "/"
425 rendered_html = mark_safe(_render_fossil_content(content, project_slug=slug, base_path=doc_base))
426
427 return render(
428 request,
429 "fossil/code_file.html",
430 {
@@ -717,18 +718,18 @@
717 comments = reader.get_ticket_comments(ticket_uuid) if ticket else []
718
719 if not ticket:
720 raise Http404("Ticket not found")
721
722 body_html = mark_safe(_render_fossil_content(ticket.body, project_slug=slug)) if ticket.body else ""
723 rendered_comments = []
724 for c in comments:
725 rendered_comments.append(
726 {
727 "user": c["user"],
728 "timestamp": c["timestamp"],
729 "html": mark_safe(_render_fossil_content(c["comment"], project_slug=slug)),
730 }
731 )
732
733 return render(
734 request,
@@ -754,11 +755,11 @@
754 pages = reader.get_wiki_pages()
755 home_page = reader.get_wiki_page("Home")
756
757 home_content_html = ""
758 if home_page:
759 home_content_html = mark_safe(_render_fossil_content(home_page.content, project_slug=slug))
760
761 return render(
762 request,
763 "fossil/wiki_list.html",
764 {
@@ -780,11 +781,11 @@
780 all_pages = reader.get_wiki_pages()
781
782 if not page:
783 raise Http404(f"Wiki page not found: {page_name}")
784
785 content_html = mark_safe(_render_fossil_content(page.content, project_slug=slug))
786
787 return render(
788 request,
789 "fossil/wiki_page.html",
790 {
@@ -862,21 +863,21 @@
862 # Check if this is a Fossil-native thread or a Django-backed thread
863 is_django_thread = False
864 from fossil.forum import ForumPost as DjangoForumPost
865
866 try:
867 django_root = DjangoForumPost.objects.get(pk=int(thread_uuid))
868 is_django_thread = True
869 except (ValueError, DjangoForumPost.DoesNotExist):
870 django_root = None
871
872 rendered_posts = []
873
874 if is_django_thread:
875 # Django-backed thread: root + replies
876 root = django_root
877 body_html = mark_safe(md.markdown(root.body, extensions=["fenced_code", "tables"])) if root.body else ""
878 rendered_posts.append(
879 {
880 "post": {
881 "user": root.created_by.username if root.created_by else "",
882 "title": root.title,
@@ -885,11 +886,11 @@
885 },
886 "body_html": body_html,
887 }
888 )
889 for reply in DjangoForumPost.objects.filter(thread_root=root).exclude(pk=root.pk).select_related("created_by"):
890 reply_html = mark_safe(md.markdown(reply.body, extensions=["fenced_code", "tables"])) if reply.body else ""
891 rendered_posts.append(
892 {
893 "post": {
894 "user": reply.created_by.username if reply.created_by else "",
895 "title": "",
@@ -909,11 +910,11 @@
909
910 if not posts:
911 raise Http404("Forum thread not found")
912
913 for post in posts:
914 body_html = mark_safe(_render_fossil_content(post.body, project_slug=slug)) if post.body else ""
915 rendered_posts.append({"post": post, "body_html": body_html})
916
917 has_write = can_write_project(request.user, project)
918
919 return render(
@@ -975,11 +976,11 @@
975
976 project, fossil_repo = _get_project_and_repo(slug, request, "write")
977
978 from fossil.forum import ForumPost as DjangoForumPost
979
980 parent = get_object_or_404(DjangoForumPost, pk=post_id, deleted_at__isnull=True)
981
982 # Determine the thread root
983 thread_root = parent.thread_root if parent.thread_root else parent
984
985 if request.method == "POST":
@@ -2233,11 +2234,11 @@
2233
2234 # Compute base_path for relative link resolution (e.g. "www/" for "www/concepts.wiki")
2235 doc_base = "/".join(doc_path.split("/")[:-1])
2236 if doc_base:
2237 doc_base += "/"
2238 content_html = mark_safe(_render_fossil_content(content, project_slug=slug, base_path=doc_base))
2239
2240 return render(
2241 request,
2242 "fossil/doc_page.html",
2243 {"project": project, "doc_path": doc_path, "content_html": content_html, "active_tab": "wiki"},
@@ -2533,11 +2534,11 @@
2533
2534 require_project_write(request, project)
2535
2536 body_html = ""
2537 if release.body:
2538 body_html = mark_safe(md.markdown(release.body, extensions=["footnotes", "tables", "fenced_code"]))
2539
2540 assets = release.assets.filter(deleted_at__isnull=True)
2541 has_write = can_write_project(request.user, project)
2542 has_admin = can_admin_project(request.user, project)
2543
2544
--- fossil/views.py
+++ fossil/views.py
@@ -7,10 +7,11 @@
7 from django.http import Http404, HttpResponse
8 from django.shortcuts import get_object_or_404, redirect, render
9 from django.utils.safestring import mark_safe
10 from django.views.decorators.csrf import csrf_exempt
11
12 from core.sanitize import sanitize_html
13 from projects.models import Project
14
15 from .models import FossilRepository
16 from .reader import FossilReader
17
@@ -337,11 +338,11 @@
338 with reader:
339 content_bytes = reader.get_file_content(f.uuid)
340 try:
341 readme_content = content_bytes.decode("utf-8")
342 doc_base = prefix if prefix else ""
343 readme_html = mark_safe(sanitize_html(_render_fossil_content(readme_content, project_slug=slug, base_path=doc_base)))
344 except (UnicodeDecodeError, Exception):
345 pass
346 break
347 if readme_html:
348 break
@@ -420,11 +421,11 @@
421 rendered_html = ""
422 if can_render and view_mode == "rendered" and not is_binary:
423 doc_base = "/".join(filepath.split("/")[:-1])
424 if doc_base:
425 doc_base += "/"
426 rendered_html = mark_safe(sanitize_html(_render_fossil_content(content, project_slug=slug, base_path=doc_base)))
427
428 return render(
429 request,
430 "fossil/code_file.html",
431 {
@@ -717,18 +718,18 @@
718 comments = reader.get_ticket_comments(ticket_uuid) if ticket else []
719
720 if not ticket:
721 raise Http404("Ticket not found")
722
723 body_html = mark_safe(sanitize_html(_render_fossil_content(ticket.body, project_slug=slug))) if ticket.body else ""
724 rendered_comments = []
725 for c in comments:
726 rendered_comments.append(
727 {
728 "user": c["user"],
729 "timestamp": c["timestamp"],
730 "html": mark_safe(sanitize_html(_render_fossil_content(c["comment"], project_slug=slug))),
731 }
732 )
733
734 return render(
735 request,
@@ -754,11 +755,11 @@
755 pages = reader.get_wiki_pages()
756 home_page = reader.get_wiki_page("Home")
757
758 home_content_html = ""
759 if home_page:
760 home_content_html = mark_safe(sanitize_html(_render_fossil_content(home_page.content, project_slug=slug)))
761
762 return render(
763 request,
764 "fossil/wiki_list.html",
765 {
@@ -780,11 +781,11 @@
781 all_pages = reader.get_wiki_pages()
782
783 if not page:
784 raise Http404(f"Wiki page not found: {page_name}")
785
786 content_html = mark_safe(sanitize_html(_render_fossil_content(page.content, project_slug=slug)))
787
788 return render(
789 request,
790 "fossil/wiki_page.html",
791 {
@@ -862,21 +863,21 @@
863 # Check if this is a Fossil-native thread or a Django-backed thread
864 is_django_thread = False
865 from fossil.forum import ForumPost as DjangoForumPost
866
867 try:
868 django_root = DjangoForumPost.objects.get(pk=int(thread_uuid), repository=fossil_repo)
869 is_django_thread = True
870 except (ValueError, DjangoForumPost.DoesNotExist):
871 django_root = None
872
873 rendered_posts = []
874
875 if is_django_thread:
876 # Django-backed thread: root + replies
877 root = django_root
878 body_html = mark_safe(sanitize_html(md.markdown(root.body, extensions=["fenced_code", "tables"]))) if root.body else ""
879 rendered_posts.append(
880 {
881 "post": {
882 "user": root.created_by.username if root.created_by else "",
883 "title": root.title,
@@ -885,11 +886,11 @@
886 },
887 "body_html": body_html,
888 }
889 )
890 for reply in DjangoForumPost.objects.filter(thread_root=root).exclude(pk=root.pk).select_related("created_by"):
891 reply_html = mark_safe(sanitize_html(md.markdown(reply.body, extensions=["fenced_code", "tables"]))) if reply.body else ""
892 rendered_posts.append(
893 {
894 "post": {
895 "user": reply.created_by.username if reply.created_by else "",
896 "title": "",
@@ -909,11 +910,11 @@
910
911 if not posts:
912 raise Http404("Forum thread not found")
913
914 for post in posts:
915 body_html = mark_safe(sanitize_html(_render_fossil_content(post.body, project_slug=slug))) if post.body else ""
916 rendered_posts.append({"post": post, "body_html": body_html})
917
918 has_write = can_write_project(request.user, project)
919
920 return render(
@@ -975,11 +976,11 @@
976
977 project, fossil_repo = _get_project_and_repo(slug, request, "write")
978
979 from fossil.forum import ForumPost as DjangoForumPost
980
981 parent = get_object_or_404(DjangoForumPost, pk=post_id, repository=fossil_repo, deleted_at__isnull=True)
982
983 # Determine the thread root
984 thread_root = parent.thread_root if parent.thread_root else parent
985
986 if request.method == "POST":
@@ -2233,11 +2234,11 @@
2234
2235 # Compute base_path for relative link resolution (e.g. "www/" for "www/concepts.wiki")
2236 doc_base = "/".join(doc_path.split("/")[:-1])
2237 if doc_base:
2238 doc_base += "/"
2239 content_html = mark_safe(sanitize_html(_render_fossil_content(content, project_slug=slug, base_path=doc_base)))
2240
2241 return render(
2242 request,
2243 "fossil/doc_page.html",
2244 {"project": project, "doc_path": doc_path, "content_html": content_html, "active_tab": "wiki"},
@@ -2533,11 +2534,11 @@
2534
2535 require_project_write(request, project)
2536
2537 body_html = ""
2538 if release.body:
2539 body_html = mark_safe(sanitize_html(md.markdown(release.body, extensions=["footnotes", "tables", "fenced_code"])))
2540
2541 assets = release.assets.filter(deleted_at__isnull=True)
2542 has_write = can_write_project(request.user, project)
2543 has_admin = can_admin_project(request.user, project)
2544
2545
+2 -1
--- pages/views.py
+++ pages/views.py
@@ -4,10 +4,11 @@
44
from django.http import HttpResponse
55
from django.shortcuts import get_object_or_404, redirect, render
66
from django.utils.safestring import mark_safe
77
88
from core.permissions import P
9
+from core.sanitize import sanitize_html
910
from organization.views import get_org
1011
1112
from .forms import PageForm
1213
from .models import Page
1314
@@ -52,11 +53,11 @@
5253
5354
@login_required
5455
def page_detail(request, slug):
5556
P.PAGE_VIEW.check(request.user)
5657
page = get_object_or_404(Page, slug=slug, deleted_at__isnull=True)
57
- content_html = mark_safe(markdown.markdown(page.content, extensions=["fenced_code", "tables", "toc"]))
58
+ content_html = mark_safe(sanitize_html(markdown.markdown(page.content, extensions=["fenced_code", "tables", "toc"])))
5859
return render(request, "pages/page_detail.html", {"page": page, "content_html": content_html})
5960
6061
6162
@login_required
6263
def page_update(request, slug):
6364
6465
ADDED tests/test_security.py
--- pages/views.py
+++ pages/views.py
@@ -4,10 +4,11 @@
4 from django.http import HttpResponse
5 from django.shortcuts import get_object_or_404, redirect, render
6 from django.utils.safestring import mark_safe
7
8 from core.permissions import P
 
9 from organization.views import get_org
10
11 from .forms import PageForm
12 from .models import Page
13
@@ -52,11 +53,11 @@
52
53 @login_required
54 def page_detail(request, slug):
55 P.PAGE_VIEW.check(request.user)
56 page = get_object_or_404(Page, slug=slug, deleted_at__isnull=True)
57 content_html = mark_safe(markdown.markdown(page.content, extensions=["fenced_code", "tables", "toc"]))
58 return render(request, "pages/page_detail.html", {"page": page, "content_html": content_html})
59
60
61 @login_required
62 def page_update(request, slug):
63
64 DDED tests/test_security.py
--- pages/views.py
+++ pages/views.py
@@ -4,10 +4,11 @@
4 from django.http import HttpResponse
5 from django.shortcuts import get_object_or_404, redirect, render
6 from django.utils.safestring import mark_safe
7
8 from core.permissions import P
9 from core.sanitize import sanitize_html
10 from organization.views import get_org
11
12 from .forms import PageForm
13 from .models import Page
14
@@ -52,11 +53,11 @@
53
54 @login_required
55 def page_detail(request, slug):
56 P.PAGE_VIEW.check(request.user)
57 page = get_object_or_404(Page, slug=slug, deleted_at__isnull=True)
58 content_html = mark_safe(sanitize_html(markdown.markdown(page.content, extensions=["fenced_code", "tables", "toc"])))
59 return render(request, "pages/page_detail.html", {"page": page, "content_html": content_html})
60
61
62 @login_required
63 def page_update(request, slug):
64
65 DDED tests/test_security.py
--- a/tests/test_security.py
+++ b/tests/test_security.py
@@ -0,0 +1,14 @@
1
+"""Security reenv(self):
2
+ """W""Security regression tests gression tests f."""
3
+ from fossil.cli import FossilCLI
4
+
5
+ cli = FossilCLI(binary="/usr/bin/false")
6
+ captured_env = {}
7
+
8
+ def capture_run(cmd, **kwargs):
9
+ captured_env.update(kwargs.get("env", {}))
10
+ return MagicMock(returncode=0, stdout="ok", stderr="")
11
+
12
+ with patch("subprocess.run", side_effect=capture_run):
13
+ cli.git_export(
14
+
--- a/tests/test_security.py
+++ b/tests/test_security.py
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_security.py
+++ b/tests/test_security.py
@@ -0,0 +1,14 @@
1 """Security reenv(self):
2 """W""Security regression tests gression tests f."""
3 from fossil.cli import FossilCLI
4
5 cli = FossilCLI(binary="/usr/bin/false")
6 captured_env = {}
7
8 def capture_run(cmd, **kwargs):
9 captured_env.update(kwargs.get("env", {}))
10 return MagicMock(returncode=0, stdout="ok", stderr="")
11
12 with patch("subprocess.run", side_effect=capture_run):
13 cli.git_export(
14

Keyboard Shortcuts

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