Skip to content

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 a7d995f deletes extra_templates from ALLOWED_MKDOCS_KEYS and adds a removal path with an opt-in override list. Pre-fix, any registered repo could set extra_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 017ace5 shows pre-fix sanitizeMkdocsYml() explicitly whitelisted pymdownx.snippets (and the pymdownx.snippets:SnippetExtension spelling) 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 old pymdown-extensions versions the inclusion stays reachable even after config sanitization (extension-default drift).
  • Order oracle. Commit e673a86 adds taskOrderFields = 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

  1. Find the portal. Backstage frontends fingerprint on /api/techdocs/metadata, /api/scaffolder/v2/, /api/catalog/entities being public-ish. Note version from /api/backend metadata 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.
  2. The order field is a free positive/negative control on any build. Send GET /v2/tasks?order=asc:secrets and ?order=asc:created_at with a low-priv token: fixed builds return 400 InputError on 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.
  3. 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.
  4. 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.yml tests inside an authorized lab Backstage only: (a) extra_templates: entries, (b) pymdownx.snippets with base_path: / / url_download: true pointing 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 for url_download), never a reverse shell; runIn: docker is containment, not mitigation, per the vendor's own text.
  5. Local publisher path check. On publisher.type: local deployments, 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.
  6. 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 any import + call resolver, and test the callable-spelling of the field — not just unknown-name rejection. The sanitizer-allow-list bypass on this page (extra_templates, pymdownx.snippets config) 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:system writing a canary, never a shell): (1) benign unknown extension name → rejection shape; (2) 'os:system'-style name with a marker arg; (3) nested plantuml_cmd/plantuml-cmd option on a whitelisted extension; (4) theme: <custom_dir-ish> keys per commit 911ca36f (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)

  1. Fingerprint the sanitizer revision behaviorally, not by package version: the opt-in requireScmUserCredentials flag and the two-band techdocs fixes mean version numbers lie about security posture. The os:system-style name probe + order=asc:secrets 400-probe + placeholder-outside-repo probe form a three-request decision table (each: reject / build-fail / succeed-under-backend-identity).
  2. 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_BUILTINS child_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 wildcardToRegexp ReDoS (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/bar variant 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: false the same as absent — the third-field-branch rule from today's wger None != None leg 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 aud accepted at Backstage — per-IdP aud check 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/plugins dicts 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; inventory scaffolder-backend-module-* packages as separate sinks, Sentry-token-bearing action = SSRF/cred-relay candidate).