From bf05d21bfd8fa9aaeb0f2340c649a567d79026d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Z=20Sink=C3=B3?= Date: Sun, 8 Nov 2026 15:20:00 +0100 Subject: [PATCH] docs: D5, what the module does with a caller-given host and base_path, and where target data should come from D5 is split in two. Part A measures, on a clean git archive of devel 2ac19ec with the module's mock host seam, what the module builds from a caller-given host and base_path and, with the Relay's egress rule read at 55a899f and transcribed (not run), what that rule makes of it. Part B is a proposal, not an input validator: the host, base_path, key_id, revision and OCID belong to the realization context, the provider contract and the observed state, and are meant to be derived from authenticated sources, not supplied freely to a provider module. Measured: host and base_path are used as written; a host with userinfo, a fragment, a query or a path parses as another host and the module still asks to sign the raw value as the host line; the wildcard *.oraclecloud.com allows any OCI service and region and the manifest has no path rule; a base_path with a query or fragment turns the resource path into a query or fragment; a newline in base_path puts a line of its own into the string handed to sign before anything refuses it; one function builds the path for observe, execute, destroy and invoke. The registry records no API version, base path or host. The probes, their output and the exact environment ship with the document. Nothing is implemented; no approval or derivation is verified by anything described. Documentation and measurement files only. --- [signing-metadata] key = cic-my-sign-key signature = vault:v1:MEUCIQDbj2Qh2FFIMAl0mJVTpnGvFYRJCUfafyxDyuTiymPBcQIgOL3TDsgaFRbxvQu7naUHjHAW2nkPwelQACDRkXv5EAk= hash-algorithm = sha256 digest = q072eGVfTg3MzhxyQVnlL5PHmr1bdwypgkJtybdOmlw= [certificate] -----BEGIN CERTIFICATE----- MIICBjCCAaygAwIBAgIUSnRMR6RPnEbg296XWPOqq/u5PCwwCgYIKoZIzj0EAwIw QzELMAkGA1UEBhMCSFUxGTAXBgNVBAoMEENlbnRyYWxJbmZyYUNvcmUxGTAXBgNV BAMMEENJQyBEZXZlbG9wZXIgQ0EwHhcNMjYwMzIwMTMyMjU5WhcNMjYxMjMxMTMy MjU5WjBFMQswCQYDVQQGEwJIVTEZMBcGA1UECgwQQ2VudHJhbEluZnJhQ29yZTEb MBkGA1UEAwwSR2Fib3IgWm9sdGFuIFNpbmtvMFkwEwYHKoZIzj0CAQYIKoZIzj0D AQcDQgAEIG2CVmTfmLB9pLLclj7YmP2eedAjklpy4LGrU2ijoiy6Xqpuybv7OgJe i+ez31s65NEV8+X/ByeX1cstR988z6N8MHowCQYDVR0TBAIwADAdBgNVHQ4EFgQU yZN6AIX/TNnIJ9GwAa/NRN3ujHAwHwYDVR0jBBgwFoAUXn6CHYzPUqU4JVP8g+OS WeDYjhcwDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEF BQcDBDAKBggqhkjOPQQDAgNIADBFAiEA+bFzXRoJ4PCQbhAAtpkcMjt0vNj5rEW0 lOMBGDNyaWkCIB1vmM7PcZzv/c9bIrxF5kqv6QXomouhByUfeNUTbpKW -----END CERTIFICATE----- --- MANIFEST.sha256 | 6 +- docs/design/README.md | 4 + .../measurements/target-and-path/README.md | 47 +++++ .../measurements/target-and-path/output.txt | 65 ++++++ .../target-and-path/probe_test.go.txt | 156 ++++++++++++++ .../specs/target-origin-and-path-safety.md | 195 ++++++++++++++++++ 6 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 docs/design/measurements/target-and-path/README.md create mode 100644 docs/design/measurements/target-and-path/output.txt create mode 100644 docs/design/measurements/target-and-path/probe_test.go.txt create mode 100644 docs/design/specs/target-origin-and-path-safety.md diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index c6a728e..6b1159f 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -78,7 +78,6 @@ 5432506f15d65fa59572362a70db4bc403c13b163ec961ecb2951d7f98d093d0 module/go.mod 546e7c533da4b86b8817ee426865df0d11e24d2aa1580647634345f452a5017e module/canonical_json_test.go 57980b8a84a13ffad62b7295e3095bd635f8894716665d0654dfb54eebbf10e5 docs/contracts/en/host-expectations.md -5b7f1f228e76f1240526022991728bbe1834c4c06b19ed5f52a15441e54dd6ee docs/design/README.md 5c650f84f688d4d965721df74779228bb6fdfa47d5c08c46e8274e6d97b212c8 module/testdata/vcn-v1/lifecycle-map.json 5ca6da66b9de8a04ca71f1c18ba2026ad02b8c1c70c700a432058da267e6b27d module/correspondence/vcn.json 5d51889fc8cd00c4c0bf1462b01be8c4d6754e2a4e59dd8e7f0599c610c64472 tools/vault-sign-agent.sh @@ -110,6 +109,7 @@ 794a6e83b11358f4f2105408d7798a3495fa992d09d469823d0148233e6df57e module/relay_integration_test.go 79f7c4ad204e39c79b7b55a0ee925a603250bb49610366a98e3210e1b925abd3 module/testdata/vcn-v1/cases/malformed-cidr/expected/observed.oci.json 7b2748a353c7aad1822022fbbb4a6271c54ef84a0fe5f3535f43850306857756 docs/design/measurements/destroy-invoke/README.md +7b465f60f85afd0ff9b2e98cba72461a7fb7aa3a3bf46954ffd5e2461f71ea94 docs/design/measurements/target-and-path/probe_test.go.txt 7b84214b85a3fd95e9153528df1fa1522ff026df1f561752d95c715250301ca6 tools/vault-sign-agent.md 7df714ce12b9abfb18357c4fc25e67b8f23e6557b72ee1ea4c2a4a5d4a596c43 docs/contracts/hu/host-expectations.md 7e18dd94212c07eb66f6db357067b71e870dc64d43336f9c1db3c08b30adf0f4 module/testdata/vcn-v1/cases/not-found/expected/observed.registry.json @@ -129,6 +129,7 @@ 8a269de9b6799fbda2837466293f020125b5376b4e0794eb25d8d4146af8aa0b docs/design/specs/relay-sign-send-interface.md 8c607fe4c6cb87f10a73accd46657733fffaf3c0d98c1197a48ffacb7d2c9803 module/testdata/vcn-v1/cases/desired-absent/expected/observed.registry.json 8c607fe4c6cb87f10a73accd46657733fffaf3c0d98c1197a48ffacb7d2c9803 module/testdata/vcn-v1/cases/field-absent/expected/observed.registry.json +8c9322cefff799c7068b28361644ff705988c081ff0978558aba0e047c0a862b docs/design/measurements/target-and-path/README.md 8c956255f18ea7baa83eec246e38e9138b9e29284972a2fa95084a70ea97d6a9 capability-manifest.schema.yaml 8db6dccfdd2342641ad77532d9f66b305f74920ecc116371e81d8a36c873c7fb tools/oci-extract/policy.go 8e2542ec21d36fd70c95f3f80573839906d137478edca225b81342f8cbda16e9 tests/test_ci_workflow_order.py @@ -140,6 +141,7 @@ 97b1851090675543e10f2d61ec390cf54d218b1bd0985b4f8195c6aa0ddef677 module/testdata/plan_digest_vectors.json 97f3f81e90d839c74efbe7ad2e1732b08ac0612a38499d6ef5dedcc637c64eb6 module/testdata/vcn-v1/cases/malformed-cidr/oci/response.json 9824bb348604556c7f55452fcc9e396c203f51311736e1c501f53ba4fdc127aa module/module_loadtest_test.go +985f45c885b583cc522db0a6570aea8baf9db816c9cf3409af7e60d3c204d6e6 docs/design/specs/target-origin-and-path-safety.md 9b18a2109d250d26d57d3cec5ed9c67bd803d34e9592ab98fb1826a5dc457a8f module/cicflow_mock_test.go 9b6f006a0e21f7baaad8509b3323e4c7e5bc4d16ab8cc165a70af855f78da460 module/testdata/canonical_json_vectors.json 9ba2ca374fd1900e82a2b6a9c27233e1603dce89b1908592b2a6cc93313be84c docs/hu/wasm-module-authoring.md @@ -169,6 +171,7 @@ b73da39348121a3ae5bf42915a4ab3c35653b7c9fb5a4c575a9ce0d89da66c7a docs/en/wasm-m b8107f8b819135d4ba3d56804ecca545c24bde54a381f5ddde7ac19815253734 docs/design/forms-integration-direction.md b8e7c23cfe46a0daf7d3441b15657e9d4b35c4bf50f9421c2841dfbf19cacc82 docs/en/architecture.yaml b9325f58a95216b240f3715a773e34fe8cdfa4d5fe48a5e7b32002a40be5cc2e tests/test_tools/test_finalize_release.py +b96d9bec819c23a253271f3f26ab999e27179cbf6381a4403f090a04bc9195f2 docs/design/README.md bb8a1743ab9f77a309bd851b4a1660f6f2420900455a5a0dfb83f4e6905968ff module/abi_manifest_test.go bbe93c718d90a03de5aab2999031c7097bd63a8e40ec14589c8dbe4364a4ec52 renovate.json bdea227cb29b579b2daaa7fa398102adc096ff9073c78bd9ec1f0dde44b89824 docs/design/relay-requirements.md @@ -180,6 +183,7 @@ c55c74bb201c91330aa8bda11dd256fe35f84b3a3a0e24a1cebe3e487bd60135 requirements.i c637082ac035d4dda23ff27a31dfd7bd9a62437db24b53d48d585897fa1874a7 module/testdata/vcn-v1/not-planned.json c6919cec77cbf13916932f91cdc9b9d32d9290282c5d87244af7adc1a30d441a module/go.sum c83bb77196b26e0f656d38b38736a488ff2aa316d995908bf9abade63fccdd67 tools/wasm_fresh_guest_test.sh +c9518b3b8d5cf39095234f9d18feef5d8ea9fd22c6d129c77e9714838bba5b02 docs/design/measurements/target-and-path/output.txt ca7e4a9412ff28a5ca3e3fd9c759a62126564d82e8892b12b6efa1cbf11d5810 tools/oci-extract/client.go cb1301d5e401e05e2c14042cb1af909dc2bc819a636b5b4e1533f4c87ed48831 docs/design/specs/approval-and-execution-state.md cb147348de6aadbcef95a82efb66d8e5252d1c82f34aba14c716747dea57d10f module/testdata/vcn-v1/cases/malformed-cidr/expected/evidence.claims.json diff --git a/docs/design/README.md b/docs/design/README.md index 6b15859..dc67e64 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -31,6 +31,10 @@ namespace it opens. Written 2026-07-18 from the design session recorded in computed over. - [specs/plan-digest.md](specs/plan-digest.md) — `plan_digest`, `target_digest`, `execution_digest` and the gate `execute` applies before it signs anything. +- [specs/target-origin-and-path-safety.md](specs/target-origin-and-path-safety.md) — D5: a + measurement of what the module does with a caller-given `host` and `base_path`, and a + proposal (concept) for where target data should come from; probes and output in + [measurements/target-and-path/](measurements/target-and-path/README.md). - [specs/destroy-invoke-binding.md](specs/destroy-invoke-binding.md) — a measurement of what `destroy` and `invoke` do today, the properties it implies, and a binding contract proposal (concept); the probes and their output are in diff --git a/docs/design/measurements/target-and-path/README.md b/docs/design/measurements/target-and-path/README.md new file mode 100644 index 0000000..5a4b899 --- /dev/null +++ b/docs/design/measurements/target-and-path/README.md @@ -0,0 +1,47 @@ +# Target data and path building: measurement of the current behaviour + +The probe and its output behind section 1 of +[`../../specs/target-origin-and-path-safety.md`](../../specs/target-origin-and-path-safety.md). It observes +what the module builds from a caller-given `host` and `base_path`, and what the CIC-Relay's +egress rule, as read in its source, makes of that. Kept unchanged so the tables can be checked. + +## Environment of the recorded run + +| | | +|---|---| +| Module commit | `2ac19ecade7131514a3103f20ab0d38cccf41521` (`devel`), taken with `git archive` into an empty directory, no working-tree residue | +| Go | go1.25.0 linux/amd64 (the repo's builder image `cic-module-oracle-cloud-builder:latest`, id `sha256:b5a3b64b391e6d4a1254b41dfa59f78025a603cfccdaacf902c408967047debf`) | +| Host | Linux 7.0.0-43-generic | +| Provider, Relay | neither is involved. The host seam is the module's own test mock (`cicFlowMock`): `sign` returns a fixed signature, `actuate` answers 200 | +| The Relay's rule | CIC-Relay at commit `55a899f`, **read, not run**: `cmd/relay/cic_flow_manifest.go` (`egressPolicy.allows` :73, `hostGlobMatch` :89, `portAllowed` :114, `pathAllowed` :126) and `cmd/relay/cic_flow_host_funcs.go` (`actuate`: `url.Parse` :153, the egress check on `u.Hostname()`, the port and `u.Path` :161). The probe contains a **transcription** of those rules (`relayHostGlob`, `relayPort`, `relayView`) and applies it to the URL string the module sent. It is not the Relay's code and the Relay's real behaviour was not measured | +| The module's manifest | `project.yaml` `capability_manifest.egress`: one rule, host `*.oraclecloud.com`, methods GET, POST, PUT, DELETE, PATCH, no ports (so 443 only), no paths (so any path) | + +## Files + +- `probe_test.go.txt` - `TestZZD5Host`, `TestZZD5BasePath`, `TestZZD5SamePathEverywhere`. A backslash in + a probe input is written `"\x5c"`, so no tool can rewrite it. +- `output.txt` - the output of the recorded run, verbatim. For each case: the URL the module handed to + `actuate`; the `(request-target)` and `host` lines of the string the module asked to have signed + (`ociCanonical`); and what the transcribed Relay rule makes of the URL (`hostname`, `port`, `path`, + `rawquery`, `fragment`, `user`, `egress`). + +The files end in `.txt` so that they are not compiled into the module. + +## Rerun + +In a scratch copy of the commit above, not in the repository: + +``` +cp probe_test.go.txt /module/zz_d5_probe_test.go +cd /module +go test -count=1 -run 'TestZZD5' -v . +``` + +It uses helpers of the module's test package (`cicFlowMock`, `decodeResult`, `observeRequest`, +`destroyRequest`, `invokeRequest`, `pollRequest`, `m4aForOp`, `ociCanonical`, `opPath`). + +## Limits + +The module against a mock, and a transcription of a rule. Not measured: the real Relay, what OCI +does with any of these URLs, what a provider or a proxy does with dot segments or an encoded dot +segment, and what the Go HTTP client sends for a URL that carries a fragment. diff --git a/docs/design/measurements/target-and-path/output.txt b/docs/design/measurements/target-and-path/output.txt new file mode 100644 index 0000000..5b9f729 --- /dev/null +++ b/docs/design/measurements/target-and-path/output.txt @@ -0,0 +1,65 @@ +D5 host=iaas.eu-frankfurt-1.oraclecloud.com sent="https://iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com" port=443 path="/20160918/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=true +D5 host=iaas.eu-frankfurt-1.oraclecloud.com:8443 sent="https://iaas.eu-frankfurt-1.oraclecloud.com:8443/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com:8443] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com" port=8443 path="/20160918/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=false +D5 host=iaas.eu-frankfurt-1.oraclecloud.com@evil.example.com sent="https://iaas.eu-frankfurt-1.oraclecloud.com@evil.example.com/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com@evil.example.com] + relay-sees: hostname="evil.example.com" port=443 path="/20160918/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="iaas.eu-frankfurt-1.oraclecloud.com" egress=false +D5 host=evil.example.com#.oraclecloud.com sent="https://evil.example.com#.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: evil.example.com#.oraclecloud.com] + relay-sees: hostname="evil.example.com" port=443 path="" rawquery="" fragment=".oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" user="" egress=false +D5 host=evil.example.com?x=.oraclecloud.com sent="https://evil.example.com?x=.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: evil.example.com?x=.oraclecloud.com] + relay-sees: hostname="evil.example.com" port=443 path="" rawquery="x=.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" fragment="" user="" egress=false +D5 host=evil.example.com/x.oraclecloud.com sent="https://evil.example.com/x.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: evil.example.com/x.oraclecloud.com] + relay-sees: hostname="evil.example.com" port=443 path="/x.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=false +D5 host=evil.example.com\.oraclecloud.com sent="https://evil.example.com\\.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: evil.example.com\.oraclecloud.com] + relay-sees: RELAY-REFUSES(invalid url) +D5 host=IAAS.EU-FRANKFURT-1.ORACLECLOUD.COM sent="https://IAAS.EU-FRANKFURT-1.ORACLECLOUD.COM/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: IAAS.EU-FRANKFURT-1.ORACLECLOUD.COM] + relay-sees: hostname="IAAS.EU-FRANKFURT-1.ORACLECLOUD.COM" port=443 path="/20160918/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=true +D5 host=iaas.eu-frankfurt-1.oraclecloud.com. sent="https://iaas.eu-frankfurt-1.oraclecloud.com./20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com.] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com." port=443 path="/20160918/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=false +D5 host=anything.oraclecloud.com sent="https://anything.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: anything.oraclecloud.com] + relay-sees: hostname="anything.oraclecloud.com" port=443 path="/20160918/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=true +D5 host=objectstorage.us-ashburn-1.oraclecloud.com sent="https://objectstorage.us-ashburn-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: objectstorage.us-ashburn-1.oraclecloud.com] + relay-sees: hostname="objectstorage.us-ashburn-1.oraclecloud.com" port=443 path="/20160918/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=true +D5 host=evil-oraclecloud.com sent="https://evil-oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: evil-oraclecloud.com] + relay-sees: hostname="evil-oraclecloud.com" port=443 path="/20160918/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=false +D5 host=http://iaas.eu-frankfurt-1.oraclecloud.com sent="https://http://iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: http://iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="http" port=443 path="//iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=false +D5 host=iaas.eu-frankfurt-1.oraclecloud.com x sent="https://iaas.eu-frankfurt-1.oraclecloud.com x/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com x] + relay-sees: RELAY-REFUSES(invalid url) +D5 host=[::1] sent="https://[::1]/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: [::1]] + relay-sees: hostname="::1" port=443 path="/20160918/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=false +--- PASS: TestZZD5Host (0.00s) +D5 base_path=/20160918 sent="https://iaas.eu-frankfurt-1.oraclecloud.com/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com" port=443 path="/20160918/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=true +D5 base_path= sent="https://iaas.eu-frankfurt-1.oraclecloud.com/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com" port=443 path="/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=true +D5 base_path=20160918 sent="https://iaas.eu-frankfurt-1.oraclecloud.com20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get 20160918/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com20160918" port=443 path="/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=false +D5 base_path=/20160918/ sent="https://iaas.eu-frankfurt-1.oraclecloud.com/20160918//vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918//vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com" port=443 path="/20160918//vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=true +D5 base_path=/20160918/../other sent="https://iaas.eu-frankfurt-1.oraclecloud.com/20160918/../other/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918/../other/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com" port=443 path="/20160918/../other/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=true +D5 base_path=//evil.example.com sent="https://iaas.eu-frankfurt-1.oraclecloud.com//evil.example.com/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get //evil.example.com/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com" port=443 path="//evil.example.com/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=true +D5 base_path=/x?inject=1 sent="https://iaas.eu-frankfurt-1.oraclecloud.com/x?inject=1/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /x?inject=1/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com" port=443 path="/x" rawquery="inject=1/vcns/ocid1.vcn.oc1..approved" fragment="" user="" egress=true +D5 base_path=/x#frag sent="https://iaas.eu-frankfurt-1.oraclecloud.com/x#frag/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /x#frag/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com" port=443 path="/x" rawquery="" fragment="frag/vcns/ocid1.vcn.oc1..approved" user="" egress=true +D5 base_path=/%2e%2e/other sent="https://iaas.eu-frankfurt-1.oraclecloud.com/%2e%2e/other/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /%2e%2e/other/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com" port=443 path="/../other/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=true +D5 base_path=/20160918 x sent="https://iaas.eu-frankfurt-1.oraclecloud.com/20160918 x/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918 x/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com" port=443 path="/20160918 x/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=true +D5 base_path=/20160918\nX: y sent="https://iaas.eu-frankfurt-1.oraclecloud.com/20160918\nX: y/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20160918 | X: y/vcns/ocid1.vcn.oc1..approved] + relay-sees: RELAY-REFUSES(invalid url) +D5 base_path=/20990101 sent="https://iaas.eu-frankfurt-1.oraclecloud.com/20990101/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /20990101/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com" port=443 path="/20990101/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=true +D5 base_path=/identity/20160918 sent="https://iaas.eu-frankfurt-1.oraclecloud.com/identity/20160918/vcns/ocid1.vcn.oc1..approved" signed[(request-target): get /identity/20160918/vcns/ocid1.vcn.oc1..approved | host: iaas.eu-frankfurt-1.oraclecloud.com] + relay-sees: hostname="iaas.eu-frankfurt-1.oraclecloud.com" port=443 path="/identity/20160918/vcns/ocid1.vcn.oc1..approved" rawquery="" fragment="" user="" egress=true +--- PASS: TestZZD5BasePath (0.00s) +D5 same-path: observe https://iaas.eu-frankfurt-1.oraclecloud.com/x/../y?q=1/vcns/ocid1.vcn.oc1..approved +D5 same-path: destroy https://iaas.eu-frankfurt-1.oraclecloud.com/x/../y?q=1/vcns/ocid1.vcn.oc1..approved +D5 same-path: invoke https://iaas.eu-frankfurt-1.oraclecloud.com/x/../y?q=1/vcns/ocid1.vcn.oc1..approved/actions/changeCompartment +D5 same-path: execute https://iaas.eu-frankfurt-1.oraclecloud.com/x/../y?q=1/vcns/ocid1.vcn.oc1..approved +D5 same-path: poll(work_request_id) (nothing sent) +--- PASS: TestZZD5SamePathEverywhere (0.00s) +ok github.com/CentralInfraCore/wasm-module-template/module 0.005s diff --git a/docs/design/measurements/target-and-path/probe_test.go.txt b/docs/design/measurements/target-and-path/probe_test.go.txt new file mode 100644 index 0000000..0d87b59 --- /dev/null +++ b/docs/design/measurements/target-and-path/probe_test.go.txt @@ -0,0 +1,156 @@ +//go:build !wasip1 + +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + "testing" +) + +func ok200(int, cicFlowCall) cicFlowReply { return cicFlowReply{Status: 200, Body: `{}`} } + +// ---- The Relay's egress rule as READ in CIC-Relay at 55a899f (cmd/relay/cic_flow_manifest.go +// and cic_flow_host_funcs.go). It is a transcription for this probe, not the Relay's code: the +// Relay was not run. The manifest of this module declares one rule: host "*.oraclecloud.com", +// methods GET/POST/PUT/DELETE/PATCH, no ports (so 443 only), no paths (so any path). +func relayHostGlob(pattern, host string) bool { + pattern = strings.ToLower(strings.TrimSpace(pattern)) + host = strings.ToLower(host) + if suffix, ok := strings.CutPrefix(pattern, "*."); ok { + return strings.HasSuffix(host, "."+suffix) && len(host) > len(suffix)+1 + } + return host == pattern +} + +func relayPort(u *url.URL) int { + if p := u.Port(); p != "" { + if n, err := strconv.Atoi(p); err == nil { + return n + } + } + if u.Scheme == "http" { + return 80 + } + return 443 +} + +// what the Relay's actuate does with the URL string it is handed: url.Parse, then the egress +// check on Hostname(), the port and u.Path, then it sends the request to that URL. +func relayView(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" { + return "RELAY-REFUSES(invalid url)" + } + allowed := relayHostGlob("*.oraclecloud.com", u.Hostname()) && relayPort(u) == 443 + return fmt.Sprintf("hostname=%q port=%d path=%q rawquery=%q fragment=%q user=%q egress=%v", + u.Hostname(), relayPort(u), u.Path, u.RawQuery, u.Fragment, u.User.String(), allowed) +} + +func d5run(t *testing.T, label string, b execBinding) { + t.Helper() + mock := &cicFlowMock{Reply: ok200} + restore := mock.wireHostSeam() + defer restore() + raw, _ := json.Marshal(observeRequest{Kind: "cic:network:vcn", Binding: b}) + out, _ := Observe(nil, raw) + res := decodeResult(t, out) + if mock.actuateCount() == 0 { + fe := "" + if res.Error != nil { + fe = res.Error.FieldPath + } + fmt.Printf("D5 %-44s observe: nothing signed or sent (status=%s %s)\n", label, res.Status, fe) + return + } + sent := mock.Actuated[0].URL + canon, _, _ := ociCanonical("GET", b.Host, opPath(b, "/vcns/{vcnId}"), "D", nil) + lines := strings.Split(canon, "\n") + fmt.Printf("D5 %-44s sent=%q signed[%s | %s]\n relay-sees: %s\n", label, sent, lines[0], lines[1], relayView(sent)) +} + +func TestZZD5Host(t *testing.T) { + good := execBinding{Host: "iaas.eu-frankfurt-1.oraclecloud.com", BasePath: "/20160918", KeyID: "t/u/f", ResourceID: "ocid1.vcn.oc1..approved"} + for _, h := range []string{ + "iaas.eu-frankfurt-1.oraclecloud.com", + "iaas.eu-frankfurt-1.oraclecloud.com:8443", + "iaas.eu-frankfurt-1.oraclecloud.com@evil.example.com", + "evil.example.com#.oraclecloud.com", + "evil.example.com?x=.oraclecloud.com", + "evil.example.com/x.oraclecloud.com", + "evil.example.com" + "\x5c" + ".oraclecloud.com", + "IAAS.EU-FRANKFURT-1.ORACLECLOUD.COM", + "iaas.eu-frankfurt-1.oraclecloud.com.", + "anything.oraclecloud.com", + "objectstorage.us-ashburn-1.oraclecloud.com", + "evil-oraclecloud.com", + "http://iaas.eu-frankfurt-1.oraclecloud.com", + "iaas.eu-frankfurt-1.oraclecloud.com x", + "[::1]", + } { + b := good + b.Host = h + d5run(t, "host="+h, b) + } +} + +func TestZZD5BasePath(t *testing.T) { + good := execBinding{Host: "iaas.eu-frankfurt-1.oraclecloud.com", BasePath: "/20160918", KeyID: "t/u/f", ResourceID: "ocid1.vcn.oc1..approved"} + for _, p := range []string{ + "/20160918", + "", + "20160918", + "/20160918/", + "/20160918/../other", + "//evil.example.com", + "/x?inject=1", + "/x#frag", + "/%2e%2e/other", + "/20160918 x", + "/20160918\nX: y", + "/20990101", + "/identity/20160918", + } { + b := good + b.BasePath = p + d5run(t, "base_path="+strings.ReplaceAll(p, "\n", "\\n"), b) + } +} + +// The same binding through every entry point that builds a path: the URL is the same string. +func TestZZD5SamePathEverywhere(t *testing.T) { + b := execBinding{Host: "iaas.eu-frankfurt-1.oraclecloud.com", BasePath: "/x/../y?q=1", KeyID: "t/u/f", ResourceID: "ocid1.vcn.oc1..approved", Revision: "e"} + urls := map[string]string{} + run := func(name string, fn func() ([]byte, error)) { + mock := &cicFlowMock{Reply: ok200} + restore := mock.wireHostSeam() + fn() + restore() + if mock.actuateCount() > 0 { + urls[name] = mock.Actuated[0].URL + } else { + urls[name] = "(nothing sent)" + } + } + run("observe", func() ([]byte, error) { r, _ := json.Marshal(observeRequest{Kind: "cic:network:vcn", Binding: b}); return Observe(nil, r) }) + run("destroy", func() ([]byte, error) { r, _ := json.Marshal(destroyRequest{Kind: "cic:network:vcn", Binding: b}); return Destroy(nil, r) }) + run("invoke", func() ([]byte, error) { + r, _ := json.Marshal(invokeRequest{Kind: "cic:network:vcn", Operation: "ChangeVcnCompartment", Binding: b}) + return Invoke(nil, r) + }) + run("execute", func() ([]byte, error) { + req := m4aForOp(t, "cic:network:vcn", providerOperation{Operation: "UpdateVcn", Method: "PUT", Path: "/vcns/{vcnId}"}, `{"displayName":"x"}`, b) + r, _ := json.Marshal(req) + return Execute(nil, r) + }) + run("poll(work_request_id)", func() ([]byte, error) { + r, _ := json.Marshal(pollRequest{Binding: b, WorkRequestID: "ocid1.wr..a"}) + return Poll(nil, r) + }) + for _, k := range []string{"observe", "destroy", "invoke", "execute", "poll(work_request_id)"} { + fmt.Printf("D5 same-path: %-22s %s\n", k, urls[k]) + } +} diff --git a/docs/design/specs/target-origin-and-path-safety.md b/docs/design/specs/target-origin-and-path-safety.md new file mode 100644 index 0000000..3794589 --- /dev/null +++ b/docs/design/specs/target-origin-and-path-safety.md @@ -0,0 +1,195 @@ +# Target data: where it comes from, and how a path is built (D5) + +Status: **Part A** (section 1) is a measurement of the module at `2ac19ec`. **Part B** (sections 3 +and 4) is a proposal (**concept**): nothing in it is implemented in the module or the Relay. + +D5 is split in two on purpose. Part A records what the module does today with the target values +a caller gives it, and what follows. Part B is the contract the architecture asks for, and it is +**not** an input validator: it asks why a caller gives these values at all. + +**Premise of Part B.** The `host`, `base_path`, `key_id`, `revision` and the resource's OCID are not +part of the domain intent. They belong to the realization (deployment) context, the provider +contract and the observed state, and the CIC derives them from authenticated schemas and context. +A provider module is not meant to be the source of truth for them. This premise is the project +owner's statement of the CIC model; it is not recorded elsewhere in this repository (searched: +`docs/`, `theads/`), so this document is its first written form here and it is open to correction. + +Why it matters: `target_digest` (`plan-digest.md`, `destroy-invoke-binding.md`) binds the five +values to a plan or an action. It does not show that they are the right ones. The M4a gate proves a +plan and a target belong together; Part B asks why this plan and this target follow from the +authenticated state and the approved intent. + +## 1. Part A: measured current behaviour + +### 1.1 Method + +Scratch probe in a copy made with `git archive` of `2ac19ec`; the host seam is the module's own +test mock; no provider and no Relay. Sources, output and exact environment: +[`../measurements/target-and-path/README.md`](../measurements/target-and-path/README.md). The +Relay's egress rule was **read** in CIC-Relay at `55a899f` (cited there) and **transcribed** into +the probe; the Relay was not run, so "egress allowed or denied" below is that transcription's +verdict, not a measurement of the Relay. + +### 1.2 What the code does (read) + +- Four entry points (`observe`, `execute`, `destroy`, `invoke`) build the request path with one + function, `opPath` = `binding.base_path` + the registry path with `{param}` replaced by + `binding.resource_id`. `poll` has its own strict builder (`/` and 8 digits). +- The request URL is the string `"https://" + host + path`. The string handed to `sign` is built + from the same `host` and `path` as two separate raw strings (`(request-target)` and `host` lines). +- The module checks that `host` and `key_id` are present, and the resource id's syntax. It checks + nothing about the structure of `host` or `base_path`. +- The module's embedded registry carries, per operation, only `method`, `path`, `path_params`, + `role` and `input` (inspected in `module/schemas/core/*.json`). It carries **no API version, no + base path and no host**; the schema extractor (`tools/oci-extract`) does not record one. The + version `/20160918` appears only in callers' bindings, tests and the manual harness. +- The module's capability manifest (`project.yaml`) declares one egress rule: host + `*.oraclecloud.com`, five methods, no ports (so 443 only), no paths (so any path). +- The Relay's `actuate` (read) parses the URL string with `url.Parse`, applies the egress rule to + `u.Hostname()`, the port and `u.Path`, and sends the request to that URL. Its path rule is a prefix + match on `u.Path`, and an empty list allows any path. + +### 1.3 Observations + +**H1. `host` is used as the caller wrote it** (module: `observe`, GET, the same `base_path` and +`resource_id`; the right column is the transcribed Relay rule applied to the URL the module sent). + +| `host` given | The URL's parsed host, port, path | Egress by the transcribed rule | +|---|---|---| +| `iaas.eu-frankfurt-1.oraclecloud.com` | same host, 443 | allowed | +| `...oraclecloud.com:8443` | port 8443 | denied (443 only) | +| `iaas.eu-frankfurt-1.oraclecloud.com@evil.example.com` | host `evil.example.com`, userinfo = the OCI name | denied | +| `evil.example.com#.oraclecloud.com` | host `evil.example.com`; the intended path became the fragment | denied | +| `evil.example.com?x=.oraclecloud.com` | host `evil.example.com`; the intended path became the query | denied | +| `evil.example.com/x.oraclecloud.com` | host `evil.example.com`; the rest became the path | denied | +| a backslash after `evil.example.com` | not a valid URL | refused | +| `IAAS.EU-FRANKFURT-1.ORACLECLOUD.COM` | upper case kept | allowed | +| `iaas.eu-frankfurt-1.oraclecloud.com.` (trailing dot) | host keeps the dot | denied | +| `anything.oraclecloud.com`, `objectstorage.us-ashburn-1.oraclecloud.com` | as given | **allowed** | +| `evil-oraclecloud.com` | as given | denied | +| `http://iaas.eu-frankfurt-1.oraclecloud.com` | host `http`, path starts `//iaas...` | denied | +| a host containing a space | not a valid URL | refused | +| `[::1]` | host `::1` | denied | + +In every row the module asked `sign` to sign a string whose `host:` line is the raw value, even when +the URL's real host is another one (rows 3 to 6): the signed host line and the URL's host differ. +What stops those requests, in the transcribed rule, is the Relay's check of the parsed host, not +the module. + +**H2. `base_path` is put in front of the path as the caller wrote it** (`host` normal). + +| `base_path` given | URL sent | The URL parsed as the Relay parses it | Egress | +|---|---|---|---| +| `/20160918` | `/20160918/vcns/` | path as intended | allowed | +| empty | `/vcns/` (no version at all) | path as sent | allowed | +| `20160918` (no leading slash) | `https://iaas...com20160918/vcns/` | host becomes `iaas...com20160918` | denied | +| `/20160918/` | `/20160918//vcns/` | double slash kept | allowed | +| `/20160918/../other` | sent as written | path `/20160918/../other/vcns/` (dot segment not resolved by the module) | allowed | +| `//evil.example.com` | `//evil.example.com/vcns/` | path with a leading double slash, host unchanged | allowed | +| `/x?inject=1` | `/x?inject=1/vcns/` | path `/x`, query `inject=1/vcns/`: **the resource part is no longer a path** | allowed | +| `/x#frag` | `/x#frag/vcns/` | path `/x`, the rest is a fragment | allowed | +| `/%2e%2e/other` | sent as written | `u.Path` is `/../other/vcns/` (decoded); the module signed the encoded form | allowed | +| `/identity/20160918` | as written | another service's prefix | allowed | +| `/20160918 x` (a space) | as written | path with a space | allowed | +| a newline inside | URL has a newline | not a valid URL | refused | + +For the newline case the module has, before `actuate`, asked `sign` to sign a string in which the +injected text is a line of its own (`(request-target): get /20160918` followed by a line `X: y/...`): +the sign call precedes the send in `actuateSigned`. + +**H3. One builder, one surface.** For the same hostile binding (`base_path` `/x/../y?q=1`) `observe`, +`destroy`, `invoke` and `execute` produce the same URL string up to the operation's own tail; +`poll` by `work_request_id` sends nothing (it refuses). So every `base_path` property above holds +for four entry points, and a fix at `opPath` reaches all four. + +### 1.4 Not measured + +The real Relay, what OCI or a proxy does with dot segments, an encoded dot segment, a query where a +path was meant or a double slash, and what the Go HTTP client sends for a URL that carries a +fragment. Whether the signed string and the sent request-target agree in each row was not measured. + +## 2. What the observations show + +| Property | From | +|---|---| +| **Q1.** The module accepts the five target values from the caller without establishing where they come from, and nothing in the registry or manifest could tell it what they should be: no API version, base path or host is recorded. | 1.2, H1, H2 | +| **Q2.** The only structural control on a request's destination is the Relay's egress rule on the parsed host and port. Its host rule is a wildcard over all of `*.oraclecloud.com` (any OCI service and region, with the same key) and it has no path rule. | 1.2, H1, H2 | +| **Q3.** What the module signs and what leaves can differ in structure when a value carries URL syntax (a host that parses as another host, a `base_path` that turns the resource path into a query or fragment). | H1, H2 | +| **Q4.** Text that is not a URL path can reach the string that is signed (a newline adds a line to the signed string); the signature is requested before anything refuses it. | H2 | +| **Q5.** The same builder serves four entry points; poll is already strict. | H3 | + +## 3. Part B: where the target data should come from (concept) + +The values are not free parameters of a provider call. Each has an origin and an authenticator in the +CIC model; where the module can derive a value itself from what it already has, it should, and where +it cannot, it should receive it from an authenticated source and not as a parameter in the request. + +| Field | Origin (premise) | Can the module derive it? | Authenticator | +|---|---|---|---| +| `host` | the realization environment the provider is assigned to (region, realm) | no: it needs the realization context | the Relay and the realization layer; the manifest allowlist is an upper bound, not the value | +| `base_path` | the provider's API contract (the service's API version) | **yes, if the contract records it**: today it does not (Q1). The schema extractor reads the SDK that sets it; recording it per service in the registry is the missing piece, **not verified here** that the SDK exposes it in a form the extractor can read | the registry digest (`contract_digest`, already in `plan_digest`) would then cover it | +| `key_id` | the Relay/CIC execution identity's binding to a tenant and principal | no | the Relay (R6, R7): the module knows a name only | +| `resource_id` | the checked binding of the CIC logical object to the provider resource | partly: syntax and type today; the binding itself comes from the state model (`observe` returns it in `provider_metadata`) | the state model and the observation | +| `revision` | the observation's provider state (an etag) | no | the observation, with its source and time | + +**What the architecture asks of the contract.** + +1. The provider module is not the source of truth for target data. A target that the caller supplied + freely does not stand in for a derivation from schemas and context. +2. `target_digest` binds the **derived and checked** binding to the plan. It binds values; it is not + the derivation. What binds the derivation is what it was derived from: the schemas' digests + (already in `plan_digest`), the contract digest (already in `plan_digest`), and a digest of the + realization context and of the observation the revision came from (**missing**). +3. A target arrives from an authenticated source (the execution context the Relay provides, R6), + not as a free field of the request, so that the module can be given something it did not have to + trust blindly. Until that exists the module's `target` input is exactly what it was in M4a: a + caller-supplied value that the gate proves consistent. +4. The Relay enforces the authority to execute the approved consequence (R7, R8); the returned state + and the ProofTrace prove separately what was observed and what happened (R9). + +M4a, the canonical JSON, the three digests, the derive-again gate and the replay limit are building +blocks for this. They are not the realization proof: that proof is "why this plan and this target +follow from the authenticated system state and the approved intent", which M4a does not show. + +## 4. Safe execution of a derived target (path building) + +These are the details of executing a derived target safely, not the architecture's starting point, +and they do not replace section 3. They follow from H1 to H3 and Q3 to Q5 and are defense in depth +at the one place that builds the path: + +- one builder for the path and one for the host, shared by the four entry points (H3); +- a structural check of `host` (a lower-case DNS name; no userinfo, port, scheme, `#`, `?`, `/`, + backslash, whitespace or control character; no trailing dot) and of `base_path` (the form + `poll` already requires: `/` and 8 digits, or derived from the registry once it is recorded); +- nothing that is not a path segment reaches the string that is signed, and the check runs before + `sign` is asked (Q4); +- the Relay's egress rule narrowed from a wildcard host and an empty path list to what a deployment + actually uses is the Relay's decision (R7 and the manifest), not this repository's. + +## 5. What can be proved where + +| Statement | Who can prove it | +|---|---| +| The path the module builds is a path under the API prefix, and no value carries URL syntax | the module (section 4) | +| The registry records the API version and a plan's digest covers it | the module, once the extractor records it | +| That `host` is the right region and realm, `key_id` the right tenant, `revision` current | not the module and not the digest: the realization context, the Relay and the observation (section 3) | +| That the signed request is the one that leaves | the Relay's host boundary (R7) | +| That anyone approved it, once, with what outcome | the Relay (R7, R8, R9) | + +## 6. Open decisions + +- **D5-1.** Record the service API version per service in the registry (extractor change), so `base_path` + is derived and no longer given. Needs a look at how the SDK exposes it, not done here. +- **D5-2.** Where the realization context and its digest live, and what carries them into an + execution (R6): the largest open design question; this document only names it. +- **D5-3.** Whether the module keeps a caller-supplied `target` field at all once an authenticated + source exists, or reads it only from that source. +- **D5-4.** The structural checks of section 4: refuse (proposed) or normalize. +- **D5-5.** The `poll` alias's behaviour with an empty `base_path` (it takes the version from the + caller today). + +## What this does not claim + +Nothing in Part B or section 4 is implemented. The measurements are of the module against a mock, +with the Relay's rule read and transcribed, not run; they do not show that any of the listed requests +would succeed against OCI, and they do not show that the Relay behaves as transcribed.