Skip to content

fix(control-plane): confine legacy $ref, and enforce the uri rule ConfigSource declares - #221

Merged
mdheller merged 2 commits into
mainfrom
fix/control-plane-legacy-ref-confinement
Jul 30, 2026
Merged

fix(control-plane): confine legacy $ref, and enforce the uri rule ConfigSource declares#221
mdheller merged 2 commits into
mainfrom
fix/control-plane-legacy-ref-confinement

Conversation

@mdheller

Copy link
Copy Markdown
Contributor

Clears the sourceos-spec Copilot backlog. Two of the five unanswered threads were live defects; the other three are answered on-thread with reasons rather than fixed.

1. Unconfined $ref — CI-side path traversal

tools/validate_control_plane_examples.py resolved the wrapper's legacy $ref with no confinement:

validation_schema_path = (schema_path.parent / legacy_ref).resolve() if legacy_ref else schema_path

$ref is schema content, so it is attacker-controlled the moment anyone can open a pull request.

Asking what calls it, because a finding in unreachable code is not worth a fix:

  • make validate depends on validate-control-plane-examples
  • .github/workflows/validate-ops-history.yml runs make validate

So it runs in CI. Demonstrated against unpatched main — planting a ../../../.. ref into ReleaseSet.json:

planted $ref = ../../../../../../../../../var/folders/.../pilfered.json
RESULT: ESCAPED — validator READ the outside file and exited 0

The boundary is schemas/, not the wrapper's directory

Worth recording, because the obvious fix is wrong. My first attempt confined to schema_path.parent — a "legacy ref names a sibling" rule. That broke the gate, because two wrappers legitimately point up:

wrapper $ref resolves to
ReleaseSet.json ../ReleaseSet.json schemas/ReleaseSet.json
Fingerprint.json ../Fingerprint.json schemas/Fingerprint.json
BootReleaseSet.json ./boot-release-set.schema.json sibling

The tighter rule was not the safer one, it was just the wrong one. Confinement is to the schema tree.

2. ConfigSource.uri — a declared rule nothing enforced

The uri description has always read "Required for git, http, and oci kinds". The schema typed it ["string","null"] with no condition, so a git source with no location validated clean. A description stating a requirement the schema does not check is a declared-unenforced rule: the reader believes a guarantee the contract never made.

Now an if/then. Behaviour, verified against every kind:

ACCEPTED  committed example (git + uri)
REJECTED  git with uri ABSENT      <- 'uri' is a required property
REJECTED  git with uri = null      <- None is not of type 'string'
REJECTED  http/oci, same both ways
ACCEPTED  inline with uri = null   (stays legal)
ACCEPTED  bundle with uri = null   (stays legal)

Evidence

check result
traversal repro, fix reverted ESCAPED — read the outside file, exited 0
traversal repro, fix restored CONFINED — refused the escaping $ref
ConfigSource, schema reverted accepts a git source with no uri
ConfigSource, schema restored 'uri' is a required property
make validate OK: validate
ajv compile ConfigSource.json --spec=draft2020 valid

The other three threads — rejected, with reasons, on-thread

The ADR findings on #12/#13 ask to renumber ADR-evidence-fabric-boundaries-v0-1.md to "the next available number (currently 0006)" to match convention. The premise does not hold: docs/adr/ uses 0001 eight times, 0012 three times, and 0002/0006/0009/0010 twice each. Following the advice would have created another collision — and 0006 is now itself used twice.

There is a real issue nearby that Copilot did not name: 0006-evidence-fabric-boundaries.md now exists with the conventional header, covering the same decision as ADR-evidence-fabric-boundaries-v0-1.md, with different content. Two ADRs for one decision. Flagged on the thread rather than resolved here — picking the canonical one is an editorial call, not a cleanup, and loose files in this estate are usually deliberate.

…figSource declares

Two findings Copilot raised on #73 that were never answered. Both are live on main.

1. tools/validate_control_plane_examples.py — unconfined $ref path join

   `(schema_path.parent / legacy_ref).resolve()` took `legacy_ref` straight from
   schema content. `$ref` is content, so it is attacker-controlled the moment
   anyone can open a pull request, and this validator runs in CI: `make validate`
   depends on `validate-control-plane-examples`, and validate-ops-history.yml runs
   `make validate`.

   Demonstrated against unpatched main: a planted `../../../..` ref made the
   validator read a file outside the repository entirely, and it exited 0.

   The boundary is the `schemas/` tree, not the wrapper's own directory. Both
   shapes in use are legitimate and had to keep working:
       ReleaseSet.json     -> "../ReleaseSet.json"              (up into schemas/)
       BootReleaseSet.json -> "./boot-release-set.schema.json"  (sibling)
   Confining to the wrapper's directory — the first thing I tried — rejects the
   former and breaks the gate. The tighter rule was not the safer one, just wrong.

2. schemas/ConfigSource.json — a declared rule nothing enforced

   `uri`'s description has always said "Required for git, http, and oci kinds",
   while the schema typed it `["string","null"]` with no condition. A git source
   with no location validated clean. That is a declared-unenforced rule: the
   reader believes a guarantee the contract never made. Now an if/then, so the
   schema says what the prose says. inline and bundle keep their null uri.

Both fail when reverted:
  - reverted validator: "ESCAPED — read the outside file and exited 0";
    restored: "CONFINED — refused the escaping $ref".
  - reverted schema: accepts a git ConfigSource with no uri;
    restored: "'uri' is a required property".

`make validate` green (OK: validate). ajv compile of ConfigSource valid.
Copilot AI review requested due to automatic review settings July 30, 2026 03:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR tightens the normative control-plane validation and schema contracts by (1) confining legacy wrapper $ref resolution to the repository schemas/ tree to prevent CI runner path traversal, and (2) enforcing the already-declared requirement that ConfigSource.uri must be present for git/http/oci sources.

Changes:

  • Add resolve_legacy_ref() to confine wrapper $ref targets to schemas/ and fail closed on escapes.
  • Enforce ConfigSource.uri as required (and non-null, non-empty) when sourceKind is git, http, or oci.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
tools/validate_control_plane_examples.py Adds confined $ref resolution to prevent schema-controlled path traversal during CI validation.
schemas/ConfigSource.json Adds conditional schema logic to require uri for git/http/oci sources, matching the documented contract.
Comments suppressed due to low confidence (1)

tools/validate_control_plane_examples.py:44

  • After confinement, the code still allows $ref to resolve to schemas/ itself (or another non-file path inside the tree). That will fail later with a low-level IsADirectoryError/FileNotFoundError rather than a clear schema error. Consider validating that the resolved target is a file before attempting to read it, and raise ValueError with context.
    base = SCHEMAS.resolve()
    target = (schema_path.parent / legacy_ref).resolve()
    if target != base and base not in target.parents:
        raise ValueError(
            f"{schema_path.name}: $ref {legacy_ref!r} resolves to {target}, outside {base}; "

Comment on lines +38 to +39
if "\x00" in legacy_ref:
raise ValueError(f"{schema_path.name}: $ref contains NUL")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct — fixed in 96a1b5d.

Confirmed by execution against the version you reviewed:

['./a.json']  -> TypeError: unsupported operand type(s) for /: 'PosixPath' and 'list'
{'x': 1}      -> TypeError: unsupported operand type(s) for /: 'PosixPath' and 'dict'
42            -> TypeError: argument of type 'int' is not iterable
True          -> TypeError: argument of type 'bool' is not iterable

The point generalizes usefully: $ref is JSON content, so its type is attacker-controlled as well as its value. I guarded the value (traversal, NUL) and left the type unguarded, which is a half-check — and a TypeError raised from inside a path helper is exactly the "crashed somewhere" outcome this validator's exit-code discipline exists to avoid.

Now an explicit isinstance raising ValueError naming the offending type, consistent with the escape and NUL branches beside it:

['./a.json'] -> ValueError: ReleaseSet.json: $ref must be a string, got list
42           -> ValueError: ReleaseSet.json: $ref must be a string, got int

Both legitimate shapes still resolve (./boot-release-set.schema.json sibling, ../ReleaseSet.json parent), make validate is green, and the traversal repro still reports CONFINED.

Copilot on this PR. `$ref` is JSON content, so its TYPE is attacker-controlled
as well as its value. A list or dict ref died on
`unsupported operand type(s) for /: 'PosixPath' and 'list'`, and an int on
`argument of type 'int' is not iterable` — TypeErrors raised from inside a path
helper, where what the operator needs to read is "this schema's $ref is
malformed".

Now an explicit isinstance check raising ValueError with the offending type,
consistent with the escape and NUL branches beside it.
@mdheller

Copy link
Copy Markdown
Contributor Author

Keeper pass — suppressed-channel finding evaluated

Copilot left one finding on this PR that is not in pulls/221/comments and not in issues/221/comments. It exists only inside the <details>Comments suppressed due to low confidence</details> block on the review body (review submitted 2026-07-30T03:43:53Z). Recording it here so it lives in a channel the backlog sweep can actually see.

Finding (tools/validate_control_plane_examples.py:44): after confinement, a $ref can still resolve to schemas/ itself (or another directory inside the tree). That passes the guard and then dies in read_text() with IsADirectoryError rather than a clear schema error.

Verdict: real, reproduced, not a merge blocker. I ran resolve_legacy_ref at this head SHA against ten inputs:

input expected actual
../ReleaseSet.json allow allowed -> schemas/ReleaseSet.json
./boot-release-set.schema.json allow allowed -> sibling
../../../../etc/passwd refuse refused, ValueError
/etc/passwd (absolute) refuse refused, ValueError
../../schemas-evil/x.json refuse refused, ValueError
../../../../../../tmp/pilfered.json refuse refused, ValueError
["a"] (non-string) refuse refused, $ref must be a string, got list
7 (non-string) refuse refused, $ref must be a string, got int
NUL-embedded ref refuse refused, $ref contains NUL
../ (resolves to schemas/) allowed, returns a directory

The containment property holds in both directions — including the schemas-evil prefix-confusion case, which is refused because the check is base not in target.parents and not a string startswith. The last row is the reported defect: error quality, not an escape. Nothing outside schemas/ is reachable.

Note also that the inline thread on :47 (non-string $ref produces a TypeError) is already fixed at this head — the isinstance(legacy_ref, str) check precedes the NUL test, which is why rows 7 and 8 return a clean ValueError. Copilot reviewed that one against an earlier commit.

Follow-up (not blocking): add if not target.is_file(): raise ValueError(...) before the read.

Second item, on the schema change

ConfigSource.json now requires uri for git|http|oci. examples/config_source.json exercises the positive case (sourceKind: "git" with a uri) and it is green. There is no negative example — a git source with no uri that must fail — so the new rule is asserted but its teeth are untested in CI. Filed as a follow-up. The rule itself is correct JSON Schema: top-level uri is ["string","null"], the then narrows to a non-empty string, and the intersection is the intended constraint.

@mdheller
mdheller merged commit 959cd28 into main Jul 30, 2026
7 checks passed
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.

2 participants