Skip to content

fix: dicht systemische template-injectie in OPI-manifesten#77

Merged
uittenbroekrobbert merged 4 commits into
mainfrom
fix/template-injection-sweep
May 26, 2026
Merged

fix: dicht systemische template-injectie in OPI-manifesten#77
uittenbroekrobbert merged 4 commits into
mainfrom
fix/template-injection-sweep

Conversation

@anneschuth

Copy link
Copy Markdown
Member

Probleem

Dezelfde injectieklasse als de reeds gemergede auth-wall sidecar-fix, maar verspreid over de deployment- en ingress-templates. opi/generation/manifests.py rendert met autoescape=False en tenant-bestuurde waarden uit het project-YAML werden ongequote (of naief dubbel-gequote met "{{ x }}") in YAML-scalars geinterpoleerd.

Bevestigde paden:

  • deployment.yaml.jinja: mountPath: {{ storage['mount-path'] }} (volledig ongequote), value: "{{ env_value }}" en - name: {{ env_name }} (naieve dubbele quote, breekt op " of newline), plus de volumes-blok. Bron: storage mount-path en env-vars uit project-YAML, ongevalideerd. Een newline-payload levert een willekeurige podSpec op (privileged: true / hostPath) die ArgoCD vervolgens toepast.
  • ingress.yaml.jinja: rewrite "^{{ path }}/?(.*)$" "{{ rewrite_base }}/$1" break; binnen een nginx configuration-snippet. Bron: component match/rewrite uit project-YAML, ongesanitiseerd, dus rauwe nginx-config-injectie (auth-bypass / proxy_pass-SSRF).
  • Amplifier: argocd-appproject.yaml.jinja met clusterResourceWhitelist/namespaceResourceWhitelist op group: '*' kind: '*'.

Wijzigingen

  • deployment.yaml.jinja: elke tenant-bestuurde scalar (env-naam/-waarde, storage naam/mount-path/pvc-naam/size, imageURL) via | tojson. Een JSON-string is een geldige YAML-flow-scalar; tojson escapet quotes en newlines, zodat uitbreken naar sibling-keys onmogelijk wordt.
  • ingress.yaml.jinja: match/rewrite worden bij de bron gevalideerd tegen een strikte URL-pad-charset (_sanitize_path_value / _normalize_path_config in project_file_handler.py) voordat ze enige template bereiken. Daardoor kunnen ze geen nginx-directives, quotes of newlines in de snippet injecteren. De nginx rewrite-directive blijft een letterlijke nginx-regel (tojson zou de regex breken), maar de inputs zijn nu charset-begrensd. Overige spec-scalars (hostname, path, service_name, tls_secret_name, haproxy rewrite-target) via | tojson, undefined-tolerant gemaakt.
  • argocd-appproject.yaml.jinja: */* vervangen door de expliciete minimale set kinds die OPI daadwerkelijk genereert (afgeleid uit alle kind: in manifests/*.jinja): cluster-scoped enkel Namespace; namespaced ConfigMap, Secret, Service, PersistentVolumeClaim, Deployment, Ingress, NetworkPolicy, Issuer, CNPG Cluster, VolumeSnapshot. De feitelijk via de user-AppProject gesyncte manifesten (deployment, service, allow-all-network-policy, ingress, configmaps/secrets/pvc/postgresql-cluster/issuer) vallen hier allemaal binnen. Backup/restore-pods worden out-of-band door connectors toegepast, niet via deze AppProject. Defense in depth die de amplifier sluit.

Testplan

  • Nieuwe regressietest tests/test_template_injection_sweep.py (21 tests): rendert de echte deployment.yaml.jinja en ingress.yaml.jinja via render_template (zonder de FastAPI-importketen) met een payload "\n privileged: true\n x: " voor mount-path, env-waarde en hostname, en assert dat de geparste YAML geen geinjecteerde sibling-keys bevat en de waarde intact blijft. Normale waarden produceren een geldig manifest. Plus charset-validatie van _sanitize_path_value/_normalize_path_config (legitieme paden /, /api, /v1/users, /kader geaccepteerd; injectie-payloads afgewezen).
  • uv run pytest tests/test_template_injection_sweep.py tests/test_manifests.py tests/test_templates.py tests/test_multi_path_ingress.py tests/test_actual_template.py tests/test_service_path.py --noconftest: alle groen (138 tests). simple-example.yaml rendert ongewijzigd; de repositories[].path daarin is een git-subdir, niet een component-routing-pad, en wordt niet door de sanitizer geraakt.
  • uv run ruff check + uv run ruff format + uv run pyright op de gewijzigde Python-bestanden: schoon (0 errors).

Onafhankelijk / pre-existing

De volledige suite faalt bij collection door een pre-existing omgevings-incompatibiliteit (Python 3.14.0b4 + gepinde pydantic 2.12.5: import fastapi faalt). Dat staat los van deze wijziging en is hier niet aangeraakt; de nieuwe test draait geisoleerd via render_template met --noconftest. De auth-wall sidecar (sidecar-authorization-wall.yaml.jinja) en argo-repository*.jinja zijn bewust niet aangeraakt (reeds gefixt).

@uittenbroekrobbert

Copy link
Copy Markdown
Contributor

Sterke gerichte fix — | tojson voor de drie templates, charset-validator op de nginx-snippet-bron en ingeperkte AppProject-whitelist zijn elk goed gekozen voor hun eigen sub-threat. De 21 tests in test_template_injection_sweep.py zijn pittig (echte template-rendering met newline-payloads, geen mocks). Whitelist klopt — cross-check tegen alle kind: in manifests/*.jinja is sluitend.

Scope-volledigheid — overgebleven templates

Van de 28 manifest-templates raken er 6+ dezelfde primitive ("{{ tenant_scalar }}" of bare {{ tenant_scalar }}) zonder | tojson:

  • service.yaml.jinja (name, namespace, project.name, labels.app)
  • allow-all-network-policy.yaml.jinja (name, namespace)
  • network-policy.yaml.jinja (name, namespace, port, pod_selector | indent — rauwe YAML-indent van tenant-string)
  • pvc.yaml.jinja (name, namespace, storage_class_name, size, source_pvc_name, access_modes)
  • argocd-application.yaml.jinja (repoURL, targetRevision, repoPath, argo_project, name, namespace)
  • postgresql-cluster.yaml.jinja (database_config.image, storage/resources-velden)
  • issuer-letsencrypt.yaml.jinja (issuer_name, contact_email, issuer_secret_name)
  • backup/restore pod-templates (kopia_hostname/kopia_source/kopia_password/db_*/source_bucket_* — in shell-args en env-values)

naming.generate_unique_name (opi/utils/naming.py:177) bouwt name op uit deployment_name + component_name zonder sanitization, dus de injectie-payload uit deze PR ("\n privileged: true\n x: ") werkt via die templates onverminderd.

Maar — gemitigeerd door PR #68 augmentatie

Ik heb op fix/project-schema-validation (PR #68) drie augmentaties gepushed die de injectievectoren bij de bron afsluiten:

  • \Z anchors op alle patterns → trailing-\n wordt geweigerd
  • env-vars value-pattern weigert CR/LF/NUL → de hoofdvector uit deployment.yaml.jinja (die deze PR wel raakt)
  • image en repositories[].url patroon-validatie

Plus: deze PR's AppProject-whitelist beperkt cluster-scoped resources fors. De namespace-scoped uitbraak (privileged: true op een container, sibling-keys in Service/Ingress) blijft technisch mogelijk via de niet-getojsonneerde templates, maar de schema-laag in #68 sluit de meeste payloads bij ingang af.

Aanbeveling

Mergen als deze PR — de gefixte templates dichten de exploitabele paden, AppProject-whitelist beperkt blast radius, en #68-augmentatie filtert payloads bij ingang.

Direct na merge: follow-up PR voor de overgebleven 6+ templates met dezelfde aanpak:

  1. service.yaml.jinja (hoogste prio — multi-tenant naam-labels)
  2. pvc.yaml.jinja
  3. argocd-application.yaml.jinja
  4. allow-all-network-policy.yaml.jinja
  5. network-policy.yaml.jinja (let op pod_selector | indent — dat is een aparte fix, niet alleen tojson)
  6. postgresql-cluster.yaml.jinja
  7. issuer-letsencrypt.yaml.jinja
  8. backup/restore pod-templates (kopia-args zijn shell-doorgegeven dus eigen klasse)

Plus de tweede laag — project_v2.json patterns op deployments[].name/namespace, components[].name, deployment-component.reference, storage[].name. Zodat de naming-helpers ook bij ingang gevalideerd zijn.

Klein punt

_normalize_path_config weigert nu rewrite: "" (was eerder OK). In projects/ geen voorbeelden, dus geen production-breakage, wel een gedragsverandering die in de PR-tekst zou mogen als release-note.

Test-uitbreiding bij follow-up

test_template_injection_sweep.py heeft 21 tests maar mist:

  • env_name injection (alleen env_value getest)
  • imageURL injection met | tojson
  • storage.name / storage.pvc_name / storage.size
  • E2E-payload tests voor de niet-aangeraakte templates

Allemaal toe te voegen in de follow-up, met dezelfde test-shape (render → parse → assert geen sibling-keys).

@uittenbroekrobbert uittenbroekrobbert force-pushed the fix/template-injection-sweep branch from ba27a37 to 4bb6f6a Compare May 19, 2026 08:02
@uittenbroekrobbert

Copy link
Copy Markdown
Contributor

Follow-up: rebased + audit afgerond, LGTM voor merge

`ba27a375` (service.yaml injectie-fix) pakte mijn aanbevolen follow-up #1 exact op — `service.yaml.jinja` quote nu `name`, `namespace`, `project.name` via `| tojson` met undefined-tolerant default. Consistent met deployment/ingress.

Rebase op main was nodig (merge-conflict in `ingress.yaml.jinja` en `project_file_handler.py`). Rebased + force-pushed (`4bb6f6ab`):

  • `ingress.yaml.jinja`: main's `external_dns_target` annotatie behouden, met `| tojson` op de waarde voor consistentie met de rest van de template.
  • `project_file_handler.py`: main's tweede `_normalize_path_config` (zonder sanitization) verwijderd; alle callers gebruiken de sanitizing versie (lijn 125). Main's callers (`extract_component_paths`, `extract_deployment_component_paths`) aangepast naar de per-item list-comprehension vorm zodat ze met de sanitizing helper werken. Schema-key blijft singular `path` (zoals op main, schema lijn 191/316/402/547).

Verificatie deep-audit

  • 24/24 tests in `test_template_injection_sweep.py` groen (incl. service-manifest extension)
  • 143/143 in de bredere template-suite (`test_manifests`, `test_templates`, `test_multi_path_ingress`, `test_actual_template`, `test_service_path`)
  • Pre-push hooks volledig groen: ruff check, ruff format, pyright, djlint, pytest unit, pip-audit
  • `service.yaml.jinja` patroon hetzelfde als deployment/ingress: `{{ (x | default('', true)) | tojson }}`

Follow-up nog steeds nodig (uit eerdere comment)

Service.yaml is nu klaar; nog open:

  1. `pvc.yaml.jinja`
  2. `argocd-application.yaml.jinja`
  3. `network-policy.yaml.jinja` (let op `pod_selector | indent` — eigen klasse)
  4. `postgresql-cluster.yaml.jinja`
  5. `issuer-letsencrypt.yaml.jinja`
  6. backup/restore pod-templates (kopia shell-args — eigen klasse)
  7. Schema-laag (`project_v2.json` patterns op naming-helper bronnen)

LGTM voor merge in huidige vorm.

# the safe URL-path charset to prevent nginx configuration-snippet
# injection (see _sanitize_path_value).
if isinstance(path_config, str):
logger.debug(f"Found single path '{path_config}' for component '{component_name}'")
@uittenbroekrobbert

Copy link
Copy Markdown
Contributor

Follow-up issues aangemaakt

@uittenbroekrobbert uittenbroekrobbert force-pushed the fix/template-injection-sweep branch from 4bb6f6a to 18641ab Compare May 21, 2026 11:50
anneschuth and others added 3 commits May 22, 2026 20:17
Zelfde injectieklasse als de reeds gefixte auth-wall sidecar, maar verspreid
over de deployment- en ingress-templates. ManifestGenerator rendert met
autoescape=False en tenant-bestuurde scalars werden ongequote (of naief
dubbel-gequote) geinterpoleerd. Een newline plus dubbele quote in bijv. een
storage mount-path of env-waarde liet een aanvaller siblings in de podSpec
injecteren (privileged: true / hostPath), die ArgoCD vervolgens toepast.

- deployment.yaml.jinja: alle tenant-bestuurde scalars (env-naam/-waarde,
  storage naam/mount-path/pvc/size, imageURL) via | tojson; een JSON-string
  is een geldige YAML-flow-scalar en escapet quotes en newlines.
- ingress.yaml.jinja: match/rewrite worden bij de bron gevalideerd tegen een
  strikte URL-pad-charset (_sanitize_path_value / _normalize_path_config in
  project_file_handler.py), zodat ze geen nginx-directives in de
  configuration-snippet kunnen injecteren (auth-bypass / proxy_pass-SSRF).
  Overige scalars in de spec via | tojson.
- argocd-appproject.yaml.jinja: cluster/namespace resource-whitelist
  teruggebracht van */* naar de expliciete minimale set kinds die OPI
  daadwerkelijk genereert (defense in depth die de amplifier sluit).
- Regressietest test_template_injection_sweep.py: rendert de echte templates
  met kwaadaardige en normale waarden en controleert de geparste YAML.

Bestaande manifest-, template- en multi-path-ingress-tests blijven groen;
simple-example.yaml rendert ongewijzigd.
De sweep quote-de tenant-gestuurde scalars in deployment.yaml.jinja en
ingress.yaml.jinja, maar service.yaml.jinja werd overgeslagen. Dat
template wordt in dezelfde per-component-lus gerenderd met exact dezelfde
tenant-gestuurde waarden (name = deployment-component, namespace,
project.name). Component- en deployment-namen zijn in het projectschema
($defs) ongevalideerd (type: string), dus een newline plus quote in een
component- of deployment-naam brak uit de YAML-scalar en injecteerde
broer-sleutels op documentniveau van het Service-manifest dat ArgoCD
vervolgens toepast: dezelfde injectieklasse als de auth-wall fix.

name, namespace, labels.app, labels.project en selector.app worden nu
met | tojson gequote, conform het patroon van de rest van de sweep.
Regressietests toegevoegd die het echte template met newline+quote
payloads renderen en zowel uitbraak-preventie als waardebehoud
controleren; rood tegen het ongepatchte template, groen erna.
destinations.namespace pinning is de grens; binnen die ns moeten
helm-charts vrij zijn om hun resources aan te maken (ServiceAccount,
StatefulSet, Job, CronJob, etc.). Cluster-scoped blijft dicht via lege
whitelist.
@uittenbroekrobbert uittenbroekrobbert force-pushed the fix/template-injection-sweep branch from 6b3bdc0 to 842d617 Compare May 22, 2026 18:17
@uittenbroekrobbert uittenbroekrobbert merged commit e9f1cfd into main May 26, 2026
18 of 20 checks passed
@uittenbroekrobbert uittenbroekrobbert deleted the fix/template-injection-sweep branch May 26, 2026 06:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants