FossilRepo
Fix fossil http proxy: use raw HTTP request on stdin instead of CGI env vars
Commit
7d099f36f18de78311efe0fbaacef8ede8d6d58e57facb815bf8e30102e98ff5
Parent
313537cc4cf40a7…
8 files changed
+18
-16
+1
-1
+1
-1
+2
-2
+2
-2
+11
-5
~
fossil/__pycache__/cli.cpython-314.pyc
~
fossil/cli.py
~
templates/fossil/branch_list.html
~
templates/organization/role_detail.html
~
templates/organization/user_detail.html
~
templates/projects/project_detail.html
~
tests/__pycache__/test_security.cpython-314-pytest-9.0.2.pyc
~
tests/test_security.py
| --- fossil/__pycache__/cli.cpython-314.pyc | ||
| +++ fossil/__pycache__/cli.cpython-314.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- fossil/__pycache__/cli.cpython-314.pyc | |
| +++ fossil/__pycache__/cli.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- fossil/__pycache__/cli.cpython-314.pyc | |
| +++ fossil/__pycache__/cli.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
+18
-16
| --- fossil/cli.py | ||
| +++ fossil/cli.py | ||
| @@ -389,44 +389,46 @@ | ||
| 389 | 389 | except Exception as e: |
| 390 | 390 | return {"success": False, "public_key": "", "fingerprint": "", "error": str(e)} |
| 391 | 391 | return {"success": False, "public_key": "", "fingerprint": ""} |
| 392 | 392 | |
| 393 | 393 | def http_proxy(self, repo_path: Path, request_body: bytes, content_type: str = "", localauth: bool = True) -> tuple[bytes, str]: |
| 394 | - """Proxy a single Fossil HTTP sync request via CGI mode. | |
| 394 | + """Proxy a single Fossil HTTP sync request. | |
| 395 | 395 | |
| 396 | - Runs ``fossil http <repo_path>`` with the request piped to stdin. | |
| 397 | - Fossil writes a full HTTP response (headers + body) to stdout; | |
| 398 | - we split the two apart and return (response_body, response_content_type). | |
| 396 | + Runs ``fossil http <repo_path>`` with a full HTTP request on stdin. | |
| 397 | + Fossil reads the HTTP method line + headers + body from stdin and | |
| 398 | + writes a full HTTP response (headers + body) to stdout. | |
| 399 | 399 | |
| 400 | 400 | When *localauth* is True, ``--localauth`` grants full push permissions. |
| 401 | 401 | When False, only anonymous pull/clone is allowed (for public repos). |
| 402 | 402 | """ |
| 403 | 403 | import os |
| 404 | 404 | |
| 405 | 405 | env = { |
| 406 | 406 | **os.environ, |
| 407 | 407 | **{k: v for k, v in self._env.items() if k not in os.environ or k == "USER"}, |
| 408 | - "REQUEST_METHOD": "POST", | |
| 409 | - "CONTENT_TYPE": content_type, | |
| 410 | - "CONTENT_LENGTH": str(len(request_body)), | |
| 411 | - "PATH_INFO": "/xfer", | |
| 412 | - "SCRIPT_NAME": "", | |
| 413 | - "HTTP_HOST": "localhost", | |
| 414 | - "SERVER_PROTOCOL": "HTTP/1.1", | |
| 415 | - } | |
| 416 | - # Do NOT set GATEWAY_INTERFACE — it causes fossil to auto-enter CGI | |
| 417 | - # mode, treating the "http" subcommand as a repository path. | |
| 418 | - env.pop("GATEWAY_INTERFACE", None) | |
| 408 | + } | |
| 409 | + # Ensure GATEWAY_INTERFACE is NOT set — it triggers CGI auto-detect | |
| 410 | + # which conflicts with the explicit "http" subcommand. | |
| 411 | + env.pop("GATEWAY_INTERFACE", None) | |
| 412 | + | |
| 413 | + # Build a raw HTTP request for fossil http's stdin | |
| 414 | + http_request = ( | |
| 415 | + f"POST /xfer HTTP/1.1\r\n" | |
| 416 | + f"Host: localhost\r\n" | |
| 417 | + f"Content-Type: {content_type or 'application/x-fossil'}\r\n" | |
| 418 | + f"Content-Length: {len(request_body)}\r\n" | |
| 419 | + f"\r\n" | |
| 420 | + ).encode() + request_body | |
| 419 | 421 | |
| 420 | 422 | cmd = [self.binary, "http", str(repo_path)] |
| 421 | 423 | if localauth: |
| 422 | 424 | cmd.append("--localauth") |
| 423 | 425 | |
| 424 | 426 | try: |
| 425 | 427 | result = subprocess.run( |
| 426 | 428 | cmd, |
| 427 | - input=request_body, | |
| 429 | + input=http_request, | |
| 428 | 430 | capture_output=True, |
| 429 | 431 | timeout=120, |
| 430 | 432 | env=env, |
| 431 | 433 | ) |
| 432 | 434 | except subprocess.TimeoutExpired: |
| 433 | 435 |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -389,44 +389,46 @@ | |
| 389 | except Exception as e: |
| 390 | return {"success": False, "public_key": "", "fingerprint": "", "error": str(e)} |
| 391 | return {"success": False, "public_key": "", "fingerprint": ""} |
| 392 | |
| 393 | def http_proxy(self, repo_path: Path, request_body: bytes, content_type: str = "", localauth: bool = True) -> tuple[bytes, str]: |
| 394 | """Proxy a single Fossil HTTP sync request via CGI mode. |
| 395 | |
| 396 | Runs ``fossil http <repo_path>`` with the request piped to stdin. |
| 397 | Fossil writes a full HTTP response (headers + body) to stdout; |
| 398 | we split the two apart and return (response_body, response_content_type). |
| 399 | |
| 400 | When *localauth* is True, ``--localauth`` grants full push permissions. |
| 401 | When False, only anonymous pull/clone is allowed (for public repos). |
| 402 | """ |
| 403 | import os |
| 404 | |
| 405 | env = { |
| 406 | **os.environ, |
| 407 | **{k: v for k, v in self._env.items() if k not in os.environ or k == "USER"}, |
| 408 | "REQUEST_METHOD": "POST", |
| 409 | "CONTENT_TYPE": content_type, |
| 410 | "CONTENT_LENGTH": str(len(request_body)), |
| 411 | "PATH_INFO": "/xfer", |
| 412 | "SCRIPT_NAME": "", |
| 413 | "HTTP_HOST": "localhost", |
| 414 | "SERVER_PROTOCOL": "HTTP/1.1", |
| 415 | } |
| 416 | # Do NOT set GATEWAY_INTERFACE — it causes fossil to auto-enter CGI |
| 417 | # mode, treating the "http" subcommand as a repository path. |
| 418 | env.pop("GATEWAY_INTERFACE", None) |
| 419 | |
| 420 | cmd = [self.binary, "http", str(repo_path)] |
| 421 | if localauth: |
| 422 | cmd.append("--localauth") |
| 423 | |
| 424 | try: |
| 425 | result = subprocess.run( |
| 426 | cmd, |
| 427 | input=request_body, |
| 428 | capture_output=True, |
| 429 | timeout=120, |
| 430 | env=env, |
| 431 | ) |
| 432 | except subprocess.TimeoutExpired: |
| 433 |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -389,44 +389,46 @@ | |
| 389 | except Exception as e: |
| 390 | return {"success": False, "public_key": "", "fingerprint": "", "error": str(e)} |
| 391 | return {"success": False, "public_key": "", "fingerprint": ""} |
| 392 | |
| 393 | def http_proxy(self, repo_path: Path, request_body: bytes, content_type: str = "", localauth: bool = True) -> tuple[bytes, str]: |
| 394 | """Proxy a single Fossil HTTP sync request. |
| 395 | |
| 396 | Runs ``fossil http <repo_path>`` with a full HTTP request on stdin. |
| 397 | Fossil reads the HTTP method line + headers + body from stdin and |
| 398 | writes a full HTTP response (headers + body) to stdout. |
| 399 | |
| 400 | When *localauth* is True, ``--localauth`` grants full push permissions. |
| 401 | When False, only anonymous pull/clone is allowed (for public repos). |
| 402 | """ |
| 403 | import os |
| 404 | |
| 405 | env = { |
| 406 | **os.environ, |
| 407 | **{k: v for k, v in self._env.items() if k not in os.environ or k == "USER"}, |
| 408 | } |
| 409 | # Ensure GATEWAY_INTERFACE is NOT set — it triggers CGI auto-detect |
| 410 | # which conflicts with the explicit "http" subcommand. |
| 411 | env.pop("GATEWAY_INTERFACE", None) |
| 412 | |
| 413 | # Build a raw HTTP request for fossil http's stdin |
| 414 | http_request = ( |
| 415 | f"POST /xfer HTTP/1.1\r\n" |
| 416 | f"Host: localhost\r\n" |
| 417 | f"Content-Type: {content_type or 'application/x-fossil'}\r\n" |
| 418 | f"Content-Length: {len(request_body)}\r\n" |
| 419 | f"\r\n" |
| 420 | ).encode() + request_body |
| 421 | |
| 422 | cmd = [self.binary, "http", str(repo_path)] |
| 423 | if localauth: |
| 424 | cmd.append("--localauth") |
| 425 | |
| 426 | try: |
| 427 | result = subprocess.run( |
| 428 | cmd, |
| 429 | input=http_request, |
| 430 | capture_output=True, |
| 431 | timeout=120, |
| 432 | env=env, |
| 433 | ) |
| 434 | except subprocess.TimeoutExpired: |
| 435 |
| --- templates/fossil/branch_list.html | ||
| +++ templates/fossil/branch_list.html | ||
| @@ -25,11 +25,11 @@ | ||
| 25 | 25 | </span> |
| 26 | 26 | </div> |
| 27 | 27 | </div> |
| 28 | 28 | |
| 29 | 29 | <div id="branch-content"> |
| 30 | -<div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm overflow-x-auto"> | |
| 30 | +<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 31 | 31 | <table class="min-w-full divide-y divide-gray-700"> |
| 32 | 32 | <thead class="bg-gray-900"> |
| 33 | 33 | <tr> |
| 34 | 34 | <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-400">Branch</th> |
| 35 | 35 | <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-400">Last Checkin</th> |
| 36 | 36 |
| --- templates/fossil/branch_list.html | |
| +++ templates/fossil/branch_list.html | |
| @@ -25,11 +25,11 @@ | |
| 25 | </span> |
| 26 | </div> |
| 27 | </div> |
| 28 | |
| 29 | <div id="branch-content"> |
| 30 | <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm overflow-x-auto"> |
| 31 | <table class="min-w-full divide-y divide-gray-700"> |
| 32 | <thead class="bg-gray-900"> |
| 33 | <tr> |
| 34 | <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-400">Branch</th> |
| 35 | <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-400">Last Checkin</th> |
| 36 |
| --- templates/fossil/branch_list.html | |
| +++ templates/fossil/branch_list.html | |
| @@ -25,11 +25,11 @@ | |
| 25 | </span> |
| 26 | </div> |
| 27 | </div> |
| 28 | |
| 29 | <div id="branch-content"> |
| 30 | <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 31 | <table class="min-w-full divide-y divide-gray-700"> |
| 32 | <thead class="bg-gray-900"> |
| 33 | <tr> |
| 34 | <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-400">Branch</th> |
| 35 | <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-400">Last Checkin</th> |
| 36 |
| --- templates/organization/role_detail.html | ||
| +++ templates/organization/role_detail.html | ||
| @@ -76,11 +76,11 @@ | ||
| 76 | 76 | {% endif %} |
| 77 | 77 | </div> |
| 78 | 78 | |
| 79 | 79 | <div class="mt-8"> |
| 80 | 80 | <h2 class="text-lg font-semibold text-gray-100 mb-4">Members with this Role</h2> |
| 81 | - <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 81 | + <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 82 | 82 | <table class="min-w-full divide-y divide-gray-700"> |
| 83 | 83 | <thead class="bg-gray-900"> |
| 84 | 84 | <tr> |
| 85 | 85 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Username</th> |
| 86 | 86 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Email</th> |
| 87 | 87 |
| --- templates/organization/role_detail.html | |
| +++ templates/organization/role_detail.html | |
| @@ -76,11 +76,11 @@ | |
| 76 | {% endif %} |
| 77 | </div> |
| 78 | |
| 79 | <div class="mt-8"> |
| 80 | <h2 class="text-lg font-semibold text-gray-100 mb-4">Members with this Role</h2> |
| 81 | <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 82 | <table class="min-w-full divide-y divide-gray-700"> |
| 83 | <thead class="bg-gray-900"> |
| 84 | <tr> |
| 85 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Username</th> |
| 86 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Email</th> |
| 87 |
| --- templates/organization/role_detail.html | |
| +++ templates/organization/role_detail.html | |
| @@ -76,11 +76,11 @@ | |
| 76 | {% endif %} |
| 77 | </div> |
| 78 | |
| 79 | <div class="mt-8"> |
| 80 | <h2 class="text-lg font-semibold text-gray-100 mb-4">Members with this Role</h2> |
| 81 | <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 82 | <table class="min-w-full divide-y divide-gray-700"> |
| 83 | <thead class="bg-gray-900"> |
| 84 | <tr> |
| 85 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Username</th> |
| 86 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Email</th> |
| 87 |
| --- templates/organization/user_detail.html | ||
| +++ templates/organization/user_detail.html | ||
| @@ -86,11 +86,11 @@ | ||
| 86 | 86 | </div> |
| 87 | 87 | </div> |
| 88 | 88 | |
| 89 | 89 | <div class="mt-8"> |
| 90 | 90 | <h2 class="text-lg font-semibold text-gray-100 mb-4">Team Memberships</h2> |
| 91 | - <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 91 | + <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 92 | 92 | <table class="min-w-full divide-y divide-gray-700"> |
| 93 | 93 | <thead class="bg-gray-900"> |
| 94 | 94 | <tr> |
| 95 | 95 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Team</th> |
| 96 | 96 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Description</th> |
| @@ -114,11 +114,11 @@ | ||
| 114 | 114 | </div> |
| 115 | 115 | </div> |
| 116 | 116 | |
| 117 | 117 | <div class="mt-8"> |
| 118 | 118 | <h2 class="text-lg font-semibold text-gray-100 mb-4">SSH Keys</h2> |
| 119 | - <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 119 | + <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> | |
| 120 | 120 | <table class="min-w-full divide-y divide-gray-700"> |
| 121 | 121 | <thead class="bg-gray-900"> |
| 122 | 122 | <tr> |
| 123 | 123 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Title</th> |
| 124 | 124 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Type</th> |
| 125 | 125 |
| --- templates/organization/user_detail.html | |
| +++ templates/organization/user_detail.html | |
| @@ -86,11 +86,11 @@ | |
| 86 | </div> |
| 87 | </div> |
| 88 | |
| 89 | <div class="mt-8"> |
| 90 | <h2 class="text-lg font-semibold text-gray-100 mb-4">Team Memberships</h2> |
| 91 | <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 92 | <table class="min-w-full divide-y divide-gray-700"> |
| 93 | <thead class="bg-gray-900"> |
| 94 | <tr> |
| 95 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Team</th> |
| 96 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Description</th> |
| @@ -114,11 +114,11 @@ | |
| 114 | </div> |
| 115 | </div> |
| 116 | |
| 117 | <div class="mt-8"> |
| 118 | <h2 class="text-lg font-semibold text-gray-100 mb-4">SSH Keys</h2> |
| 119 | <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 120 | <table class="min-w-full divide-y divide-gray-700"> |
| 121 | <thead class="bg-gray-900"> |
| 122 | <tr> |
| 123 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Title</th> |
| 124 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Type</th> |
| 125 |
| --- templates/organization/user_detail.html | |
| +++ templates/organization/user_detail.html | |
| @@ -86,11 +86,11 @@ | |
| 86 | </div> |
| 87 | </div> |
| 88 | |
| 89 | <div class="mt-8"> |
| 90 | <h2 class="text-lg font-semibold text-gray-100 mb-4">Team Memberships</h2> |
| 91 | <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 92 | <table class="min-w-full divide-y divide-gray-700"> |
| 93 | <thead class="bg-gray-900"> |
| 94 | <tr> |
| 95 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Team</th> |
| 96 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Description</th> |
| @@ -114,11 +114,11 @@ | |
| 114 | </div> |
| 115 | </div> |
| 116 | |
| 117 | <div class="mt-8"> |
| 118 | <h2 class="text-lg font-semibold text-gray-100 mb-4">SSH Keys</h2> |
| 119 | <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 120 | <table class="min-w-full divide-y divide-gray-700"> |
| 121 | <thead class="bg-gray-900"> |
| 122 | <tr> |
| 123 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Title</th> |
| 124 | <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Type</th> |
| 125 |
| --- templates/projects/project_detail.html | ||
| +++ templates/projects/project_detail.html | ||
| @@ -143,12 +143,12 @@ | ||
| 143 | 143 | |
| 144 | 144 | <!-- Clone instructions --> |
| 145 | 145 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4" x-data="{ copied: false }"> |
| 146 | 146 | <h3 class="text-sm font-medium text-gray-300 mb-2">Clone</h3> |
| 147 | 147 | <div class="flex items-center gap-2"> |
| 148 | - <code class="flex-1 text-xs font-mono text-gray-400 bg-gray-900 rounded px-3 py-2 truncate">fossil clone /path/to/{{ project.slug }}.fossil</code> | |
| 149 | - <button @click="navigator.clipboard.writeText('fossil clone /path/to/{{ project.slug }}.fossil'); copied = true; setTimeout(() => copied = false, 1500)" | |
| 148 | + <code class="flex-1 text-xs font-mono text-gray-400 bg-gray-900 rounded px-3 py-2 truncate">fossil clone {{ request.scheme }}://{{ request.get_host }}/projects/{{ project.slug }}/fossil/xfer {{ project.slug }}.fossil</code> | |
| 149 | + <button @click="navigator.clipboard.writeText('fossil clone {{ request.scheme }}://{{ request.get_host }}/projects/{{ project.slug }}/fossil/xfer {{ project.slug }}.fossil'); copied = true; setTimeout(() => copied = false, 1500)" | |
| 150 | 150 | class="flex-shrink-0 rounded px-2 py-2 text-gray-500 hover:text-brand-light hover:bg-gray-700"> |
| 151 | 151 | <svg x-show="!copied" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" /></svg> |
| 152 | 152 | <svg x-show="copied" class="h-4 w-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="display:none"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg> |
| 153 | 153 | </button> |
| 154 | 154 | </div> |
| 155 | 155 |
| --- templates/projects/project_detail.html | |
| +++ templates/projects/project_detail.html | |
| @@ -143,12 +143,12 @@ | |
| 143 | |
| 144 | <!-- Clone instructions --> |
| 145 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4" x-data="{ copied: false }"> |
| 146 | <h3 class="text-sm font-medium text-gray-300 mb-2">Clone</h3> |
| 147 | <div class="flex items-center gap-2"> |
| 148 | <code class="flex-1 text-xs font-mono text-gray-400 bg-gray-900 rounded px-3 py-2 truncate">fossil clone /path/to/{{ project.slug }}.fossil</code> |
| 149 | <button @click="navigator.clipboard.writeText('fossil clone /path/to/{{ project.slug }}.fossil'); copied = true; setTimeout(() => copied = false, 1500)" |
| 150 | class="flex-shrink-0 rounded px-2 py-2 text-gray-500 hover:text-brand-light hover:bg-gray-700"> |
| 151 | <svg x-show="!copied" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" /></svg> |
| 152 | <svg x-show="copied" class="h-4 w-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="display:none"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg> |
| 153 | </button> |
| 154 | </div> |
| 155 |
| --- templates/projects/project_detail.html | |
| +++ templates/projects/project_detail.html | |
| @@ -143,12 +143,12 @@ | |
| 143 | |
| 144 | <!-- Clone instructions --> |
| 145 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4" x-data="{ copied: false }"> |
| 146 | <h3 class="text-sm font-medium text-gray-300 mb-2">Clone</h3> |
| 147 | <div class="flex items-center gap-2"> |
| 148 | <code class="flex-1 text-xs font-mono text-gray-400 bg-gray-900 rounded px-3 py-2 truncate">fossil clone {{ request.scheme }}://{{ request.get_host }}/projects/{{ project.slug }}/fossil/xfer {{ project.slug }}.fossil</code> |
| 149 | <button @click="navigator.clipboard.writeText('fossil clone {{ request.scheme }}://{{ request.get_host }}/projects/{{ project.slug }}/fossil/xfer {{ project.slug }}.fossil'); copied = true; setTimeout(() => copied = false, 1500)" |
| 150 | class="flex-shrink-0 rounded px-2 py-2 text-gray-500 hover:text-brand-light hover:bg-gray-700"> |
| 151 | <svg x-show="!copied" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" /></svg> |
| 152 | <svg x-show="copied" class="h-4 w-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="display:none"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg> |
| 153 | </button> |
| 154 | </div> |
| 155 |
| --- tests/__pycache__/test_security.cpython-314-pytest-9.0.2.pyc | ||
| +++ tests/__pycache__/test_security.cpython-314-pytest-9.0.2.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- tests/__pycache__/test_security.cpython-314-pytest-9.0.2.pyc | |
| +++ tests/__pycache__/test_security.cpython-314-pytest-9.0.2.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- tests/__pycache__/test_security.cpython-314-pytest-9.0.2.pyc | |
| +++ tests/__pycache__/test_security.cpython-314-pytest-9.0.2.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
+11
-5
| --- tests/test_security.py | ||
| +++ tests/test_security.py | ||
| @@ -205,12 +205,14 @@ | ||
| 205 | 205 | cmd_str = " ".join(captured_cmd) |
| 206 | 206 | assert "ghp_s3cretTOKEN123" not in cmd_str |
| 207 | 207 | # URL should not have token embedded |
| 208 | 208 | assert "ghp_s3cretTOKEN123@" not in cmd_str |
| 209 | 209 | |
| 210 | - def test_token_passed_via_env(self): | |
| 211 | - """When auth_token is provided, git credential helper is configured via env.""" | |
| 210 | + def test_token_passed_via_askpass(self): | |
| 211 | + """When auth_token is provided, GIT_ASKPASS is configured and token is in a separate file.""" | |
| 212 | + import os | |
| 213 | + | |
| 212 | 214 | from fossil.cli import FossilCLI |
| 213 | 215 | |
| 214 | 216 | cli = FossilCLI(binary="/usr/bin/false") |
| 215 | 217 | captured_env = {} |
| 216 | 218 | |
| @@ -225,13 +227,17 @@ | ||
| 225 | 227 | autopush_url="https://github.com/user/repo.git", |
| 226 | 228 | auth_token="ghp_s3cretTOKEN123", |
| 227 | 229 | ) |
| 228 | 230 | |
| 229 | 231 | assert captured_env.get("GIT_TERMINAL_PROMPT") == "0" |
| 230 | - assert captured_env.get("GIT_CONFIG_COUNT") == "1" | |
| 231 | - assert captured_env.get("GIT_CONFIG_KEY_0") == "credential.helper" | |
| 232 | - assert "ghp_s3cretTOKEN123" in captured_env.get("GIT_CONFIG_VALUE_0", "") | |
| 232 | + # Uses GIT_ASKPASS instead of shell credential helper | |
| 233 | + askpass_path = captured_env.get("GIT_ASKPASS") | |
| 234 | + assert askpass_path is not None | |
| 235 | + # Temp files are cleaned up after git_export returns | |
| 236 | + assert not os.path.exists(askpass_path) | |
| 237 | + # No shell credential helper should be set | |
| 238 | + assert "GIT_CONFIG_COUNT" not in captured_env | |
| 233 | 239 | |
| 234 | 240 | def test_token_redacted_from_output(self): |
| 235 | 241 | """If the token somehow leaks into Fossil/Git stdout, it is scrubbed.""" |
| 236 | 242 | from fossil.cli import FossilCLI |
| 237 | 243 | |
| 238 | 244 |
| --- tests/test_security.py | |
| +++ tests/test_security.py | |
| @@ -205,12 +205,14 @@ | |
| 205 | cmd_str = " ".join(captured_cmd) |
| 206 | assert "ghp_s3cretTOKEN123" not in cmd_str |
| 207 | # URL should not have token embedded |
| 208 | assert "ghp_s3cretTOKEN123@" not in cmd_str |
| 209 | |
| 210 | def test_token_passed_via_env(self): |
| 211 | """When auth_token is provided, git credential helper is configured via env.""" |
| 212 | from fossil.cli import FossilCLI |
| 213 | |
| 214 | cli = FossilCLI(binary="/usr/bin/false") |
| 215 | captured_env = {} |
| 216 | |
| @@ -225,13 +227,17 @@ | |
| 225 | autopush_url="https://github.com/user/repo.git", |
| 226 | auth_token="ghp_s3cretTOKEN123", |
| 227 | ) |
| 228 | |
| 229 | assert captured_env.get("GIT_TERMINAL_PROMPT") == "0" |
| 230 | assert captured_env.get("GIT_CONFIG_COUNT") == "1" |
| 231 | assert captured_env.get("GIT_CONFIG_KEY_0") == "credential.helper" |
| 232 | assert "ghp_s3cretTOKEN123" in captured_env.get("GIT_CONFIG_VALUE_0", "") |
| 233 | |
| 234 | def test_token_redacted_from_output(self): |
| 235 | """If the token somehow leaks into Fossil/Git stdout, it is scrubbed.""" |
| 236 | from fossil.cli import FossilCLI |
| 237 | |
| 238 |
| --- tests/test_security.py | |
| +++ tests/test_security.py | |
| @@ -205,12 +205,14 @@ | |
| 205 | cmd_str = " ".join(captured_cmd) |
| 206 | assert "ghp_s3cretTOKEN123" not in cmd_str |
| 207 | # URL should not have token embedded |
| 208 | assert "ghp_s3cretTOKEN123@" not in cmd_str |
| 209 | |
| 210 | def test_token_passed_via_askpass(self): |
| 211 | """When auth_token is provided, GIT_ASKPASS is configured and token is in a separate file.""" |
| 212 | import os |
| 213 | |
| 214 | from fossil.cli import FossilCLI |
| 215 | |
| 216 | cli = FossilCLI(binary="/usr/bin/false") |
| 217 | captured_env = {} |
| 218 | |
| @@ -225,13 +227,17 @@ | |
| 227 | autopush_url="https://github.com/user/repo.git", |
| 228 | auth_token="ghp_s3cretTOKEN123", |
| 229 | ) |
| 230 | |
| 231 | assert captured_env.get("GIT_TERMINAL_PROMPT") == "0" |
| 232 | # Uses GIT_ASKPASS instead of shell credential helper |
| 233 | askpass_path = captured_env.get("GIT_ASKPASS") |
| 234 | assert askpass_path is not None |
| 235 | # Temp files are cleaned up after git_export returns |
| 236 | assert not os.path.exists(askpass_path) |
| 237 | # No shell credential helper should be set |
| 238 | assert "GIT_CONFIG_COUNT" not in captured_env |
| 239 | |
| 240 | def test_token_redacted_from_output(self): |
| 241 | """If the token somehow leaks into Fossil/Git stdout, it is scrubbed.""" |
| 242 | from fossil.cli import FossilCLI |
| 243 | |
| 244 |