fix(issues): the preview must not ask the tracker for a repository context

The composer's preview did not fail a call — it killed the Gitea process, and
with it the git server and the container registry. `POST /api/v1/markdown` with
`{"Mode": "gfm", "Context": "<repo>"}` makes Gitea resolve every hex-shaped word
as a commit link, and on the API's render path the commit checker it asks is nil:

  panic: runtime error: invalid memory address or nil pointer dereference
  renderhelper.(*commitChecker).IsCommitIDExisting(0x0)
   <- markup.hashCurrentPatternProcessor (html_commit.go:217)

Every report carries hex — a tab id, a request id — so this was not an edge case
but the only case: the preview answered "the tracker isn't answering" every time,
because the tracker had just died.

Measured on throwaway instances, not argued. Same body, same call:
  gfm + Context + "5645b1c0ae01"  -> 1.26.4 CRASH, 1.27.1 CRASH, 1.27.3 200
  gfm + Context, no hex           -> 200
  gfm, no Context                 -> 200, and the HTML keeps its tables, task
                                     lists and chroma-highlighted code blocks
Upstream fixed it in 1.27.2 ("fix: avoid markup render panic", #38698), but a
self-hoster runs whatever version they run, so the payload is what gets fixed.

Sending was never affected: the created issue's own page renders the same body
fine (measured) — a real repo context has a real commit checker. The only loss is
that a hash or a `#123` is not yet a link in the preview; the issue page links it.

The test pins the payload dict, because the defect is in what we send.
This commit is contained in:
2026-09-20 07:51:08 +02:00
parent 8142484347
commit 05ab963951
2 changed files with 33 additions and 4 deletions
+17 -4
View File
@@ -3,7 +3,7 @@ before a line of this was written (plans/e16-debug-issue.md, S0).
Configured by an admin (sysconfig `issues_gitea_url` / `_repo` / `_token`, the token encrypted at Configured by an admin (sysconfig `issues_gitea_url` / `_repo` / `_token`, the token encrypted at
rest). The repository must be PRIVATE: an issue carries its reporter's log — page titles, filter rest). The repository must be PRIVATE: an issue carries its reporter's log — page titles, filter
values, at Teljes what they typed into search — and a public repository would publish it. That is values, at Teljes (TRACE) what they typed into search — and a public repository would publish it. That is
checked on every send, not only in the admin's "test connection", because a repository can be made checked on every send, not only in the admin's "test connection", because a repository can be made
public after the fact. public after the fact.
@@ -154,9 +154,22 @@ def check(db: Session) -> dict:
def preview(db: Session, text: str) -> str: def preview(db: Session, text: str) -> str:
"""The body as Gitea itself will render it (its markdown flavour, its sanitiser).""" """The body as Gitea itself will render it (its markdown flavour, its sanitiser).
with _client(_settings(db)) as (client, repo):
r = _call(client, "POST", "/markdown", json={"Text": text, "Mode": "gfm", "Context": repo}) **No `Context`, deliberately — a repository context here does not fail the call, it kills the
tracker.** With one, Gitea's post-processor turns every hex-shaped word into a commit link, and
on the API's render path the commit checker it asks is nil: the whole Gitea process segfaults
and restarts, taking the git server (and, here, the container registry) down with it. Every
report carries hex — a tab id, a request id — so this was not an edge case but the only case.
Measured on throwaway instances (2026-09-20): `{"Mode": "gfm", "Context": "<repo>"}` with
`5645b1c0ae01` in the text crashes 1.26.4 and 1.27.1 and is fixed in 1.27.3 — but a self-hoster
runs whatever version they run, so the payload stays safe here rather than relying on theirs.
Dropping `Context` is not a downgrade of the preview: measured against the same body, the HTML
keeps its tables, its task lists and its highlighted code blocks. It only stops resolving
repo-relative links (a hash, a `#123`), which the issue page itself still resolves.
"""
with _client(_settings(db)) as (client, _repo):
r = _call(client, "POST", "/markdown", json={"Text": text, "Mode": "gfm"})
return r.text return r.text
+16
View File
@@ -786,6 +786,7 @@ class FakeGitea:
self.redirect = False # an http:// address behind a proxy that sends it to https self.redirect = False # an http:// address behind a proxy that sends it to https
self.garbage_asset = False # a 2xx that is not the API's answer (a proxy's page) self.garbage_asset = False # a 2xx that is not the API's answer (a proxy's page)
self.patched_body: str | None = None self.patched_body: str | None = None
self.markdown_payloads: list[dict] = [] # what the preview asked the tracker to render
def handler(self, request: httpx.Request) -> httpx.Response: def handler(self, request: httpx.Request) -> httpx.Response:
path = request.url.path.removeprefix("/api/v1") path = request.url.path.removeprefix("/api/v1")
@@ -826,6 +827,7 @@ class FakeGitea:
self.patched_body = json.loads(request.content)["body"] self.patched_body = json.loads(request.content)["body"]
return httpx.Response(201, json={"number": 7}) return httpx.Response(201, json={"number": 7})
if request.method == "POST" and path == "/markdown": if request.method == "POST" and path == "/markdown":
self.markdown_payloads.append(json.loads(request.content))
return httpx.Response(200, text="<h3>Steps</h3>") return httpx.Response(200, text="<h3>Steps</h3>")
return httpx.Response(404, json={"message": "not found"}) return httpx.Response(404, json={"message": "not found"})
@@ -1180,6 +1182,20 @@ def test_the_preview_is_the_trackers_own_rendering(gitea_fake, db, make_user, as
assert stranger.post(f"/api/debug-sessions/{sid}/issue").status_code == 404 assert stranger.post(f"/api/debug-sessions/{sid}/issue").status_code == 404
def test_the_preview_never_asks_the_tracker_for_a_repository_context(
gitea_fake, db, make_user, as_user
):
"""A `Context` makes Gitea hunt for commit links, and on the API's render path that hunt
dereferences a nil checker: the tracker's whole process segfaults. Every report carries a
hex-shaped id, so the payload — not the text — is what has to stay safe (measured against
1.26.4 / 1.27.1, fixed upstream in 1.27.3; a self-hoster's version is not ours to pick)."""
owner = as_user(make_user())
sid = _start(owner)["id"]
text = "| tab | 5645b1c0ae01 |"
assert owner.post(f"/api/debug-sessions/{sid}/preview", json={"text": text}).status_code == 200
assert gitea_fake.markdown_payloads == [{"Text": text, "Mode": "gfm"}]
def test_the_admin_connection_test_names_what_is_wrong(gitea_fake, db, make_user, as_user): def test_the_admin_connection_test_names_what_is_wrong(gitea_fake, db, make_user, as_user):
admin = as_user(make_user(role="admin")) admin = as_user(make_user(role="admin"))
r = admin.post("/api/admin/config/test-issues") r = admin.post("/api/admin/config/test-issues")