Skip to content

Harden XTCE schema validation against local file read (CWE-73) and SSRF (CWE-918) - #267

Open
blakedehaas wants to merge 13 commits into
mainfrom
Issues/266-path-validation-and-ssrf
Open

Harden XTCE schema validation against local file read (CWE-73) and SSRF (CWE-918)#267
blakedehaas wants to merge 13 commits into
mainfrom
Issues/266-path-validation-and-ssrf

Conversation

@blakedehaas

@blakedehaas blakedehaas commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Hardens XTCE schema validation against two reported vulnerabilities in validate_xtce. Both share one root cause: a document's xsi:schemaLocation is attacker-controlled but was used to read files and make network requests without restriction. Fixes #266.

Problems being fixed

  • SSRF — CWE-918 (7.4 High). A crafted document could point xsi:schemaLocation at any URL, causing the validating host to issue outbound requests — e.g. the cloud metadata endpoint http://169.254.169.254/latest/meta-data/iam/security-credentials/ for IAM credential theft — and the response was cached to disk, persisting exfiltrated data.
  • LFI — CWE-73. The same field could point at an absolute or traversal filesystem path (/etc/hostname, ../../etc/passwd), causing arbitrary local files to be read as a "schema".

How we fix it

The document-supplied xsi:schemaLocation is now treated as untrusted, while an operator-supplied local_xsd remains trusted. Schema resolution is:

  1. Bundled schema (offline). The standard OMG XTCE 1.2 schema ships in the package, so the common case validates with no network request — this alone removes the SSRF surface for typical use.
  2. local_xsd (trusted). Opened directly at any path.
  3. Allowlisted download. Any other URL is fetched only if it is https on an allowlisted host (default www.omg.org). Internal/link-local targets (169.254.169.254, 127.0.0.1, private ranges) are always blocked. Local filesystem paths from a document are rejected outright — use local_xsd.

Additional hardening:

  • Downloaded content is size-capped and cached only after it validates as an XSD, so a non-schema response can never be persisted.
  • New options (API + CLI, with env-var overrides): allowed_schema_hosts (hosts or exact URLs), allow_insecure_http (dangerous opt-in; host allowlist + internal-address guard still apply), allow_schema_download. Default download stays on but allowlisted. DEFAULT_ALLOWED_SCHEMA_HOSTS is exported.

Behavior changes

  • A document whose xsi:schemaLocation is a local path or a non-allowlisted / non-https URL is now rejected — pass local_xsd, extend the allowlist, or (for http) opt in explicitly.
  • Absolute local_xsd / --local-xsd again works from any working directory (fixes a regression that rewrote it to a bare filename).

Testing

New tests/unit/test_xtce/test_validation_security.py asserts every advisory PoC is rejected with no outbound request, plus positive coverage for the allowlist (arg/env/exact-URL), the allow_insecure_http opt-in still blocking internal IPs, offline bundle validation, and cache behavior. Full suite passes; docs and changelog updated.

PR description drafted with Claude and reviewed by Gavin (@medley56).

@blakedehaas
blakedehaas requested a review from medley56 as a code owner July 20, 2026 17:04
@blakedehaas blakedehaas changed the title Update error message in validation.py path validation and ssrf Jul 20, 2026
@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.37380% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.49%. Comparing base (6de220f) to head (080a9de).

Files with missing lines Patch % Lines
space_packet_parser/xtce/validation.py 90.15% 13 Missing ⚠️
tests/conftest.py 35.00% 13 Missing ⚠️
tests/unit/test_xtce/test_validation_security.py 99.14% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #267      +/-   ##
==========================================
- Coverage   94.92%   94.49%   -0.43%     
==========================================
  Files          48       49       +1     
  Lines        3904     4163     +259     
==========================================
+ Hits         3706     3934     +228     
- Misses        198      229      +31     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@medley56
medley56 force-pushed the Issues/266-path-validation-and-ssrf branch from 158b3d6 to eb0ed24 Compare August 7, 2026 20:36
medley56 and others added 2 commits August 7, 2026 21:33
Treat a document-supplied xsi:schemaLocation as untrusted: reject local
filesystem paths (CWE-73) and restrict schema URLs to https on an allowlisted,
non-internal host (CWE-918). Bundle the OMG XTCE 1.2 schema so the standard
case validates offline with no network request.

- Bundle SpaceSystem.xsd; resolve it offline by URL before any download.
- Add allowed_schema_hosts / allow_insecure_http / allow_schema_download to
  validate_xtce and the spp validate CLI, with SPP_ALLOWED_SCHEMA_HOSTS and
  SPP_ALLOW_INSECURE_HTTP env overrides; export DEFAULT_ALLOWED_SCHEMA_HOSTS.
- Block internal/link-local IP-literal hosts (169.254.169.254, 127.0.0.1).
- Restore trusted local_xsd: absolute paths work from any cwd again.
- Cache only content that validates as XSD; cap download size; 0600 perms.
- Use accurate error codes (DISALLOWED_SCHEMA_LOCATION) and surface messages.
- Move mock_schema_download fixture to conftest; add security regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@medley56 medley56 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This review was generated with Claude and reviewed by Gavin (@medley56).

Hi @blakedehaas — thanks for kicking this off. Getting the SSRF/LFI hardening started was the hard part, and the structure you set up (a dedicated schema-load path, the caching layer, the test scaffolding) is what we built the final version on top of. As code owner I took the branch the rest of the way, and I want to walk you through what we changed and why so nothing here is a surprise.

The core reframe: a document's xsi:schemaLocation is fully attacker-controlled whenever we validate an untrusted document, so the fix has to treat it as untrusted at every step — while keeping the operator-supplied local_xsd fully trusted. The original approach guarded some of the surface but left the highest-severity path open, so we re-centered everything on that trust boundary.

What was still exploitable (and is now closed):

  • SSRF (the 7.4 High) was not actually mitigated. The scheme check allowed any http/https URL, so the advisory's own PoC — http://169.254.169.254/latest/meta-data/... — sailed through. We replaced the scheme-only check with https-by-default + a host/URL allowlist + an internal-address guard, so metadata/loopback/private targets are blocked outright.
  • LFI was only partly closed. startswith("/") missed Windows/UNC paths, and a sibling-directory string-prefix bug let some traversals through. Document-derived local paths are now rejected wholesale (any non-http(s) location) — a local schema must come through local_xsd.

What we added:

  • Bundled the OMG XTCE 1.2 schema, so the standard case validates offline with no network request — this removes the SSRF surface for the common path entirely and makes validation robust to OMG being unreachable.
  • Configurable, safe-by-default controls: allowed_schema_hosts (hosts or exact URLs), allow_insecure_http (dangerous opt-in), and allow_schema_download, all mirrored on the CLI and with env-var overrides. Default download stays on but allowlisted.

What we fixed from the first iteration: restored absolute local_xsd (it was being silently rewritten to a bare filename), stopped caching un-validated bytes, added a download size cap, gave rejections accurate/surfaced error codes, and moved the mock_schema_download fixture into conftest.py (the integration test couldn't find it, so it was erroring at setup).

Full rationale is in the changelog and docs/source/users.md; details are in the inline comments. Happy to hop on a call if any of the decisions are worth talking through — especially the "download on-by-default but allowlisted" choice, since that was the main judgment call.

# covers absolute POSIX/Windows/UNC paths, relative paths, bare filenames, and non-http schemes
# such as file:// and ftp://. Host/scheme allowlist policy is enforced later, at load time,
# after bundled-schema resolution.
if urlparse(schema_location).scheme not in ("http", "https"):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This comment was generated with Claude and reviewed by Gavin (@medley56).

LFI fix (CWE-73). The original guard here was schema_location.startswith("/"), which only catches POSIX absolute paths — it missed Windows drive paths (C:\...), UNC paths (\\server\share), and left relative traversals (../../etc/passwd) to a downstream check that had a sibling-directory prefix bug. We inverted the logic: anything that isn't an http(s) URL is treated as a local reference and rejected here, up front. A local schema now has exactly one path in — the trusted local_xsd argument. Regression-tested against /etc/hostname, ../../../etc/passwd, C:\..., UNC, and file:// in test_validation_security.py.

return False


def _enforce_schema_url_policy(schema_location: str, allowed_hosts: frozenset[str], allow_insecure_http: bool) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This comment was generated with Claude and reviewed by Gavin (@medley56).

SSRF fix (CWE-918) — the headline change. The previous version allowed any http/https URL, which means the advisory's PoC (http://169.254.169.254/latest/meta-data/iam/security-credentials/) was still fully reachable — a scheme check is not an SSRF control. This helper now enforces three things together: https only (unless allow_insecure_http is explicitly set), the host/URL allowlist (default www.omg.org; entries can be bare hosts or exact URLs, matched exactly — never a suffix test), and the internal-address guard below. All three are independent, so relaxing one (e.g. enabling http for an internal mirror) doesn't reopen the others.

return _BUNDLED_SCHEMA_DIR / filename


def _reject_internal_host(host: str | None, schema_location: str) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This comment was generated with Claude and reviewed by Gavin (@medley56).

This is the guard that deterministically kills the cloud-metadata and loopback PoCs. IP literals in the URL (169.254.169.254, 127.0.0.1, 10.x, etc.) are rejected as private/loopback/link-local/reserved regardless of the allowlist. We scoped it to IP literals on purpose: hostnames are governed by the allowlist, and doing DNS resolution here would add a network round-trip plus a TOCTOU/rebinding wrinkle. The residual DNS-rebinding case (an allowlisted hostname that resolves internally) is noted in the docstring as a documented, much narrower follow-up.

"""
location = str(schema_location)

# 1. Bundled schema (offline, no policy needed — it is our own file).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This comment was generated with Claude and reviewed by Gavin (@medley56).

Bundling (offline by default). Resolution order is now: (1) bundled schema → (2) trusted local_xsd → (3) allowlisted download. Because the OMG XTCE 1.2 schema ships in the package (xtce/schemas/), a document referencing the standard www.omg.org/.../SpaceSystem.xsd URL validates from disk with zero network calls — verified by a test that patches urlopen and asserts it's never called. This both removes the SSRF surface for the common case and makes validation resilient to OMG downtime. Only XTCE 1.2 is bundled today; a 1.1 document falls through to the allowlisted-download path (OMG-hosted, so it still works). Adding 1.1 offline is a one-line _BUNDLED_SCHEMAS entry.

# local_xsd is trusted, caller-supplied input: open it directly, at whatever path
# (absolute or relative) the caller provided. No confinement is applied because this
# is not the attacker-controlled surface (unlike document-derived xsi:schemaLocation).
schema_location = str(local_xsd)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This comment was generated with Claude and reviewed by Gavin (@medley56).

Trust boundary + regression fix. The prior version treated local_xsd like untrusted input: an absolute path was rewritten via relative_to(cwd()), falling back to just the basename when that failed — which silently broke absolute --local-xsd from any other working directory, or worse, validated against a same-named file that happened to sit in cwd. local_xsd is operator-supplied and trusted, so it's now opened directly at whatever path was given. The confinement effort belongs on the untrusted document path (above), not here. Restored behavior is covered by test_validate_xtce_absolute_local_xsd_is_used_directly.

)

schema, version = _parse_schema_content(schema_content, schema_location)
# Only cache content that validated as an XSD.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This comment was generated with Claude and reviewed by Gavin (@medley56).

Cache hardening. Previously the raw HTTP response was written to ~/.cache/... before it was parsed, so a non-schema body (an SSRF probe response, an error page, exfiltrated data) would persist on disk — which the advisory explicitly called out. Now we download with a size cap (MAX_SCHEMA_BYTES), validate the bytes as an XSD, and only then write to cache. We deliberately cache the raw validated bytes (not the post-_fix_known_schema_issues version) so the download-avoidance benefit is preserved without baking a fixup into the cache — a future improvement to the fix logic still applies on the next load. Cache files are also written 0600.

self.validation_result = validation_result
# Optional machine-readable code so callers (e.g. _validate_xtce_schema) can map
# the failure onto the correct ValidationResult error_code instead of a generic one.
self.error_code = error_code

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This comment was generated with Claude and reviewed by Gavin (@medley56).

Small but worth flagging: in the first iteration the "Path traversal detected" error was raised inside a try whose own except Exception immediately re-wrapped it as a generic "Invalid schema path", so the real reason never reached the caller — and every rejection from _find_schema_url was reported as MISSING_SCHEMA_LOCATION even when a location was present but disallowed. Errors now carry an explicit error_code, so a disallowed/blocked location surfaces as DISALLOWED_SCHEMA_LOCATION with its actual message, distinct from a genuinely missing one.

"ftp://example.com/schema.xsd", # non-http scheme
],
)
def test_document_url_ssrf_is_rejected(location):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This comment was generated with Claude and reviewed by Gavin (@medley56).

New regression suite anchored directly to the advisories: every PoC (metadata IP, loopback, arbitrary external host, absolute/relative/Windows/UNC/file:// local paths) asserts rejection and that urlopen is never called; plus positive coverage for the allowlist (arg, env var, exact-URL entries), the allow_insecure_http opt-in still being blocked by the internal-IP guard, offline bundle use, and the cache behavior. Separately, the mock_schema_download fixture moved to tests/conftest.py — it lived in this module before, so tests/integration/test_cli.py::test_validate_xtce couldn't resolve it and was erroring at setup rather than running.

@medley56 medley56 changed the title path validation and ssrf Harden XTCE schema validation against local file read (CWE-73) and SSRF (CWE-918) Aug 7, 2026
@medley56
medley56 requested a review from djesernik August 7, 2026 23:52
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.

path validation and ssrf

2 participants