Skip to content

Commit ef4e185

Browse files
authored
feat(incomingwebhook): GitLab MR/Issue card facts + stop filtering events (#610)
- GitLab merge_request/issue InteractiveCards gain a Source/Target branch (MR) + Labels(N) FactSet, mirroring the existing pipeline card. Card-only; text degrade path unchanged. - GitLab adapter no longer filters MR/Issue actions or pipeline statuses (explicit product decision); every action/status now renders on both text and card paths, except a payload missing the action/status field itself. - Escapes every field the filter removal exposed (action verb, pipeline status, glActor username) at every text (mdInertText) and card (escapeCardText) interpolation site, closing two markdown/link-injection regressions found across multiple review rounds. - Adds a pipeline-card color for in-progress statuses, a duration-clamp indicator, and a dedicated cardFactItemMax constant. - Extensive regression test coverage for hostile-input escaping on both paths. Reviewed and approved by lml2468, yujiawei, Jerry-Xin, mochashanyao. One deferred, tracked follow-up: text-path markdown-breakout hardening for ref/branch code spans and URL destinations (pre-existing, spans GitLab push/tag/note renderers and the GitHub adapter — out of scope here per adapter-parity).
1 parent 4c099ef commit ef4e185

9 files changed

Lines changed: 893 additions & 61 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
type: Learning
3+
title: "Removing a whitelist gate on external input silently removes its implicit escaping guarantee"
4+
description: A switch that only ever returns hardcoded safe literals doubles as an implicit sanitizer; widening its default case to pass through raw external input reopens markdown/link injection at every call site that relied on the old guarantee, unless each site is updated to explicitly escape.
5+
tags: ["trust-boundary", "escaping", "markdown-injection", "adapter", "webhook", "code-review"]
6+
timestamp: 2026-07-20T00:00:00Z
7+
# --- octospec extension fields ---
8+
source: self
9+
origin_task: gitlab-mr-issue-cards
10+
origin_pr: self
11+
status: pending
12+
candidate_rule: trust-boundary
13+
---
14+
15+
# Removing a whitelist gate silently removes an implicit escaping guarantee
16+
17+
## Context
18+
19+
`modules/incomingwebhook/adapter_gitlab.go`'s `glActionVerb` mapped GitLab's
20+
`object_attributes.action` (an external, unvalidated string — any holder of
21+
the webhook URL token can set it to anything) to a render verb. Originally it
22+
was a strict whitelist: `open`/`close`/`reopen`/`merge` → four hardcoded
23+
literals, anything else → `""` (skip). Because the only values it could ever
24+
*return* were those four safe literals, none of its callers escaped the
25+
result before interpolating it into markdown text or a card headline — there
26+
was nothing to escape.
27+
28+
## The trap
29+
30+
A later change (per an explicit product decision to stop filtering GitLab
31+
events by action) widened the `default` case from `return ""` to
32+
`return action` — i.e., "unknown actions render too, using their raw name."
33+
This is a reasonable product change on its own. But it silently deleted the
34+
whitelist's second, unstated job: every caller's assumption that this
35+
function's output was always a safe literal became false, and none of the
36+
four call sites (2 text-path, 2 card-path) were updated to escape the now-
37+
possibly-hostile value. The result: `action: "**pwn** [x](http://evil)"`
38+
rendered as forged bold + a live link in the delivered message. This shipped
39+
in the same commit as the filter-removal and was only caught by an
40+
independent code-review pass — the "unknown action" test that already existed
41+
didn't catch it either, because it exercised `"approved"` (an explicitly
42+
mapped, safe case), not a genuinely unmapped value.
43+
44+
## It recurred in the same PR, on the sibling field
45+
46+
The same commit that widened `glActionVerb` *also* removed the equivalent
47+
whitelist gate on GitLab pipeline `status` (`success`/`failed`/`canceled`
48+
any non-empty value), in `renderGitLabPipeline`. The fix for `action` shipped
49+
in a follow-up commit — but that fix was scoped to `glActionVerb` specifically
50+
and didn't re-check `status`, which had the identical shape of bug: raw
51+
`ev.ObjectAttributes.Status` interpolated unescaped into the text-path
52+
markdown once its gate was gone. It took a **second**, independent review (a
53+
human PR review, after a first AI-delegated review had already caught and
54+
"fixed" the `action` half) to catch it. Point 4 below is not hypothetical —
55+
it's exactly the check that would have caught this the first time, and it
56+
needed to be applied to *every* field a gate-removal commit touches, not just
57+
the one an initial finding happened to name.
58+
59+
## The rule
60+
61+
When a function's return value has been implicitly safe only because its
62+
domain was a small hardcoded whitelist, and a change widens that domain to
63+
include (or pass through) external input:
64+
65+
1. Treat the return value as untrusted from that point on, at **every**
66+
existing call site — not just new ones.
67+
2. Escape at the interpolation site (the boundary the caller can't cross),
68+
using the same escaper already used for other external fields in that
69+
context (`mdInertText` for GitLab adapter text-path markdown,
70+
`escapeCardText` for the octo/v1 card leaf) — see
71+
`.octospec/rules/trust-boundary.md`.
72+
3. Write a regression test with a value that is genuinely outside the old
73+
whitelist and contains markdown metacharacters — a test using a value the
74+
new code *happens* to map explicitly (like `"approved"` here) proves
75+
nothing about the new raw-passthrough branch.
76+
4. When reviewing a "widen this gate" change, explicitly ask: was this gate's
77+
restricted output range load-bearing for escaping anywhere downstream? —
78+
and enumerate **every** field the same commit un-gated, not just the one
79+
already flagged. A gate-removal commit that touches N fields needs this
80+
check done N times, independently; fixing the first one found does not
81+
imply the others were checked.
82+
83+
## Candidate rule promotion
84+
85+
Proposing to fold this into `.octospec/rules/trust-boundary.md` as a named
86+
sub-case of "escape at the right boundary": *whitelist-gates-as-implicit-
87+
sanitizers* — call out that narrowing a function's possible outputs to a
88+
fixed safe set is a common, easy-to-miss way code becomes implicitly
89+
unescaped, and that widening such a gate is itself a trust-boundary-relevant
90+
change requiring the same review depth as adding a new external field.

.octospec/log.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,36 @@ Change history for this repo's `.octospec/`, following the
44
[OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md)
55
change-log convention (§7). Newest first.
66

7+
## 2026-07-20 (gitlab-mr-issue-cards)
8+
9+
- **Feature** — GitLab merge_request/issue InteractiveCards gained a
10+
Source/Target branch (MR) + Labels(N) FactSet, mirroring the existing
11+
pipeline card. Card-only; text degrade path unchanged.
12+
- **Behavior change** — GitLab adapter no longer filters MR/Issue events by
13+
action or pipeline events by status (explicit product decision); every
14+
action/status now renders on both text and card paths.
15+
- **Fix** — A follow-up code review found the filter-removal had silently
16+
reopened a markdown/link injection: `glActionVerb`'s raw-passthrough
17+
fallback for unmapped actions was interpolated unescaped. Fixed by escaping
18+
at every call site; also deduped the pipeline Jobs / new Labels fact
19+
cap-and-join logic.
20+
- **Fix** — A PR review (lml2468, PR #610) then found the exact same bug
21+
class on the sibling field the first fix missed: GitLab pipeline `status`
22+
also lost its whitelist gate in the same commit, and was still interpolated
23+
raw on the text path. Fixed identically. See
24+
[journal](journal/shared/gitlab-mr-issue-cards.md) and the pending learning
25+
on whitelist-gates-as-implicit-sanitizers (updated with this recurrence).
26+
- **Fix** — Re-review (yujiawei, PR #610) found the same class of bug a third
27+
time, pre-existing in `glActor`'s `username` branch (byte-identical to
28+
`main`, not introduced by this task, but folded into the same fix pass):
29+
it assumed GitLab's restricted username charset made escaping unnecessary,
30+
which does not hold at this trust boundary (the endpoint only checks a
31+
shared secret, not that the payload is genuinely from GitLab). Also
32+
addressed two non-blocking review nits (mochashanyao, PR #610): a
33+
distinguishing `>` prefix when `formatPipelineDuration` clamps a hostile
34+
value, and a dedicated `cardFactItemMax` constant instead of reusing the
35+
actor-name clamp for Jobs/Labels fact items (yujiawei, PR #610).
36+
737
## 2026-07-17 (docs-approval-card-enrich)
838

939
- **Feature** — Enriched the docs access-request approval card (header + colored

0 commit comments

Comments
 (0)