Backstage wave (7 GHSAs): Scaffolder order-field secret oracle¶
Wave in one line: October 7 16:2xZ — TechDocs mkdocs.yml sanitizer allow-list misses, Scaffolder order-field secret oracle, and task-event/log secret reflection
Source: hourly offensive-security scan of GitHub Security Advisories, 2026-10-07 (published 16:22–16:25Z). Backstage (Spotify's developer portal) published seven advisories for @backstage/plugin-techdocs-node (fixed 1.15.4, releases v1.50.5/v1.54.6) and @backstage/plugin-scaffolder-backend (fixed 4.1.0). Fix commits are public, so the sanitizer gaps ship with their exact pre-fix shape.
Backstage is a high-value recon target class on its own: an internal portal that (a) builds documentation from any registered repository's committed mkdocs.yml, and (b) runs templated "scaffolder" actions with backend-managed credentials. An attacker needs only repo write access — routinely a much lower bar than portal admin.
Advisory table¶
| Leg | GHSA | CVE | CVSS | Boundary (from fix diffs + advisory text) |
|---|---|---|---|---|
| MkDocs sanitizer bypass → RCE | GHSA-p75x-jh7p-ppcx | CVE-2026-106505 | 7.7 | Repo-committed mkdocs.yml circumvents the sanitizer shipped for CVE-2026-25153 and executes arbitrary code on the TechDocs backend during doc generation |
| Theme config RCE | GHSA-8w7q-29mw-gf5c | CVE-2026-106509 | 7.7 | mkdocs.yml theme/config values cause arbitrary code execution during local/container doc builds |
| Markdown-extension file inclusion / SSRF | GHSA-f7v3-xhm6-w245 | CVE-2026-106557 | 7.7 | Doc-source registration/modification makes a TechDocs build read outside the doc boundary (backend-host files or internal network). Requires pymdown-extensions >= 10.21.3 (mkdocs-techdocs-core >= 1.7.0) — older PyMdown snippets stay vulnerable even with sanitized config |
| Local publisher file exposure | GHSA-cm77-ch27-6w6v | CVE-2026-106508 | 5.3 | techdocs.publisher.type: local serving endpoint follows filesystem references outside the doc tree → host file read by authenticated users; cloud publishers unaffected |
| Task-list ordering oracle | GHSA-vwp5-f99x-x3rq | CVE-2026-106506 | 5.3 | GET /v2/tasks?order=asc:<field> accepted any field pre-fix, including secret-bearing columns → repeated ordered requests infer confidential retained task data |
| Task-event secret exposure | GHSA-95p4-vv4g-jxxm | CVE-2026-106502 | 5.3 | Authenticated user retrieves backend-managed credentials from Scaffolder task events under template/failure conditions |
| Task-log secret exposure | GHSA-4xvq-3m68-h444 | CVE-2026-106504 | 6.5 | Any user who can create+read tasks sees sensitive values in logs when a denied action echoes its input — error path reflects secret-bearing inputs |
What the fix diffs prove¶
- Sanitizer bypass #1 — allow-list membership. Commit
a7d995fdeletesextra_templatesfromALLOWED_MKDOCS_KEYSand adds a removal path with an opt-in override list. Pre-fix, any registered repo could setextra_templates— templates MkDocs renders outside the normal doc flow — straight through the sanitizer. The prior "fix" for CVE-2026-25153 was a key allow-list; the bypass was a key that was on the list but whose semantics the sanitizer never constrained. - Sanitizer bypass #2 — config stripped, extension kept (and vice versa). Commit
017ace5shows pre-fixsanitizeMkdocsYml()explicitly whitelistedpymdownx.snippets(and thepymdownx.snippets:SnippetExtensionspelling) as a string entry while stripping every other:-bearing name, and did not scrub its config. The fix tests pin the exact dangerous config shapes:base_path: /,restrict_base_path: false,url_download: true— i.e. snippet inclusion from filesystem root and remote URL download at build time. The advisory adds that with oldpymdown-extensionsversions the inclusion stays reachable even after config sanitization (extension-default drift). - Order oracle. Commit
e673a86addstaskOrderFields = new Set(['created_at','status','created_by'])and rejects anything else with 400. The new test literally uses?order=asc:secrets— the maintainers' own PoC field name is the secret column. Pre-fix, ordering by secret-bearing task fields leaks values through ordering responses.
Operator playbook¶
- Find the portal. Backstage frontends fingerprint on
/api/techdocs/metadata,/api/scaffolder/v2/,/api/catalog/entitiesbeing public-ish. Note version from/api/backendmetadata or bundle comments; the two fix bands (techdocs-node 1.15.4 / core 1.50.5–1.54.6, scaffolder 4.1.0) fingerprint per plugin, not per core. - The
orderfield is a free positive/negative control on any build. SendGET /v2/tasks?order=asc:secretsand?order=asc:created_atwith a low-priv token: fixed builds return 400InputErroron the former, 200 on the latter — a one-request patch-level probe. On vulnerable builds, treat every retained queued/failed task's secret-bearing fields as ordering-oracle inputs (compare position shifts across repeated requests; the advisory's own precondition list is retained secrets + target task visibility + known secret structure + repeated requests). Same sort-as-oracle class as the October 6 Payload join-sort leg. - Task create/read as a secret relay. The log leg's precondition is a denied action whose input carries a sensitive value — so enumerate which actions a low-priv user may invoke with attacker-influenced inputs, trigger denial, and read back the task log/event stream for reflected values. Report event-stream and log reflection as separate sinks; they were separately patched.
- Repo-commit → backend RCE is the portal's main edge. With write access to any catalog-registered repo (every dev on the estate), craft
mkdocs.ymltests inside an authorized lab Backstage only: (a)extra_templates:entries, (b)pymdownx.snippetswithbase_path: //url_download: truepointing at an owned callback, (c) theme/plugin entries the sanitizer's allow-list predates. Proof boundary: prove inclusion (marker file content rendered into docs, or an owned-callback hit forurl_download), never a reverse shell;runIn: dockeris containment, not mitigation, per the vendor's own text. - Local publisher path check. On
publisher.type: localdeployments, probe the doc-serving endpoint for references outside the doc tree with synthetic marker paths in a lab; the cloud publishers are not affected, so fingerprint publisher type from response shapes before claiming the leg. - Report framing. The three TechDocs legs are one attack surface with three sinks (build RCE, build-time file/URL inclusion, serve-time file exposure). Bundle per-sink but lead with repo-write-to-backend-code-execution; the Scaffolder legs are read-side oracle + error-path reflection — do not collapse them into one "info disclosure".
Cross-references¶
- Sort/order-as-oracle family: Oct 6 Payload wave (join-internal sort never field-access-validated).
- Secret-in-error/log reflection family: Oct 2 Airflow masker-shape-gaps wave.
- Config-file-to-build-execution class: the repo-trust pages (build tools honoring repo-local config without a trust prompt).
October 7 18:0xZ — second coordinated wave: the markdown_extensions name field is a callable-resolution sink, plus 20 more legs¶
Nineteen minutes after the first dump, Backstage published a second wave (17:58–18:05Z, ~23 advisories, same fix bands: techdocs-node 1.15.4, scaffolder-backend 4.1.0, catalog-backend 3.9.1, kubernetes-backend 0.21.8–0.21.10, backend-defaults 0.7.18/0.17.8, auth oidc-provider 0.4.20). The headliner changes the RCE story.
The marquee leg: extension-name → Python callable¶
GHSA-4488-j8vj-vqqv / CVE-2026-106510 (with GHSA-qmw3-745m-w99g / CVE-2026-106558, config-value constraints): commit 02cd7cdb ships the maintainers' own exploit fixtures — mkdocs_with_dangerous_extensions.yml contains a markdown_extensions entry spelled 'subprocess:Popen' with args: ['/bin/sh','-c','id'] and another spelled 'os:system'. Pre-fix, the sanitizer validated extension names against a grammar it assumed was module paths, but Python-Markdown resolves a module:callable entry by importing the module and calling the named attribute with the entry's mapping. So any authenticated user who can register a catalog entity gets arbitrary OS command execution on the TechDocs build host — no extension bug needed, the name field itself is the injection point. The same commit adds DANGEROUS_EXTENSION_CONFIG_KEYS = {plantuml_cmd} (an extension option that names a command — the mermaid2/plantuml family config-key route) and a key-level ALLOWED_THEME_KEYS set (name/font/icon/.../palette/features), meaning theme config sub-keys were previously passed through wholesale. Fixture mkdocs_with_plantuml_cmd_hyphenated.yml confirms the hyphenated spelling plantuml-cmd was part of the test matrix — spelling variants of dangerous config keys are explicitly in scope.
- Durable rule: a "name/identifier" field consumed by a plugin-loader grammar that supports
module:callable, entry-point, or factory notation is an execution sink. Sweep every config surface that feeds Python-Markdown, MkDocs plugins, Hydra_target_, Jinja extension lists, logging()factories, or anyimport + callresolver, and test the callable-spelling of the field — not just unknown-name rejection. The sanitizer-allow-list bypass on this page (extra_templates,pymdownx.snippetsconfig) is the same class one level up: keys the list contains whose semantics nobody constrained; this wave shows the values' grammar was also unconstrained. - Probe ladder for a lab TechDocs build (authorized only, prove invocation marker e.g.
os:systemwriting a canary, never a shell): (1) benign unknown extension name → rejection shape; (2)'os:system'-style name with a marker arg; (3) nestedplantuml_cmd/plantuml-cmdoption on a whitelisted extension; (4)theme: <custom_dir-ish>keys per commit911ca36f(source symlink validation landed alongside). The 400-vs-build-failure-vs-marker differential fingerprints sanitizer revision.
Remaining legs, grouped by axis (all from the 17:58–18:05Z wave)¶
| Cluster | Legs | Operator axis |
|---|---|---|
| Catalog source confinement | qgvj/106498 placeholder directives resolve outside the entity's source repo; pcmq/106496 disallowed location types still processed → backend file access; xvh9/106493 S3/Azure catalog readers accept object paths outside the configured bucket boundary | "Which namespace does this fetch resolve against?" — register an entity whose location/placeholder points at a sibling repo, a file:-class type, and an outside-boundary object key; each success is a read primitive under the backend's credentials, not yours |
| Cloud output collision | 7649/106494 bucket object names collide with protected files in the generated output dir → content injection; rg9r/106489 + 7mfx/106490 external-storage TechDocs serving crosses entity permission boundaries (S3/GCS and Azure Blob respectively) | If you can write objects into the docs bucket (often broader IAM than the portal), you can inject content into other entities' rendered docs — a stored-XSS/phishing candidate without touching Backstage itself |
| proxy-backend | 9ghw/106491 crafted URL forwards outside the configured base path on already-configured proxy targets | Path-escape on an authenticated SSRF-adjacent forwarder: probe ..%2f/double-encode variants against each configured proxy endpoint's other route families; the vendor workaround (fronting normalizer) admits the backend never canonicalized |
| Kubernetes plugin | p795/106561 custom resource reported with list kind SecretList had data values unmasked (fix 050a4e11); r9ph/106563 deprecated /services/:serviceId accepts crafted entity data → cross-cluster workload read; h53x/106487 catalog-supplied kubernetes-cluster entities can trigger the backend's local in-cluster identity against the local API endpoint | Masking keyed on response shape (list kind) not resource class → any alternate CR spelling evades the mask; deprecated routes survive as unguarded twins; catalog write access = steering which identity reads which surface — even "not sent to the supplied endpoint" means the backend's own ServiceAccount tokens become usable where the plugin proxies |
| Scaffolder credential boundary | 29gx/106462 SCM actions fall back to broader app/installation auth when user credential absent — fix is opt-in scaffolder.requireScmUserCredentials: true (commit 6fb2a41e); hmp2/106503 action-restriction bypass; cq7v/106492 restricted service credentials regain write scope through plugin delegation paths; g8rx/106486 Bitbucket PR actions touch paths outside work area; 75qf+2cmg/106559+106560 Confluence-to-Markdown writes influenced by attacker-edited wiki pages | Template-author-controlled content from an upstream SaaS (Confluence page body/title) becomes file-write path content in the backend's filesystem — run-template-on-untrusted-page is a real edge; and a security fix shipped behind a default-off flag means patched-version fingerprinting ≠ fixed behavior: probe the credential-fallback differentially (invoke an SCM action without a user token, observe whether installation credentials act) rather than trusting version numbers |
| Scaffolder visibility/state | 7hfw/106461 task listing leaks metadata across policy; g2v8/106501 cross-user task read returns internal execution data incl. external-service creds; mfvq/106499 defaultEnvironment.secrets values land in task logs via template iteration; xvgh/106500 task-state timing race touches backend-writable files | Wave-1's order-oracle thesis, generalized: task rows, events, logs, listings, and retry/failure paths are five separately-patched secret reflection surfaces — enumerate all five per deployment; isTaskOwner is a config suggestion, not enforced behavior |
| Identity ingestion | 826h/106488 OIDC email-claim resolution lets a provider user assume another catalog identity when provider permits unverified emails (commit 1b1f05e8 validates email claims); gp6m/106463 GitLab org event ingestion admits unintended catalog identity | Identity resolution keyed on a claim the upstream IdP never verified = ATO-by-email-squatting; on any portal with email-join SSO, test whether the IdP marks email verified and whether the portal checks. Event-driven ingestion adds identities on a path that bypasses the sign-in resolver's org-membership gate |
Updated playbook (additions to wave 1)¶
- Fingerprint the sanitizer revision behaviorally, not by package version: the opt-in
requireScmUserCredentialsflag and the two-band techdocs fixes mean version numbers lie about security posture. Theos:system-style name probe +order=asc:secrets400-probe + placeholder-outside-repo probe form a three-request decision table (each: reject / build-fail / succeed-under-backend-identity). - Check what you can write besides Backstage. Bucket object writes (7649), Confluence page edits (75qf), and catalog cluster entity writes (h53x) are each outside-portal primitives that land inside portal authority. Inventory the portal's inputs (storage buckets, upstream wikis, cluster manifests) as attack surface in their own right.
Tracked without publication (this 17:5x–18:0xZ window)¶
- WeasyPrint unbounded image-format dispatch to Ghostscript RCE — promoted as a follow-up on the Sept 20 media-pipeline page.
- Hydra config-to-callable pair — promoted on the Oct 2 Airflow callable-resolution page.
- vm2 fork
DANGEROUS_BUILTINSchild_process omission — promoted on the Oct 5 vm2 dump page. - mppx condition-gate self-declared-wallet grant — promoted on the July 30 CHARX EV page as the asserted-identity generalization of the identifier-as-credential rule.
- Kunstmaan MediaBundle upload-blacklist case-order bypass (p279/CVE-2026-104890): check-before-normalize ordering + default blacklist of only
php/htaccess(battery:phtml, php5, phar, shtml, cgi) + unescaped blacklist regex interpolation — canonical upload-extension class, tracked. - Ghost Author-role delete-any-post (x3mg/105680) — verb-scope authz drift, known class.
- jolokia JSR160 JMX target-URL deny-list enrichment (c9ff re-surfacing) — JMX/deser class covered.
Tracked without publication (same 16:1x–16:2xZ window)¶
- Payload MongoDB field-level write ACL bypass via null/primitive rows in array inputs (GHSA-4ww4-68q3-h7g5 / CVE-2026-106100, 7.1) — folded on the Oct 6 Payload page.
- Ghost trio: members comment authz bypass (6q6j, fixed 6.44.1), staff webhook SSRF (354h), staff ImageSize path traversal (83rp) — known classes (verb drift, staff-role SSRF, image-service traversal); no new axis.
- RabbitMQ Java client broker credentials leaked in exception message (h6w7) — credentials-in-error-text class already covered.
- ImageSharp CCITT/TIFF fax heap overflow pair (v76p/jj3q), yawkat LZ4 unvalidated-length allocation pair (4v53/6cx8), Twisted IMAP
wildcardToRegexpReDoS (8pqf) — memory-safety/DoS class per parser precedent.
October 7 20:2xZ follow-up: third Backstage wave (same fix bands) — overlapping proxy routes, Cloudflare Access audience skip, negative email-verification inversion, Sentry scaffolder action input validation¶
Third coordinated wave published 20:23–20:25Z, mostly re-stating classes this page already maps, with three legs carrying their own axis:
- Inconsistent credential enforcement for overlapping proxy routes (GHSA-472h-9c5j-prrr / CVE-2026-106456, 4.8): proxy route-matching is longest/first-match-sensitive, and a more-specific route's credential requirement doesn't cover an overlapping less-specific sibling — same route-family drift as the r9ph deprecated-services leg, now proven on the proxy subsystem: enumerate every proxy prefix at every path depth and diff auth posture per prefix segment (a
/proxy/foo/barvariant that matches a looser rule is the finding). - Explicit negative email-verification ignored during shared OAuth profile normalization (GHSA-xm5q-p7w3-x6cp / CVE-2026-106460, 6.8): identity-linking normalization treats
email_verified: falsethe same as absent — the third-field-branch rule from today's wgerNone != Noneleg again: any boolean trust field must be tested present-true / present-false / absent, and "merge profiles" logic that defaults to trust on unrecognized values resurrects the 826h unverified-email identity path on a different provider shape. - Insufficient audience validation in the Cloudflare Access auth provider (GHSA-q333-f498-w2x7 / CVE-2026-106457, 6.8): tokens for one
audaccepted at Backstage — per-IdPaudcheck parity sweep (this quarter: Spring header predicates, Homer JWT unregister, now CF Access provider) on any product with pluggable auth providers. - Also same-class: r7f2 MkDocs plugin-config validation in TechDocs (7.7, marquee-adjacent — retest
markdown_extensions/hooks/pluginsdicts on patched builds since the extension-config gate was the miss), c36c Bitbucket Server catalog event repo-filter inconsistency (catalog confinement cluster), g734 Sentry scaffolder action improper input validation (8.5 — scaffolder actions other than git/Confluence keep their own unvalidated params; inventoryscaffolder-backend-module-*packages as separate sinks, Sentry-token-bearing action = SSRF/cred-relay candidate).