diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1ea9da9..4b450dd 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -67,3 +67,17 @@ jobs: python-version: "3.12" - name: Check every field in the body appears in Appendix A run: python3 scripts/check_appendix.py + + creative-inspection: + name: Creative inspection fixtures + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python3 scripts/check_creative_fixtures.py + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: node scripts/check_preview_observations.mjs diff --git a/docs/creative-inspection-fixtures.md b/docs/creative-inspection-fixtures.md new file mode 100644 index 0000000..b75c288 --- /dev/null +++ b/docs/creative-inspection-fixtures.md @@ -0,0 +1,173 @@ +# Inspect creative without reporting live delivery + +This contribution implements the proposed wording in +[#42](https://github.com/IABTechLab/OpenProposal/issues/42) and supplies synthetic +fixtures that exercise it. It also supports the distinction raised in +[#32](https://github.com/IABTechLab/OpenProposal/issues/32): resolving a format, +fetching a document, checking conformance and receiving seller approval are +separate results. + +The normative change adds the inspection subsection to section 5.4 and a note +to the CTV example. It adds no fields or enum values. The appendix field +indexes therefore remain unchanged. + +## Reproduce the document inspection + +``` +python3 scripts/check_creative_fixtures.py +python3 scripts/check_creative_fixtures.py --json +``` + +The standard-library runner starts a loopback HTTP server. The server records +every request, including requests to synthetic notification traps. Nothing +loads a campaign URL. The two VAST templates use fixture substitution tokens +that the runner replaces with the server's loopback address. + +The reference inspector retrieves only document request identities listed by +the test harness. It follows a single-ad wrapper chain, hashes retrieved bytes and +parses XML. It does not execute code, fetch media, probe event URLs, expand +macros, use proxy environment variables, or automatically follow redirects. +An HTTP redirect can itself target an event endpoint. It must not become an +unchecked route around the retrieval policy. + +The allowlist, event guard and cycle detection share one HTTP URL identity +function. It removes fragments, normalizes scheme/host spelling, IPv6 spelling +and default ports, and supplies `/` for an empty path. The transport sends that +same normalized URL. Path spelling, query order, percent encodings and an empty +query delimiter are preserved. A receipt retains both the supplied document URL +and the actual request URL. Unsupported schemes, credentials, control characters +and non-ASCII URLs are blocked in this deliberately limited fixture profile. + +The event-role set includes linear, companion, non-linear and icon tracking, +viewability, verification and error URIs. Clickthrough destinations are also +excluded from wrapper retrieval. Regression cases deliberately allowlist targets +that are also declared as events, so an absent allowlist entry cannot hide a +broken event guard. Fragment aliases are tested on both sides of that comparison. +This does not infer server-side URL aliases, custom extension semantics or +resource intent. The caller must establish that its initial resource allowlist +is appropriate. It is not a production fetch policy. + +Successful wrapper traversal reaches the inline document with no requests to +its impression, playback, click, viewability, verification or error endpoints. +Failure cases include cycles, missing documents, malformed XML, a redirect to +an event URL, excessive size, excessive depth and an event URL also used as a +wrapper target. Unresolved macros, unsupported encodings, DTDs and ad pods are +reported as not checked within this limited profile. Missing wrapper targets +and malformed XML fail their specific structural checks. + +The server-side positive control deliberately sends GET and HEAD to a local +notification trap and verifies both are recorded. This establishes that zero +notification requests in the inspection cases is an observable test result. +Neither response availability nor HTTP method makes an event request safe. + +## Reproduce the browser preview + +Requires Node.js 22 or later and an installed Chrome or Chromium executable. +Set `CHROME_BIN` if it is not at one of the common paths checked by the runner. +`CREATIVE_CACHE_DIR` can place the temporary browser profile in a chosen cache +directory. The directory must already exist. The profile is removed on exit. + +``` +node scripts/check_creative_preview.mjs +node scripts/check_creative_preview.mjs --json +node scripts/check_preview_observations.mjs +``` + +The browser runs synthetic creative that attempts a GET, HEAD, POST, beacon, +XHR, image, script, nested frame, CSS image, video poster, form submission, +navigation and WebSocket connection. Each mechanism has a positive control +that must reach its loopback notification trap using the expected method. +Each isolated case must execute the same attempt and produce zero requests +at that trap. A receipt includes the browser version, attempted operation, +server observations, blocked requests and a 500 ms observation interval +after the synchronous attempt. + +The assertion checks the entire notification-trap log for that case, including +unexpected paths and methods. The receipt preserves every observation and its +full URL. Control cases must reach only their expected notification endpoint; +isolated cases must produce no notification at all. The assertion regression +tests run without a browser and are included in CI. + +An integration negative control checks that an unexpected notification cannot +disappear from the result: + +``` +node scripts/check_creative_preview.mjs --json --inject-notification-canary +``` + +That command intentionally exits with status 1. The test driver injects one +loopback event at an unexpected path in `fetch-get/isolated`; that case must +fail and its receipt must contain the event. This is a check of the assertion, +not a browser escape or a live campaign notification. + +The isolated preview combines a sandboxed frame, a restrictive Content +Security Policy, browser request interception and an independent HTTP proxy +that permits only the exact preview-document request. Proxy bypass for +loopback is disabled. The proxy rejects CONNECT except for the WebSocket +positive control's loopback tunnel. It permits a control's specific loopback +event URL only while that control runs. It never forwards to external +destinations. A separate navigation case disables CSP and browser interception +to verify that the independent gate itself blocks the notification. + +The independent gate is essential to this demonstration. An initial test +using CSP and target-scoped browser interception alone allowed frame +navigation to reach the synthetic event endpoint in Chrome. The navigation +case remains in the suite and must be blocked outside the renderer too. +This finding supports a requirement about observable notifications rather +than prescribing a particular browser API as sufficient isolation. + +These are finite synthetic tests, not proof that this harness safely executes +arbitrary third-party code. It is not a production sandbox. Delayed traffic, +all possible protocols, browser vulnerabilities, WebRTC, native applications +and real verification SDKs are outside the test coverage. A production +inspector needs enforcement for its entire execution environment and must +decline a check it cannot isolate. The browser fixture demonstrates request +suppression; it does not demonstrate video decoding or playback conformance. + +## Report the exact check and its evidence + +The JSON receipt is a non-normative sidecar for these tests, not a new +OpenProposal object or an adopted readiness protocol. Each result names the +check, its outcome and its reason. Retrieved documents carry their SHA-256 +hash, URL and byte count. The receipt also identifies the asset, inspection +time, root URL, depth/size limits, redirect policy and macro context. + +- `passed`: the named check ran and met that check's condition. Parsing XML + does not pass VAST schema validation. Reaching `InLine` does not pass + playback, media decode, seller specs or seller approval. +- `failed`: the named check ran and found a concrete violation, such as + malformed XML or a cycle in the retrieved wrapper chain. +- `not_checked`: the check did not establish an outcome. The receipt states + whether data was missing, retrieval failed, policy blocked it or the + implementation did not support the check. These reasons do not establish + that the creative itself is invalid. + +A required check set is satisfied only if every named check has at least one +result and all its results passed. Missing, failed or not-checked results +cannot satisfy it. An empty requirement list cannot establish satisfaction. +Even satisfied inspection checks do not establish seller acceptance. +`readiness` remains `not_assessed` in this first profile. + +The inspector leaves VAST schema conformance, resolved format membership, +seller-spec conformance, media decode, verification execution, live playback +and seller approval explicitly not checked. A minimal `` fixture is +included to prove that successful traversal never becomes a schema pass. + +## Boundaries for the next creative contribution + +Readiness needs a declared check set for the resolved format and delivery +protocol. An audit needs the exact asset bytes, resolved wrapper documents, +seller-spec version, execution context, checker version and evidence expiry. +Mutable URLs or changed specifications cannot inherit an earlier pass without +a revalidation rule. Those are follow-up decisions, not implicit guarantees +of this fixture receipt. + +This proposal does not require inspectors to fetch documents or offer a +preview. It does not change VAST's live-delivery tracking requirements. +Synthetic event delivery to a documented receiver test mode would need its +own evidence that the receiver excludes it from production measurement. +An inspector-owned `test=true` parameter cannot establish that fact. + +References: [VAST 4.3, sections 2.3.4 and 2.3.6](https://iabtechlab.com/wp-content/uploads/2022/09/VAST_4.3.pdf), +[Content Security Policy](https://www.w3.org/TR/CSP3/), and +[Chrome DevTools Fetch domain](https://chromedevtools.github.io/devtools-protocol/tot/Fetch/). diff --git a/examples/line-item-ctv.yaml b/examples/line-item-ctv.yaml index fda3994..0487e17 100644 --- a/examples/line-item-ctv.yaml +++ b/examples/line-item-ctv.yaml @@ -33,3 +33,6 @@ buying_route: SSP_PLATFORM transaction_mechanism: { available: ["PRIVATE_AUCTION"], included: ["PRIVATE_AUCTION"] } serving_mode: ssai correlation_id: "ep-q4-2026-commitment/weather-app-ctv" + +# Inspection and previews must not send production tracking or error notifications. +# See the creative inspection requirements in spec section 5.4. diff --git a/fixtures/creative/inline.xml b/fixtures/creative/inline.xml new file mode 100644 index 0000000..84a50ec --- /dev/null +++ b/fixtures/creative/inline.xml @@ -0,0 +1,45 @@ + + + + + OpenProposal inspection fixture + Synthetic inspection asset + + synthetic-serving-id + + + + + + + + + + synthetic + + + + + + + + synthetic-asset + + 00:00:15 + + + + + + + + + + + + + + + + + diff --git a/fixtures/creative/wrapper.xml b/fixtures/creative/wrapper.xml new file mode 100644 index 0000000..c999551 --- /dev/null +++ b/fixtures/creative/wrapper.xml @@ -0,0 +1,20 @@ + + + + + OpenProposal inspection fixture + + + + + + + + + + + + + + + diff --git a/scripts/check_creative_fixtures.py b/scripts/check_creative_fixtures.py new file mode 100644 index 0000000..0bd8bf8 --- /dev/null +++ b/scripts/check_creative_fixtures.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Exercise inspection against local resources and local notification traps.""" + +import argparse +import json +import threading +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from inspect_creative_fixture import FixtureTransport, Unchecked, checks_satisfied, inspect, request_url + +ROOT = Path(__file__).resolve().parents[1] + + +def run(): + requests, routes = [], {} + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + requests.append({"method": "GET", "path": self.path}) + if self.path == "/redirect": + self.send_response(302) + self.send_header("Location", "/events/redirect") + self.end_headers() + return + status, body = routes.get(self.path, (404, b"not found")) + self.send_response(status) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_HEAD(self): + requests.append({"method": "HEAD", "path": self.path}) + self.send_response(204) + self.end_headers() + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = "http://127.0.0.1:" + str(server.server_port) + inline = (ROOT / "fixtures/creative/inline.xml").read_text().replace("{{BASE}}", base) + wrapper = (ROOT / "fixtures/creative/wrapper.xml").read_text().replace("{{BASE}}", base) + + def wrap(target): + return wrapper.replace("{{TARGET}}", base + target).encode() + + routes.update({ + "/inline": (200, inline.encode()), "/wrapper": (200, wrap("/inline")), + "/cycle": (200, wrap("/cycle")), "/broken-wrapper": (200, wrap("/missing")), + "/external": (200, wrapper.replace("{{TARGET}}", "https://unreachable.example.test/ad").encode()), + "/macro": (200, wrap("/inline?context=[PAGEURL]")), + "/malformed": (200, b""), + "/not-vast": (200, b""), + "/doctype": (200, b']>&x;'), + "/utf16": (200, inline.encode("utf-16")), + "/large": (200, b" " * 65537), "/empty": (200, b''), + "/pod": (200, b''), + "/missing-target": (200, b''), + "/both": (200, b''), + "/dual-role": (200, wrap("/events/wrapper-impression")), + "/events/wrapper-impression": (200, inline.encode()), + "/inline-minimal": (200, b''), + }) + allowed = {base + path for path in routes if not path.startswith("/events/")} | {base + "/missing", base + "/redirect"} + definitions = [ + ("wrapper-to-inline", "/wrapper", "wrapper_resolution", "passed", ["/wrapper", "/inline"], 5), + ("inline-document", "/inline", "wrapper_resolution", "passed", ["/inline"], 5), + ("wrapper-cycle", "/cycle", "wrapper_resolution", "failed", ["/cycle"], 5), + ("wrapper-http-failure", "/broken-wrapper", "document_retrieval", "not_checked", ["/broken-wrapper", "/missing"], 5), + ("redirect-to-event-blocked", "/redirect", "document_retrieval", "not_checked", ["/redirect"], 5), + ("external-target-blocked", "/external", "document_retrieval", "not_checked", ["/external"], 5), + ("unresolved-macro-blocked", "/macro", "document_retrieval", "not_checked", ["/macro"], 5), + ("malformed-xml", "/malformed", "xml_well_formedness", "failed", ["/malformed"], 5), + ("wrong-root", "/not-vast", "vast_root", "failed", ["/not-vast"], 5), + ("doctype-not-executed", "/doctype", "xml_well_formedness", "not_checked", ["/doctype"], 5), + ("unsupported-encoding", "/utf16", "xml_well_formedness", "not_checked", ["/utf16"], 5), + ("document-size-limit", "/large", "document_retrieval", "not_checked", ["/large"], 5), + ("empty-response-not-conformance", "/empty", "wrapper_resolution", "not_checked", ["/empty"], 5), + ("ad-pod-outside-profile", "/pod", "wrapper_resolution", "not_checked", ["/pod"], 5), + ("wrapper-target-missing", "/missing-target", "wrapper_resolution", "failed", ["/missing-target"], 5), + ("ambiguous-ad-shape", "/both", "wrapper_resolution", "failed", ["/both"], 5), + ("document-count-limit", "/wrapper", "wrapper_resolution", "not_checked", ["/wrapper"], 1), + ("event-also-used-as-wrapper-target", "/dual-role", "wrapper_resolution", "not_checked", ["/dual-role"], 5), + ("minimal-inline-is-not-schema-validation", "/inline-minimal", "vast_schema_conformance", "not_checked", ["/inline-minimal"], 5), + ] + dual_allowed = {"/dual-role": {base + "/events/wrapper-impression"}} + expected_event_guard = set() + + def add_guard_case(name, xml, target): + path = "/" + name + routes[path] = (200, xml.encode()) + allowed.add(base + path) + dual_allowed[path] = {target} + expected_event_guard.add(path) + definitions.append((name, path, "wrapper_resolution", "not_checked", [path], 5)) + + event = base + "/events/wrapper-impression" + add_guard_case("fragment-on-wrapper-target", wrapper.replace("{{TARGET}}", event + "#inspection"), event + "#inspection") + add_guard_case("fragment-on-event-url", wrapper.replace(event, event + "#measurement").replace("{{TARGET}}", event), event) + add_guard_case("scheme-case-event-alias", wrapper.replace("{{TARGET}}", event.replace("http:", "HTTP:")), event.replace("http:", "HTTP:")) + routes["/cycle-fragment"] = (200, wrap("/cycle-fragment#again")) + allowed.update({base + "/cycle-fragment", base + "/cycle-fragment#again"}) + definitions.append(("fragment-alias-cycle", "/cycle-fragment", "wrapper_resolution", "failed", ["/cycle-fragment"], 5)) + + # Fixed VAST roles, intentionally independent of the inspector's role set. + # Each target is allowlisted to prove the semantic guard, not a missing + # allowlist entry, prevents the request. + tracking = base + "/events/shared" + routes["/events/shared"] = (200, inline.encode()) + resource = f'' + for role in ("CompanionClickTracking", "CompanionClickThrough", + "NonLinearClickTracking", "NonLinearClickThrough", + "IconViewTracking", "IconClickTracking", "IconClickThrough"): + tag = f'<{role}>' + xml = wrapper.replace("{{TARGET}}", tracking) + if role.startswith("Companion"): + extra = f'{resource}{tag}' + xml = xml.replace("", extra + "") + elif role.startswith("NonLinear"): + extra = f'{resource}{tag}' + xml = xml.replace("", extra + "") + else: + if role != "IconViewTracking": + tag = "" + tag + "" + extra = f'{resource}{tag}' + xml = xml.replace("", extra + "") + add_guard_case("event-role-" + role, xml, tracking) + + results = [] + try: + for name, path, check, outcome, expected_requests, limit in definitions: + requests.clear() + # Dual-role case deliberately allows the URL to prove the semantic + # event guard stops it before the transport allowlist is consulted. + permitted = allowed | dual_allowed.get(path, set()) + receipt = inspect(base + path, FixtureTransport(permitted), max_documents=limit) + matches = [item for item in receipt["checks"] if item["check"] == check] + notifications = [item for item in requests if item["path"].startswith("/events/")] + passed = (bool(matches) and matches[-1]["outcome"] == outcome and not notifications + and [item["path"] for item in requests] == expected_requests + and not checks_satisfied(receipt, ["vast_schema_conformance"]) + and not checks_satisfied(receipt, ["unknown_required_check"]) + and not checks_satisfied(receipt, []) and receipt["readiness"] == "not_assessed") + if path == "/wrapper" and limit == 5: + passed = passed and checks_satisfied(receipt, ["document_retrieval", "xml_well_formedness", "wrapper_resolution"]) + passed = passed and len(receipt["identified_event_urls"]) == 13 + if path in expected_event_guard: + passed = passed and matches[-1]["reason"] == "target is also identified as an event URL" + passed = passed and not checks_satisfied(receipt, ["document_retrieval", "xml_well_formedness", "wrapper_resolution"]) + results.append({"id": name, "passed": bool(passed), "expected_check": check, + "expected_outcome": outcome, "server_requests": requests.copy(), "receipt": receipt}) + # A positive control proves the trap records notifications, including HEAD. + # These endpoints are synthetic and loopback-only, never campaign URLs. + requests.clear() + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + for method in ("GET", "HEAD"): + try: + with opener.open(urllib.request.Request(base + "/events/control", method=method)): + pass + except urllib.error.HTTPError: + pass + results.append({"id": "notification-trap-positive-control", "passed": requests == [ + {"method": "GET", "path": "/events/control"}, {"method": "HEAD", "path": "/events/control"}], + "server_requests": requests.copy()}) + url_cases = [ + ("url-fragment-removed", "http://example.test/events/x#preview", "http://example.test/events/x"), + ("url-http-authority-normalized", "HTTP://EXAMPLE.TEST:80/events/x", "http://example.test/events/x"), + ("url-https-default-port", "https://EXAMPLE.TEST:443/events/x", "https://example.test/events/x"), + ("url-empty-path", "http://example.test", "http://example.test/"), + ("url-ipv6-normalized", "http://[0:0:0:0:0:0:0:1]:80/x", "http://[::1]/x"), + ("url-query-preserved", "http://example.test/x?signature=%7e&b=2&a=1#preview", "http://example.test/x?signature=%7e&b=2&a=1"), + ("url-empty-query-preserved", "http://example.test/x?#preview", "http://example.test/x?"), + ] + for name, value, expected in url_cases: + actual = request_url(value) + results.append({"id": name, "passed": actual == expected, "input": value, "expected": expected, "actual": actual}) + for name, value in (("url-credentials-blocked", "http://user:password@example.test/x"), + ("url-non-http-blocked", "file:///events/x"), + ("url-control-character-blocked", "http://example.test/\nx")): + try: + request_url(value) + blocked = False + except Unchecked: + blocked = True + results.append({"id": name, "passed": blocked, "expected": "not_checked"}) + return {"profile": "creative-inspection-discussion-2", "passed": sum(item["passed"] for item in results), + "total": len(results), "cases": results} + finally: + server.shutdown() + server.server_close() + thread.join() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + report = run() + if args.json: + print(json.dumps(report, indent=2)) + else: + for case in report["cases"]: + print(("PASS " if case["passed"] else "FAIL ") + case["id"]) + print(f'{report["passed"]}/{report["total"]} creative inspection cases pass') + raise SystemExit(report["passed"] != report["total"]) diff --git a/scripts/check_creative_preview.mjs b/scripts/check_creative_preview.mjs new file mode 100644 index 0000000..e650814 --- /dev/null +++ b/scripts/check_creative_preview.mjs @@ -0,0 +1,269 @@ +#!/usr/bin/env node +// Synthetic loopback preview tests. Never load live creative with this harness. +import { spawn } from 'node:child_process'; +import { createServer, request as httpRequest } from 'node:http'; +import { connect } from 'node:net'; +import { mkdtemp, readFile, rm, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { once } from 'node:events'; +import { assessNotifications } from './creative_preview_observations.mjs'; + +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +const attempts = { + 'fetch-get': `fetch(url).catch(() => {})`, + 'fetch-head': `fetch(url, {method: 'HEAD'}).catch(() => {})`, + 'fetch-post': `fetch(url, {method: 'POST', body: 'synthetic'}).catch(() => {})`, + beacon: `navigator.sendBeacon(url, 'synthetic')`, + xhr: `const x = new XMLHttpRequest(); x.open('GET', url); x.send()`, + image: `const x = new Image(); x.src = url; document.body.append(x)`, + script: `const x = document.createElement('script'); x.src = url; document.body.append(x)`, + iframe: `const x = document.createElement('iframe'); x.src = url; document.body.append(x)`, + 'css-image': `const x = document.createElement('div'); x.style.cssText = 'width:40px;height:40px;background-image:url(' + url + ')'; document.body.append(x)`, + 'video-poster': `const x = document.createElement('video'); x.poster = url; document.body.append(x)`, + form: `const x = document.createElement('form'); x.method = 'POST'; x.action = url; document.body.append(x); x.submit()`, + navigation: `location.href = url`, + websocket: `const x = new WebSocket(url.replace('http:', 'ws:')); x.onerror = () => {}`, +}; +const jsonMode = process.argv.includes('--json'); +const injectCanary = process.argv.includes('--inject-notification-canary'); +const csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"; +const escapeAttribute = value => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<'); +let base; +const received = []; +const gateBlocked = []; +let allowedRequests = new Set(); +let allowControlTunnel = false; +// A separate HTTP proxy enforces the fixture policy across renderer changes. +// CSP and target-scoped interception alone missed a navigation in testing. +const gate = createServer((request, response) => { + let url; + try { url = new URL(request.url); } catch { response.writeHead(400).end(); return; } + if (url.origin !== base || !allowedRequests.has(request.method + ' ' + url.href)) { + gateBlocked.push({method: request.method, url: url.href}); + response.writeHead(403).end(); return; + } + const upstream = httpRequest(url, {method: request.method, headers: request.headers}, result => { + response.writeHead(result.statusCode, result.headers); result.pipe(response); + }); + upstream.on('error', () => response.writeHead(502).end()); + request.pipe(upstream); +}); +gate.on('connect', (request, socket, head) => { + if (allowControlTunnel && request.url === new URL(base).host) { + const upstream = connect(server.address().port, '127.0.0.1', () => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.length) upstream.write(head); + socket.pipe(upstream); upstream.pipe(socket); + }); + upstream.on('error', () => socket.destroy()); socket.on('error', () => upstream.destroy()); + socket.on('close', () => upstream.destroy()); + return; + } + gateBlocked.push({method: 'CONNECT', url: request.url}); + socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); +}); +gate.on('upgrade', (request, socket, head) => { + let url; + try { url = new URL(request.url); } catch { socket.destroy(); return; } + // WebSocket proxy requests use ws: while the fixture allowlist uses http:. + const normalized = url.href.replace(/^ws:/, 'http:'); + if (!normalized.startsWith(base + '/') || !allowedRequests.has('UPGRADE ' + normalized)) { + gateBlocked.push({method: 'UPGRADE', url: url.href}); + socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); return; + } + const upstream = connect(server.address().port, '127.0.0.1', () => { + upstream.write(request.method + ' ' + url.pathname + ' HTTP/1.1\r\n' + + Object.entries(request.headers).map(([key, value]) => key + ': ' + value).join('\r\n') + '\r\n\r\n'); + if (head.length) upstream.write(head); + socket.pipe(upstream); upstream.pipe(socket); + }); + upstream.on('error', () => socket.destroy()); socket.on('error', () => upstream.destroy()); + socket.on('close', () => upstream.destroy()); +}); +const server = createServer((request, response) => { + const url = new URL(request.url, base); + if (url.pathname === '/preview') { + const id = url.searchParams.get('case'); + const isolated = url.searchParams.get('mode') === 'isolated'; + const operation = attempts[id]; + if (!operation) { response.writeHead(400).end(); return; } + const eventUrl = base + '/events/' + id; + const frame = `${isolated ? `` : ''}`; + const html = `Synthetic creative inspection + + `; + response.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }); + response.end(html); + } else if (url.pathname.startsWith('/events/')) { + received.push({ method: request.method, path: url.pathname, url: url.href }); + response.writeHead(200, { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }); + response.end('synthetic notification trap'); + } else { + response.writeHead(404).end(); + } +}); +server.on('upgrade', (request, socket) => { + const url = new URL(request.url, base); + received.push({ method: 'UPGRADE', path: url.pathname, url: url.href }); + socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); +}); + +class CDP { + constructor(socket) { + this.socket = socket; this.id = 0; this.pending = new Map(); this.handlers = []; + socket.addEventListener('message', ({data}) => { + const message = JSON.parse(data); + if (message.id) { + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); clearTimeout(pending.timer); + if (message.error) pending.reject(new Error(JSON.stringify(message.error))); + else pending.resolve(message.result); + } else for (const handler of this.handlers) handler(message); + }); + } + call(method, params = {}, sessionId) { + const id = ++this.id; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('CDP timed out: ' + method)); }, 10000); + this.pending.set(id, {resolve, reject, timer}); + this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? {sessionId} : {}) })); + }); + } + close() { this.socket.close(); } +} + +async function findChrome() { + const paths = process.env.CHROME_BIN ? [process.env.CHROME_BIN] : [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', + ]; + for (const path of paths) { try { await access(path); return path; } catch {} } + throw new Error('Chrome not found. Set CHROME_BIN to a Chrome/Chromium executable.'); +} + +let chrome, profile, browser, cdp; +const cases = []; +try { + const executable = await findChrome(); + profile = await mkdtemp(join(process.env.CREATIVE_CACHE_DIR || tmpdir(), 'openproposal-preview-')); + server.listen(0, '127.0.0.1'); await once(server, 'listening'); + base = 'http://127.0.0.1:' + server.address().port; + gate.listen(0, '127.0.0.1'); await once(gate, 'listening'); + chrome = spawn(executable, ['--headless=new', '--remote-debugging-port=0', '--user-data-dir=' + profile, + '--no-first-run', '--no-default-browser-check', '--disable-background-networking', + '--disable-component-update', '--disable-sync', '--disable-quic', + '--proxy-server=http://127.0.0.1:' + gate.address().port, '--proxy-bypass-list=<-loopback>', + 'about:blank'], {stdio: 'ignore'}); + let port; + for (let i = 0; i < 100; i++) { + try { port = (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]; break; } catch {} + if (chrome.exitCode !== null) throw new Error('Chrome exited before DevTools started'); + await delay(100); + } + if (!port) throw new Error('Chrome did not start DevTools'); + const endpoint = 'http://127.0.0.1:' + port; + browser = await (await fetch(endpoint + '/json/version')).json(); + for (const id of Object.keys(attempts)) { + for (const mode of ['control', 'isolated', ...(id === 'navigation' ? ['network-gate'] : [])]) { + const target = await (await fetch(endpoint + '/json/new?about:blank', {method: 'PUT'})).json(); + const socket = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, {once:true}); socket.addEventListener('error', reject, {once:true}); }); + cdp = new CDP(socket); + const blocked = [], protocolErrors = [], pendingEvents = new Set(); + const previewUrl = base + '/preview?case=' + id + '&mode=' + mode; + const isolated = mode !== 'control'; + const expectedMethod = {'fetch-head':'HEAD', 'fetch-post':'POST', beacon:'POST', form:'POST', websocket:'UPGRADE'}[id] || 'GET'; + allowedRequests = new Set(['GET ' + previewUrl]); + if (!isolated) allowedRequests.add(expectedMethod + ' ' + base + '/events/' + id); + allowControlTunnel = id === 'websocket' && mode === 'control'; + gateBlocked.length = 0; + async function guard(sessionId) { + await cdp.call('Fetch.enable', {patterns: [{urlPattern: '*', requestStage: 'Request'}]}, sessionId); + await cdp.call('Target.setAutoAttach', {autoAttach: true, waitForDebuggerOnStart: true, flatten: true}, sessionId); + } + cdp.handlers.push(message => { + const work = (async () => { + if (message.method === 'Fetch.requestPaused') { + const {requestId, request} = message.params; + if (request.url === previewUrl && request.method === 'GET') { + await cdp.call('Fetch.continueRequest', {requestId}, message.sessionId); + } else { + blocked.push({url: request.url, method: request.method}); + await cdp.call('Fetch.failRequest', {requestId, errorReason: 'BlockedByClient'}, message.sessionId); + } + } else if (message.method === 'Target.attachedToTarget' && isolated) { + await guard(message.params.sessionId); + await cdp.call('Runtime.runIfWaitingForDebugger', {}, message.params.sessionId); + } + })().catch(error => protocolErrors.push(error.message)); + pendingEvents.add(work); work.finally(() => pendingEvents.delete(work)); + }); + await cdp.call('Page.enable'); await cdp.call('Runtime.enable'); + if (mode === 'isolated') await guard(); + const observationStart = received.length; + await cdp.call('Page.navigate', {url: previewUrl}); + let messages = []; + for (let i = 0; i < 100; i++) { + const state = await cdp.call('Runtime.evaluate', {expression: 'window.fixtureMessages || []', returnByValue: true}); + messages = state.result.value || []; + if (messages.some(message => message.attempted === id)) break; + await delay(25); + } + // Finite observation window is reported with the receipt, not a promise + // that arbitrary creative could not schedule traffic at a later time. + await delay(500); + await Promise.all([...pendingEvents]); + const state = await cdp.call('Runtime.evaluate', {expression: 'window.fixtureMessages || []', returnByValue: true}); + messages = state.result.value || []; + // Negative control for the assertion itself, sent by this test driver. + // A run with this flag must fail and retain the unexpected event. + if (injectCanary && mode === 'isolated' && id === 'fetch-get') { + await fetch(base + '/events/unexpected-audit-canary'); + } + const observation = assessNotifications(received.slice(observationStart), { + isolated, expectedPath: '/events/' + id, expectedMethod, + }); + const notifications = observation.notifications; + const attempted = messages.some(message => message.attempted === id); + const unexpectedScriptError = messages.some(message => message.error && message.error !== 'SecurityError'); + const passed = attempted && !unexpectedScriptError && protocolErrors.length === 0 && observation.passed; + cases.push({id: id + '/' + mode, passed, attempted, expected_method: expectedMethod, + server_notifications: notifications, blocked_requests: blocked, + network_gate_blocks: gateBlocked.slice(), messages, + protocol_errors: protocolErrors, observation_ms_after_attempt: 500}); + await fetch(endpoint + '/json/close/' + target.id); + cdp.close(); cdp = null; + } + } + const report = {profile: 'synthetic-browser-inspection-2', browser: browser.Browser, + assertion_canary_injected: injectCanary, + tested_at: new Date().toISOString(), passed: cases.filter(item => item.passed).length, + total: cases.length, cases}; + if (jsonMode) console.log(JSON.stringify(report, null, 2)); + else { + for (const item of cases) console.log((item.passed ? 'PASS ' : 'FAIL ') + item.id); + console.log(`${report.passed}/${report.total} synthetic browser cases pass (${browser.Browser})`); + } + if (report.passed !== report.total) process.exitCode = 1; +} finally { + cdp?.close(); + if (chrome && chrome.exitCode === null && chrome.signalCode === null) { + chrome.kill('SIGTERM'); + await Promise.race([once(chrome, 'exit'), delay(3000)]); + if (chrome.exitCode === null && chrome.signalCode === null) { + chrome.kill('SIGKILL'); await once(chrome, 'exit'); + } + } + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + gate.closeAllConnections(); + await new Promise(resolve => gate.close(resolve)); + if (profile) await rm(profile, {recursive: true, force: true, maxRetries: 5, retryDelay: 100}); +} diff --git a/scripts/check_preview_observations.mjs b/scripts/check_preview_observations.mjs new file mode 100644 index 0000000..ea39205 --- /dev/null +++ b/scripts/check_preview_observations.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { assessNotifications } from './creative_preview_observations.mjs'; + +const expectedPath = '/events/fetch-get'; +const expectedMethod = 'GET'; +const cases = [ + {id:'empty-isolated-log', isolated:true, observations:[], expected:true}, + {id:'expected-event-is-still-forbidden', isolated:true, observations:[{method:'GET',path:expectedPath}], expected:false}, + {id:'unexpected-event-path-is-forbidden', isolated:true, observations:[{method:'GET',path:'/events/unexpected-audit-canary'}], expected:false}, + {id:'unexpected-event-method-is-forbidden', isolated:true, observations:[{method:'HEAD',path:'/events/unexpected'}], expected:false}, + {id:'mixed-events-are-all-preserved', isolated:true, observations:[{method:'GET',path:expectedPath},{method:'POST',path:'/events/other'}], expected:false}, + {id:'control-must-reach-trap', isolated:false, observations:[], expected:false}, + {id:'expected-control-event', isolated:false, observations:[{method:'GET',path:expectedPath}], expected:true}, + {id:'wrong-control-path', isolated:false, observations:[{method:'GET',path:'/events/other'}], expected:false}, + {id:'wrong-control-method', isolated:false, observations:[{method:'HEAD',path:expectedPath}], expected:false}, + {id:'control-with-extra-notification', isolated:false, observations:[{method:'GET',path:expectedPath},{method:'GET',path:'/events/other'}], expected:false}, +]; +const results = cases.map(vector => { + const result = assessNotifications(vector.observations, {isolated:vector.isolated, expectedPath, expectedMethod}); + // The receipt must retain the complete observations, even when they fail. + assert.deepEqual(result.notifications, vector.observations); + assert.notEqual(result.notifications, vector.observations); + return {id:vector.id, passed:result.passed === vector.expected, expected_check_result:vector.expected, actual:result}; +}); +const report = {profile:'preview-notification-assertions-1', passed:results.filter(x=>x.passed).length, total:results.length, cases:results}; +if(process.argv.includes('--json')) console.log(JSON.stringify(report,null,2)); +else {for(const item of results) console.log((item.passed?'PASS ':'FAIL ')+item.id);console.log(`${report.passed}/${report.total} notification assertions pass`);} +if(report.passed !== report.total) process.exitCode=1; diff --git a/scripts/creative_preview_observations.mjs b/scripts/creative_preview_observations.mjs new file mode 100644 index 0000000..76c2880 --- /dev/null +++ b/scripts/creative_preview_observations.mjs @@ -0,0 +1,9 @@ +// Shared assertion for the synthetic notification trap, not an egress filter. +export function assessNotifications(observations, {isolated, expectedPath, expectedMethod}) { + const notifications = observations.map(observation => ({...observation})); + const expected = observation => observation.path === expectedPath && observation.method === expectedMethod; + return { + passed: isolated ? notifications.length === 0 : notifications.length > 0 && notifications.every(expected), + notifications, + }; +} diff --git a/scripts/inspect_creative_fixture.py b/scripts/inspect_creative_fixture.py new file mode 100644 index 0000000..0c0780b --- /dev/null +++ b/scripts/inspect_creative_fixture.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Bounded, fixture-only document inspection supporting OpenProposal #42. + +Not a production fetcher, player, VAST validator or creative-readiness service. +""" + +import hashlib +import ipaddress +import urllib.error +import urllib.request +from urllib.parse import urlsplit, urlunsplit +import xml.etree.ElementTree as ET +from datetime import datetime, timezone + +EVENT_ELEMENTS = {"Impression", "Error", "Tracking", "ClickTracking", "ClickThrough", + "CustomClick", "Viewable", "NotViewable", "ViewUndetermined", + "CompanionClickTracking", "CompanionClickThrough", + "NonLinearClickTracking", "NonLinearClickThrough", + "IconViewTracking", "IconClickTracking", "IconClickThrough"} + + +class Unchecked(Exception): + """The check could not run within the fixture's retrieval policy.""" + + +def request_url(url): + """Canonicalize the HTTP request identity used by this fixture transport. + + Fragments never reach the server. Preserve path/query spelling because + application routing and signatures can depend on it. This cannot identify + arbitrary server-side aliases, redirects or the meaning of a resource. + """ + if not isinstance(url, str) or not url.isascii() or any(ord(c) <= 32 or ord(c) == 127 for c in url): + raise Unchecked("URL is outside the ASCII fixture profile") + try: + parts = urlsplit(url) + if parts.scheme not in ("http", "https") or not parts.hostname or parts.username is not None or parts.password is not None: + raise ValueError("unsupported authority") + host = parts.hostname.lower() + if ":" in host: + host = "[" + ipaddress.IPv6Address(host).compressed + "]" + port = parts.port + if port is not None and not 1 <= port <= 65535: + raise ValueError("invalid port") + authority = host + if port is not None and port != (80 if parts.scheme == "http" else 443): + authority += ":" + str(port) + normalized = urlunsplit((parts.scheme, authority, parts.path or "/", parts.query, "")) + if not parts.query and "?" in url.split("#", 1)[0]: + normalized += "?" + return normalized + except ValueError as error: + raise Unchecked("URL is outside the HTTP fixture profile") from error + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +class FixtureTransport: + def __init__(self, documents, max_bytes=65536): + # Explicit document URLs come from the local fixture server, not the XML. + # Callers must establish each URL is a document resource, not an event. + self.documents = frozenset(request_url(url) for url in documents) + self.max_bytes = max_bytes + self.requests = [] + self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + + def get(self, url): + url = request_url(url) + if url not in self.documents: + raise Unchecked("document URL is outside the fixture allowlist") + self.requests.append({"method": "GET", "url": url}) + try: + with self.opener.open(url, timeout=2) as response: + data = response.read(self.max_bytes + 1) + except (urllib.error.URLError, TimeoutError, OSError) as error: + raise Unchecked("document retrieval failed or redirected: " + type(error).__name__) from error + if len(data) > self.max_bytes: + raise Unchecked("document exceeds the inspection byte limit") + return data + + +def inspect(root_url, transport, *, asset_id="fixture-asset", max_documents=5): + checks, documents, event_urls, visited = [], [], set(), set() + event_requests = set() + + def record(name, outcome, reason, url=None): + check = {"check": name, "outcome": outcome, "reason": reason} + if url: + check["document_url"] = url + checks.append(check) + + url = root_url + for _ in range(max_documents): + try: + identity = request_url(url) + except Unchecked as error: + record("document_retrieval", "not_checked", str(error), url) + record("wrapper_resolution", "not_checked", "target URL is unsupported", url) + break + if identity in event_requests: + record("wrapper_resolution", "not_checked", "target is also identified as an event URL", url) + break + if identity in visited: + record("wrapper_resolution", "failed", "wrapper cycle in retrieved documents", url) + break + visited.add(identity) + try: + data = transport.get(url) + except Unchecked as error: + record("document_retrieval", "not_checked", str(error), url) + record("wrapper_resolution", "not_checked", "a required document was not retrieved", url) + break + record("document_retrieval", "passed", "retrieved the requested document bytes", url) + documents.append({"url": url, "request_url": identity, + "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}) + try: + xml = data.decode("utf-8") + except UnicodeDecodeError: + record("xml_well_formedness", "not_checked", "fixture reader supports UTF-8 only", url) + record("wrapper_resolution", "not_checked", "document was not parsed", url) + break + if "\x00" in xml or "` or `verificationNotExecuted` URL. + +A skipped or blocked check MUST NOT be reported as passed. This section does +not waive any required checks. + +These requirements apply to inspection only. During live delivery, +implementations remain subject to the tracking and error-reporting requirements +of the applicable execution standard. + ### 5.5 Targeting envelope Each field declares bounds, not a value. A bare value means the field is @@ -371,6 +407,10 @@ buyer commitment, and `settable` bounds. See See [`examples/line-item-ctv.yaml`](../examples/line-item-ctv.yaml). +Inspection of this line item's VAST creative, including wrapper retrieval and +previews, MUST follow the inspection requirements in section 5.4. Inspection +MUST NOT send production tracking or error notifications. + ### 6.4 Audio line item See [`examples/line-item-audio.yaml`](../examples/line-item-audio.yaml).