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.
911b0a4f791c4d49f5363fd34e410feca384157db4fc986c03761de33cb3edfe
| --- Dockerfile | ||
| +++ Dockerfile | ||
| @@ -46,11 +46,11 @@ | ||
| 46 | 46 | |
| 47 | 47 | # Create data directories |
| 48 | 48 | RUN mkdir -p /data/repos /data/trash /data/ssh |
| 49 | 49 | |
| 50 | 50 | # 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 \ | |
| 52 | 52 | && mkdir -p /run/sshd /home/fossil/.ssh \ |
| 53 | 53 | && chown fossil:fossil /home/fossil/.ssh \ |
| 54 | 54 | && chmod 700 /home/fossil/.ssh |
| 55 | 55 | |
| 56 | 56 | COPY docker/sshd_config /etc/ssh/sshd_config |
| 57 | 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 /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 | + | |
| 1 | 3 | from django.contrib import messages |
| 2 | 4 | from django.contrib.auth import login, logout |
| 3 | 5 | from django.contrib.auth.decorators import login_required |
| 4 | 6 | from django.http import HttpResponse |
| 5 | 7 | from django.shortcuts import get_object_or_404, redirect, render |
| 8 | +from django.utils.http import url_has_allowed_host_and_scheme | |
| 6 | 9 | from django.views.decorators.http import require_POST |
| 7 | 10 | from django_ratelimit.decorators import ratelimit |
| 8 | 11 | |
| 9 | 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, "" | |
| 10 | 49 | |
| 11 | 50 | |
| 12 | 51 | @ratelimit(key="ip", rate="10/m", block=True) |
| 13 | 52 | def login_view(request): |
| 14 | 53 | if request.user.is_authenticated: |
| @@ -16,12 +55,14 @@ | ||
| 16 | 55 | |
| 17 | 56 | if request.method == "POST": |
| 18 | 57 | form = LoginForm(request, data=request.POST) |
| 19 | 58 | if form.is_valid(): |
| 20 | 59 | 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") | |
| 23 | 64 | else: |
| 24 | 65 | form = LoginForm() |
| 25 | 66 | |
| 26 | 67 | return render(request, "accounts/login.html", {"form": form}) |
| 27 | 68 | |
| @@ -84,15 +125,20 @@ | ||
| 84 | 125 | |
| 85 | 126 | keys = UserSSHKey.objects.filter(deleted_at__isnull=True).select_related("user") |
| 86 | 127 | |
| 87 | 128 | lines = [] |
| 88 | 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 | |
| 89 | 135 | # Each key gets a forced command that identifies the user |
| 90 | 136 | forced_cmd = ( |
| 91 | 137 | f'command="/usr/local/bin/fossil-shell {key.user.username}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty' |
| 92 | 138 | ) |
| 93 | - lines.append(f"{forced_cmd} {key.public_key.strip()}") | |
| 139 | + lines.append(f"{forced_cmd} {clean_key}") | |
| 94 | 140 | |
| 95 | 141 | authorized_keys_path.write_text("\n".join(lines) + "\n" if lines else "") |
| 96 | 142 | authorized_keys_path.chmod(0o600) |
| 97 | 143 | |
| 98 | 144 | |
| @@ -106,17 +152,22 @@ | ||
| 106 | 152 | if request.method == "POST": |
| 107 | 153 | title = request.POST.get("title", "").strip() |
| 108 | 154 | public_key = request.POST.get("public_key", "").strip() |
| 109 | 155 | |
| 110 | 156 | 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) | |
| 113 | 164 | |
| 114 | 165 | UserSSHKey.objects.create( |
| 115 | 166 | user=request.user, |
| 116 | 167 | title=title, |
| 117 | - public_key=public_key, | |
| 168 | + public_key=sanitized_key, | |
| 118 | 169 | key_type=key_type, |
| 119 | 170 | fingerprint=fingerprint, |
| 120 | 171 | created_by=request.user, |
| 121 | 172 | ) |
| 122 | 173 | |
| 123 | 174 | |
| 124 | 175 | 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 @@ | ||
| 16 | 16 | PubkeyAuthentication yes |
| 17 | 17 | AuthorizedKeysFile /data/ssh/authorized_keys |
| 18 | 18 | |
| 19 | 19 | # Only allow the fossil user |
| 20 | 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 | |
| 21 | 25 | |
| 22 | 26 | # Disable everything except the sync protocol |
| 23 | 27 | PermitTunnel no |
| 24 | 28 | AllowTcpForwarding no |
| 25 | 29 | X11Forwarding no |
| 26 | 30 |
| --- 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 |
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -7,10 +7,11 @@ | ||
| 7 | 7 | from django.http import Http404, HttpResponse |
| 8 | 8 | from django.shortcuts import get_object_or_404, redirect, render |
| 9 | 9 | from django.utils.safestring import mark_safe |
| 10 | 10 | from django.views.decorators.csrf import csrf_exempt |
| 11 | 11 | |
| 12 | +from core.sanitize import sanitize_html | |
| 12 | 13 | from projects.models import Project |
| 13 | 14 | |
| 14 | 15 | from .models import FossilRepository |
| 15 | 16 | from .reader import FossilReader |
| 16 | 17 | |
| @@ -337,11 +338,11 @@ | ||
| 337 | 338 | with reader: |
| 338 | 339 | content_bytes = reader.get_file_content(f.uuid) |
| 339 | 340 | try: |
| 340 | 341 | readme_content = content_bytes.decode("utf-8") |
| 341 | 342 | 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))) | |
| 343 | 344 | except (UnicodeDecodeError, Exception): |
| 344 | 345 | pass |
| 345 | 346 | break |
| 346 | 347 | if readme_html: |
| 347 | 348 | break |
| @@ -420,11 +421,11 @@ | ||
| 420 | 421 | rendered_html = "" |
| 421 | 422 | if can_render and view_mode == "rendered" and not is_binary: |
| 422 | 423 | doc_base = "/".join(filepath.split("/")[:-1]) |
| 423 | 424 | if doc_base: |
| 424 | 425 | 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))) | |
| 426 | 427 | |
| 427 | 428 | return render( |
| 428 | 429 | request, |
| 429 | 430 | "fossil/code_file.html", |
| 430 | 431 | { |
| @@ -717,18 +718,18 @@ | ||
| 717 | 718 | comments = reader.get_ticket_comments(ticket_uuid) if ticket else [] |
| 718 | 719 | |
| 719 | 720 | if not ticket: |
| 720 | 721 | raise Http404("Ticket not found") |
| 721 | 722 | |
| 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 "" | |
| 723 | 724 | rendered_comments = [] |
| 724 | 725 | for c in comments: |
| 725 | 726 | rendered_comments.append( |
| 726 | 727 | { |
| 727 | 728 | "user": c["user"], |
| 728 | 729 | "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))), | |
| 730 | 731 | } |
| 731 | 732 | ) |
| 732 | 733 | |
| 733 | 734 | return render( |
| 734 | 735 | request, |
| @@ -754,11 +755,11 @@ | ||
| 754 | 755 | pages = reader.get_wiki_pages() |
| 755 | 756 | home_page = reader.get_wiki_page("Home") |
| 756 | 757 | |
| 757 | 758 | home_content_html = "" |
| 758 | 759 | 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))) | |
| 760 | 761 | |
| 761 | 762 | return render( |
| 762 | 763 | request, |
| 763 | 764 | "fossil/wiki_list.html", |
| 764 | 765 | { |
| @@ -780,11 +781,11 @@ | ||
| 780 | 781 | all_pages = reader.get_wiki_pages() |
| 781 | 782 | |
| 782 | 783 | if not page: |
| 783 | 784 | raise Http404(f"Wiki page not found: {page_name}") |
| 784 | 785 | |
| 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))) | |
| 786 | 787 | |
| 787 | 788 | return render( |
| 788 | 789 | request, |
| 789 | 790 | "fossil/wiki_page.html", |
| 790 | 791 | { |
| @@ -862,21 +863,21 @@ | ||
| 862 | 863 | # Check if this is a Fossil-native thread or a Django-backed thread |
| 863 | 864 | is_django_thread = False |
| 864 | 865 | from fossil.forum import ForumPost as DjangoForumPost |
| 865 | 866 | |
| 866 | 867 | try: |
| 867 | - django_root = DjangoForumPost.objects.get(pk=int(thread_uuid)) | |
| 868 | + django_root = DjangoForumPost.objects.get(pk=int(thread_uuid), repository=fossil_repo) | |
| 868 | 869 | is_django_thread = True |
| 869 | 870 | except (ValueError, DjangoForumPost.DoesNotExist): |
| 870 | 871 | django_root = None |
| 871 | 872 | |
| 872 | 873 | rendered_posts = [] |
| 873 | 874 | |
| 874 | 875 | if is_django_thread: |
| 875 | 876 | # Django-backed thread: root + replies |
| 876 | 877 | 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 "" | |
| 878 | 879 | rendered_posts.append( |
| 879 | 880 | { |
| 880 | 881 | "post": { |
| 881 | 882 | "user": root.created_by.username if root.created_by else "", |
| 882 | 883 | "title": root.title, |
| @@ -885,11 +886,11 @@ | ||
| 885 | 886 | }, |
| 886 | 887 | "body_html": body_html, |
| 887 | 888 | } |
| 888 | 889 | ) |
| 889 | 890 | 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 "" | |
| 891 | 892 | rendered_posts.append( |
| 892 | 893 | { |
| 893 | 894 | "post": { |
| 894 | 895 | "user": reply.created_by.username if reply.created_by else "", |
| 895 | 896 | "title": "", |
| @@ -909,11 +910,11 @@ | ||
| 909 | 910 | |
| 910 | 911 | if not posts: |
| 911 | 912 | raise Http404("Forum thread not found") |
| 912 | 913 | |
| 913 | 914 | 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 "" | |
| 915 | 916 | rendered_posts.append({"post": post, "body_html": body_html}) |
| 916 | 917 | |
| 917 | 918 | has_write = can_write_project(request.user, project) |
| 918 | 919 | |
| 919 | 920 | return render( |
| @@ -975,11 +976,11 @@ | ||
| 975 | 976 | |
| 976 | 977 | project, fossil_repo = _get_project_and_repo(slug, request, "write") |
| 977 | 978 | |
| 978 | 979 | from fossil.forum import ForumPost as DjangoForumPost |
| 979 | 980 | |
| 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) | |
| 981 | 982 | |
| 982 | 983 | # Determine the thread root |
| 983 | 984 | thread_root = parent.thread_root if parent.thread_root else parent |
| 984 | 985 | |
| 985 | 986 | if request.method == "POST": |
| @@ -2233,11 +2234,11 @@ | ||
| 2233 | 2234 | |
| 2234 | 2235 | # Compute base_path for relative link resolution (e.g. "www/" for "www/concepts.wiki") |
| 2235 | 2236 | doc_base = "/".join(doc_path.split("/")[:-1]) |
| 2236 | 2237 | if doc_base: |
| 2237 | 2238 | 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))) | |
| 2239 | 2240 | |
| 2240 | 2241 | return render( |
| 2241 | 2242 | request, |
| 2242 | 2243 | "fossil/doc_page.html", |
| 2243 | 2244 | {"project": project, "doc_path": doc_path, "content_html": content_html, "active_tab": "wiki"}, |
| @@ -2533,11 +2534,11 @@ | ||
| 2533 | 2534 | |
| 2534 | 2535 | require_project_write(request, project) |
| 2535 | 2536 | |
| 2536 | 2537 | body_html = "" |
| 2537 | 2538 | 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"]))) | |
| 2539 | 2540 | |
| 2540 | 2541 | assets = release.assets.filter(deleted_at__isnull=True) |
| 2541 | 2542 | has_write = can_write_project(request.user, project) |
| 2542 | 2543 | has_admin = can_admin_project(request.user, project) |
| 2543 | 2544 | |
| 2544 | 2545 |
| --- 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 |
| --- pages/views.py | ||
| +++ pages/views.py | ||
| @@ -4,10 +4,11 @@ | ||
| 4 | 4 | from django.http import HttpResponse |
| 5 | 5 | from django.shortcuts import get_object_or_404, redirect, render |
| 6 | 6 | from django.utils.safestring import mark_safe |
| 7 | 7 | |
| 8 | 8 | from core.permissions import P |
| 9 | +from core.sanitize import sanitize_html | |
| 9 | 10 | from organization.views import get_org |
| 10 | 11 | |
| 11 | 12 | from .forms import PageForm |
| 12 | 13 | from .models import Page |
| 13 | 14 | |
| @@ -52,11 +53,11 @@ | ||
| 52 | 53 | |
| 53 | 54 | @login_required |
| 54 | 55 | def page_detail(request, slug): |
| 55 | 56 | P.PAGE_VIEW.check(request.user) |
| 56 | 57 | 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"]))) | |
| 58 | 59 | return render(request, "pages/page_detail.html", {"page": page, "content_html": content_html}) |
| 59 | 60 | |
| 60 | 61 | |
| 61 | 62 | @login_required |
| 62 | 63 | def page_update(request, slug): |
| 63 | 64 | |
| 64 | 65 | 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 |