|
| 1 | +--- |
| 2 | +type: Journal |
| 3 | +title: "Journal: gitlab-mr-issue-cards" |
| 4 | +description: GitLab merge_request/issue cards gained a Source/Target branch + Labels FactSet; the adapter stopped filtering MR/Issue actions and pipeline statuses per explicit product decision. Three independent review passes each caught a real trust-boundary escaping gap in the same file, two introduced by the filter removal and one pre-existing — all three the same "whitelist-gate-as-implicit-sanitizer" bug class. |
| 5 | +tags: ["incomingwebhook", "adapter", "gitlab", "card", "trust-boundary", "external-content", "markdown", "code-review"] |
| 6 | +timestamp: 2026-07-20T00:00:00Z |
| 7 | +# --- octospec extension fields --- |
| 8 | +task: gitlab-mr-issue-cards |
| 9 | +upstream: self |
| 10 | +source: self |
| 11 | +--- |
| 12 | +# Journal: gitlab-mr-issue-cards |
| 13 | + |
| 14 | +## What was done |
| 15 | + |
| 16 | +Five commits on `feat/gitlab-mr-issue-cards`: |
| 17 | + |
| 18 | +1. **Add source/target branch + labels to GitLab MR/Issue cards.** The |
| 19 | + `merge_request`/`issue` InteractiveCards (shipped in #596) only carried a |
| 20 | + bare "actor verb an MR/issue" headline + numbered title. Added a |
| 21 | + Source/Target branch FactSet (MR only, each row independent so a payload |
| 22 | + missing one still shows the other) and a shared `Labels (N)` FactSet row |
| 23 | + (both MR and issue), parsing `object_attributes.source_branch`/ |
| 24 | + `target_branch` and the top-level `labels[]` array — all previously |
| 25 | + unparsed. Card-only, same convention as the pipeline card's Duration/Jobs: |
| 26 | + the plain-text degrade path is untouched, so flag-off bytes are identical |
| 27 | + to history. |
| 28 | + |
| 29 | +2. **Stop filtering GitLab MR/Issue actions and pipeline statuses.** Per |
| 30 | + explicit, twice-confirmed instruction from the requesting user (after being |
| 31 | + shown the concrete spam tradeoff — an active MR fires `update` per push; a |
| 32 | + pipeline fires `pending`→`running`→terminal): `glActionVerb`'s `default` |
| 33 | + case now falls back to the raw action string instead of returning `""` |
| 34 | + (which signaled skip), and `renderGitLabPipeline`/`buildGitLabPipelineCard` |
| 35 | + render for any non-empty status instead of gating on a fixed |
| 36 | + success/failed/canceled switch. The only remaining skip is a genuinely |
| 37 | + missing action/status field. |
| 38 | + |
| 39 | +3. **Fix: escape the action verb before interpolation.** A follow-up code |
| 40 | + review (see below) found that commit 2's raw-passthrough fallback |
| 41 | + interpolated the *unescaped* external `action` field into both the |
| 42 | + text-path markdown and the card headline. Fixed by escaping `verb` at |
| 43 | + every call site (`mdInertText` text path, `escapeCardText` card path), and |
| 44 | + documented the contract on `glActionVerb` itself. Also extracted the |
| 45 | + duplicated "cap + escape + join" logic (pipeline Jobs fact, new Labels |
| 46 | + fact) into a shared `glCappedFactValue` helper, which incidentally fixed a |
| 47 | + minor bug where a blank label title would inflate the `Labels(N)` count |
| 48 | + with an empty slot. |
| 49 | + |
| 50 | +4. **Fix: escape the pipeline status too (caught by PR review).** After |
| 51 | + the PR was opened, a reviewer (lml2468) found that commit 2 removed |
| 52 | + the *pipeline* status whitelist gate but the raw `ev.ObjectAttributes.Status` |
| 53 | + was still interpolated unescaped in `renderGitLabPipeline`'s text path (the |
| 54 | + pipeline card path was already correctly escaped via `escapeCardText`, |
| 55 | + only the text path had the gap). This is the **identical bug class** as |
| 56 | + commit 3, on the sibling field the earlier review didn't examine — the |
| 57 | + first review had only seen the diff up to commit 2, and my own commit |
| 58 | + 3 fix pattern-matched on `glActionVerb` specifically without checking |
| 59 | + whether the same "gate removed, escaping not added" gap existed anywhere |
| 60 | + else the same PR touched. Fixed identically: `mdInertText(status, glActorMax)` |
| 61 | + at both `renderGitLabPipeline` branches, with regression tests for both |
| 62 | + (web_url present/absent). |
| 63 | + |
| 64 | +5. **Fix: escape `glActor`'s `username` branch (pre-existing, folded in on |
| 65 | + re-review).** A second reviewer (yujiawei) re-reviewed after commit 4 and |
| 66 | + found a *third* instance of the same bug class — this one pre-existing, |
| 67 | + byte-identical to `main`, not introduced by this task. `glActor` assumed |
| 68 | + GitLab's restricted username charset (`[a-zA-Z0-9_.-]`) made the `username` |
| 69 | + branch safe to interpolate raw; that assumption does not hold at this |
| 70 | + endpoint's actual trust boundary (it only verifies a shared secret token, |
| 71 | + not that the payload genuinely came from GitLab), so a token holder could |
| 72 | + set `username` to arbitrary markdown-bearing text. Folded into this PR |
| 73 | + (rather than filed separately) since it's the same file, same pattern, and |
| 74 | + the fix is the one-line change already applied twice above: |
| 75 | + `mdInertText(username, glActorMax)`, matching `glActorCard`'s card-path |
| 76 | + equivalent which was already correct. Also addressed two non-blocking |
| 77 | + review nits picked up in the same pass: `formatPipelineDuration` now |
| 78 | + prefixes `>` when it clamps a hostile duration (so a clamped value reads |
| 79 | + distinctly from a genuine ~100h pipeline), and `glCappedFactValue` uses a |
| 80 | + new dedicated `cardFactItemMax` constant instead of reusing the |
| 81 | + actor-name-sized `cardActorMax` for job/label name truncation. |
| 82 | + |
| 83 | +## Load-bearing decisions |
| 84 | + |
| 85 | +- **A whitelist gate doubles as an implicit sanitizer — removing it does not |
| 86 | + remove the need to escape.** Before commit 2, `glActionVerb` only ever |
| 87 | + returned one of four hardcoded, injection-free literals |
| 88 | + (`opened`/`closed`/`reopened`/`merged`); the fixed whitelist made explicit |
| 89 | + escaping unnecessary in practice. Widening the function to fall through to |
| 90 | + raw external input silently deleted that guarantee without anyone changing |
| 91 | + the render call sites — the bug shipped in the same commit as the filter |
| 92 | + removal and was only caught by an independent review pass. See the pending |
| 93 | + learning below. |
| 94 | +- **Escape at the boundary that's actually load-bearing, not by convention |
| 95 | + alone.** `verb` is escaped at each of its 4 interpolation sites (2 text, 2 |
| 96 | + card) rather than inside `glActionVerb`, because callers need it as a plain |
| 97 | + string for both a `mdInertText`- and an `escapeCardText`-shaped context. |
| 98 | + `glActor`/`glActorCard`, by contrast, escape *inside* the helper (as of |
| 99 | + commit 5) — there both call sites want the same one string back, so there's |
| 100 | + no reason to push the escaping decision out to callers. Same principle |
| 101 | + ("escape once, correctly, at whichever point makes every caller safe by |
| 102 | + construction"), different shape depending on how many distinct contexts a |
| 103 | + value flows into. |
| 104 | +- **Filtering removal is a product decision, not a technical default.** The |
| 105 | + user was shown the concrete consequence before confirming; this is recorded |
| 106 | + here so a future reader doesn't mistake the wide-open behavior for an |
| 107 | + oversight and "fix" it back to filtered without checking history first. |
| 108 | + |
| 109 | +## Process note: three independent review passes, three instances of the same bug class |
| 110 | + |
| 111 | +A first review pass (before commit 3 existed) caught the `action` escaping |
| 112 | +gap as HIGH severity, correctly identified that the existing "unknown action" |
| 113 | +test didn't actually exercise the raw-passthrough branch (it used |
| 114 | +`"approved"`, which is an explicitly-mapped case), and flagged the Jobs/Labels |
| 115 | +duplication. All three were fixed in commit 3. |
| 116 | + |
| 117 | +A PR reviewer (lml2468) then found that the fix in commit 3 was *incomplete*: |
| 118 | +it treated the bug as specific to `glActionVerb`/`action` and didn't check |
| 119 | +whether the same "gate removed → escaping assumption broken" pattern applied |
| 120 | +to `status`, which the very same commit-2 change had also un-gated. It had. |
| 121 | +This is a direct, concrete instance of the pending learning this task itself |
| 122 | +filed (`gitlab-mr-issue-cards-whitelist-gate-sanitizer.md`) — point 4 of that |
| 123 | +learning ("when reviewing a widen-this-gate change, ask whether the |
| 124 | +restricted output range was load-bearing for escaping anywhere downstream") |
| 125 | +should have been applied to *both* fields removed by commit 2, not just the |
| 126 | +one a first review happened to flag. Fixed in commit 4. |
| 127 | + |
| 128 | +A second reviewer (yujiawei) re-reviewed after commit 4 and found a *third* |
| 129 | +instance — pre-existing in `glActor`, not introduced by this PR, but the same |
| 130 | +"an assumption made the field implicitly safe, and the assumption was never |
| 131 | +actually enforced by code" shape (here: assumed GitLab's username charset, |
| 132 | +rather than a removed whitelist, made escaping unnecessary). Fixed in |
| 133 | +commit 5, along with two smaller non-blocking review nits (mochashanyao: |
| 134 | +duration-clamp indicator; yujiawei: dedicated fact-item-length constant). |
| 135 | +Three passes, three real findings, zero false positives — worth noting for |
| 136 | +calibrating how much a single review pass should be trusted on |
| 137 | +trust-boundary-classified changes. |
| 138 | + |
| 139 | +## Verification |
| 140 | + |
| 141 | +- `go test ./modules/incomingwebhook/... -run '<adapter/card subset>'` green |
| 142 | + (including the new injection regression tests). |
| 143 | +- `golangci-lint run ./modules/incomingwebhook/...` = 0 issues; `gofmt` clean. |
| 144 | +- Manual render check (throwaway test, not committed) confirmed the actual |
| 145 | + rendered text for a realistic MR/pipeline payload before/after each change. |
| 146 | + |
| 147 | +## Follow-ups / notes |
| 148 | + |
| 149 | +- GitHub adapter's PR/issue cards remain unenriched (no branch/labels |
| 150 | + FactSet) and still gate on a fixed action whitelist — out of scope here, |
| 151 | + the user scoped this task to GitLab only. |
| 152 | +- If message volume from unfiltered MR `update`/pipeline non-terminal statuses |
| 153 | + turns out to be a real problem in production, the filter can be |
| 154 | + reintroduced at the same two gate points (`glActionVerb`'s default case, |
| 155 | + `renderGitLabPipeline`/`buildGitLabPipelineCard`'s status check) — now with |
| 156 | + the escaping fix in place regardless of which way that goes. |
| 157 | +- **Deferred (yujiawei, PR #610 review): text-path markdown-breakout family |
| 158 | + in GitLab/GitHub adapters.** Two related, pre-existing gaps in the same |
| 159 | + spirit as the bugs this PR fixed for `action`/`status`/`username`, both |
| 160 | + unchanged from `main` and both out of scope to fix in this PR (see |
| 161 | + reasoning below): |
| 162 | + - **Ref/branch code-span backtick breakout.** `glShortRef` doesn't strip |
| 163 | + backticks, and its output goes raw into a `` `%s` `` text-path code span |
| 164 | + at 6 sites: GitLab push branch create/delete, push commit-count line, tag |
| 165 | + push (2 sites), and pipeline (2 sites — the only ones this PR's changes |
| 166 | + actually widen exposure to, by rendering non-terminal statuses that |
| 167 | + previously never reached this code path). A ref/branch name containing a |
| 168 | + literal backtick is not rejected by git's ref-name rules, so this is |
| 169 | + real, not just theoretical. The card path is already safe (`cardCodeSpan` |
| 170 | + strips backticks via `mdCodeSpanText`). |
| 171 | + - **Raw URL destinations in markdown link syntax.** `renderGitLabMergeRequest`/ |
| 172 | + `renderGitLabIssue`/`renderGitLabNote` place `object_attributes.url` (and |
| 173 | + the note URL) directly as a link destination `](%s)`; a `)` in that |
| 174 | + token can close the link early and let the rest of the string inject |
| 175 | + forged markdown after it. Same class of bug, different sink — flagged by |
| 176 | + yujiawei's second review pass as worth closing in the same follow-up |
| 177 | + rather than treating as unrelated. |
| 178 | + - **Not fixed here**: a correct fix for either needs to touch |
| 179 | + `renderGitLabPush`/`renderGitLabTagPush`/`renderGitLabNote` (functions |
| 180 | + this PR never modified) and, per this repo's adapter-parity rule, the |
| 181 | + equivalent sinks in `adapter_github.go` — a partial, pipeline-only patch |
| 182 | + here would leave push/tag/note/GitHub with the identical gap, a worse, |
| 183 | + inconsistent posture than not touching it. **Tracked as one follow-up |
| 184 | + task**: harden every GitLab *and* GitHub text-path ref/branch code span |
| 185 | + (route through `mdCodeSpanText`, mirroring the card path) *and* every |
| 186 | + raw URL-destination interpolation (needs a `safeMarkdownURL`-style |
| 187 | + destination validator/escaper on the text path — `adapter.go` already |
| 188 | + has `safeMarkdownURL` for a different purpose, worth checking if it |
| 189 | + applies directly). |
| 190 | + |
| 191 | +- **Noted, not tracked as a task (yujiawei, PR #610 review, both explicitly |
| 192 | + "awareness only" / cosmetic, no panic or injection risk):** |
| 193 | + - `int(ev.ObjectAttributes.Duration)` on an absurd external JSON float |
| 194 | + (e.g. `1e100`) can saturate before `formatPipelineDuration`'s upper |
| 195 | + clamp runs, silently dropping the duration fact instead of showing |
| 196 | + `>100h 0m`. Safe (no panic, no absurd string), just a display gap for a |
| 197 | + payload no real GitLab instance would ever send. |
| 198 | + - The text path still clamps `verb`/`status` with `glActorMax` while the |
| 199 | + card path has a dedicated `cardFactItemMax` — harmless today (same |
| 200 | + value), just a naming/domain mismatch if either constant's value |
| 201 | + diverges later. |
0 commit comments