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.