From 7ab6d2b39d688b92790336cf7c2f2604f012a605 Mon Sep 17 00:00:00 2001
From: Aleksander Sekowski
Date: Sat, 24 Oct 2026 10:46:41 -0700
Subject: [PATCH 1/2] Separate creative inspection from production
notifications
---
.github/workflows/validate.yml | 10 ++
docs/creative-inspection-fixtures.md | 137 ++++++++++++++
examples/line-item-ctv.yaml | 3 +
fixtures/creative/inline.xml | 45 +++++
fixtures/creative/wrapper.xml | 20 +++
scripts/check_creative_fixtures.py | 142 +++++++++++++++
scripts/check_creative_preview.mjs | 256 +++++++++++++++++++++++++++
scripts/inspect_creative_fixture.py | 146 +++++++++++++++
spec/CHANGELOG.md | 4 +
spec/openproposal-3.0.md | 40 +++++
10 files changed, 803 insertions(+)
create mode 100644 docs/creative-inspection-fixtures.md
create mode 100644 fixtures/creative/inline.xml
create mode 100644 fixtures/creative/wrapper.xml
create mode 100644 scripts/check_creative_fixtures.py
create mode 100644 scripts/check_creative_preview.mjs
create mode 100644 scripts/inspect_creative_fixture.py
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 1ea9da9..5a32d10 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -67,3 +67,13 @@ 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
diff --git a/docs/creative-inspection-fixtures.md b/docs/creative-inspection-fixtures.md
new file mode 100644
index 0000000..b532882
--- /dev/null
+++ b/docs/creative-inspection-fixtures.md
@@ -0,0 +1,137 @@
+# 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 exact document URLs 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.
+
+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
+```
+
+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 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..24d440e
--- /dev/null
+++ b/scripts/check_creative_fixtures.py
@@ -0,0 +1,142 @@
+#!/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, checks_satisfied, inspect
+
+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),
+ ]
+ 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 | ({base + "/events/wrapper-impression"} if path == "/dual-role" else 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
+ 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()})
+ return {"profile": "creative-inspection-discussion-1", "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..c4dbb14
--- /dev/null
+++ b/scripts/check_creative_preview.mjs
@@ -0,0 +1,256 @@
+#!/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';
+
+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 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 });
+ 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) => {
+ received.push({ method: 'UPGRADE', path: new URL(request.url, base).pathname });
+ 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();
+ received.length = 0;
+ 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 || [];
+ const notifications = received.filter(request => request.path === '/events/' + id);
+ const attempted = messages.some(message => message.attempted === id);
+ const passed = attempted && protocolErrors.length === 0 && (isolated ? notifications.length === 0 : notifications.some(request => request.method === expectedMethod));
+ 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-1', browser: browser.Browser,
+ 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/inspect_creative_fixture.py b/scripts/inspect_creative_fixture.py
new file mode 100644
index 0000000..941dfb5
--- /dev/null
+++ b/scripts/inspect_creative_fixture.py
@@ -0,0 +1,146 @@
+#!/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 urllib.error
+import urllib.request
+import xml.etree.ElementTree as ET
+from datetime import datetime, timezone
+
+EVENT_ELEMENTS = {"Impression", "Error", "Tracking", "ClickTracking", "ClickThrough",
+ "CustomClick", "Viewable", "NotViewable", "ViewUndetermined"}
+
+
+class Unchecked(Exception):
+ """The check could not run within the fixture's retrieval policy."""
+
+
+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 exact 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(documents)
+ self.max_bytes = max_bytes
+ self.requests = []
+ self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
+
+ def get(self, 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()
+
+ 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):
+ if url in event_urls:
+ record("wrapper_resolution", "not_checked", "target is also identified as an event URL", url)
+ break
+ if url in visited:
+ record("wrapper_resolution", "failed", "wrapper cycle in retrieved documents", url)
+ break
+ visited.add(url)
+ 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, "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).
From 8fde92cefc82e806c71889fe0468032f6943d314 Mon Sep 17 00:00:00 2001
From: Aleksander Sekowski
Date: Sat, 24 Oct 2026 11:37:06 -0700
Subject: [PATCH 2/2] Close inspection URL and notification-observation gaps
---
.github/workflows/validate.yml | 4 ++
docs/creative-inspection-fixtures.md | 40 ++++++++++++-
scripts/check_creative_fixtures.py | 73 ++++++++++++++++++++++-
scripts/check_creative_preview.mjs | 25 ++++++--
scripts/check_preview_observations.mjs | 28 +++++++++
scripts/creative_preview_observations.mjs | 9 +++
scripts/inspect_creative_fixture.py | 72 ++++++++++++++++++----
7 files changed, 230 insertions(+), 21 deletions(-)
create mode 100644 scripts/check_preview_observations.mjs
create mode 100644 scripts/creative_preview_observations.mjs
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 5a32d10..4b450dd 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -77,3 +77,7 @@ jobs:
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
index b532882..b75c288 100644
--- a/docs/creative-inspection-fixtures.md
+++ b/docs/creative-inspection-fixtures.md
@@ -23,13 +23,30 @@ 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 exact document URLs listed by the test
-harness. It follows a single-ad wrapper chain, hashes retrieved bytes and
+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
@@ -53,6 +70,7 @@ 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,
@@ -64,6 +82,24 @@ 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
diff --git a/scripts/check_creative_fixtures.py b/scripts/check_creative_fixtures.py
index 24d440e..0bd8bf8 100644
--- a/scripts/check_creative_fixtures.py
+++ b/scripts/check_creative_fixtures.py
@@ -8,7 +8,7 @@
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
-from inspect_creative_fixture import FixtureTransport, checks_satisfied, inspect
+from inspect_creative_fixture import FixtureTransport, Unchecked, checks_satisfied, inspect, request_url
ROOT = Path(__file__).resolve().parents[1]
@@ -87,13 +87,56 @@ def wrap(target):
("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}>{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 | ({base + "/events/wrapper-impression"} if path == "/dual-role" else set())
+ 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/")]
@@ -105,6 +148,9 @@ def wrap(target):
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.
@@ -120,7 +166,28 @@ def wrap(target):
results.append({"id": "notification-trap-positive-control", "passed": requests == [
{"method": "GET", "path": "/events/control"}, {"method": "HEAD", "path": "/events/control"}],
"server_requests": requests.copy()})
- return {"profile": "creative-inspection-discussion-1", "passed": sum(item["passed"] for item in results),
+ 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()
diff --git a/scripts/check_creative_preview.mjs b/scripts/check_creative_preview.mjs
index c4dbb14..e650814 100644
--- a/scripts/check_creative_preview.mjs
+++ b/scripts/check_creative_preview.mjs
@@ -7,6 +7,7 @@ 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 = {
@@ -25,6 +26,7 @@ const attempts = {
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;
@@ -99,7 +101,7 @@ const server = createServer((request, response) => {
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 });
+ 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 {
@@ -107,7 +109,8 @@ const server = createServer((request, response) => {
}
});
server.on('upgrade', (request, socket) => {
- received.push({ method: 'UPGRADE', path: new URL(request.url, base).pathname });
+ 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');
});
@@ -204,7 +207,7 @@ try {
});
await cdp.call('Page.enable'); await cdp.call('Runtime.enable');
if (mode === 'isolated') await guard();
- received.length = 0;
+ const observationStart = received.length;
await cdp.call('Page.navigate', {url: previewUrl});
let messages = [];
for (let i = 0; i < 100; i++) {
@@ -219,9 +222,18 @@ try {
await Promise.all([...pendingEvents]);
const state = await cdp.call('Runtime.evaluate', {expression: 'window.fixtureMessages || []', returnByValue: true});
messages = state.result.value || [];
- const notifications = received.filter(request => request.path === '/events/' + id);
+ // 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 passed = attempted && protocolErrors.length === 0 && (isolated ? notifications.length === 0 : notifications.some(request => request.method === expectedMethod));
+ 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,
@@ -230,7 +242,8 @@ try {
cdp.close(); cdp = null;
}
}
- const report = {profile: 'synthetic-browser-inspection-1', browser: browser.Browser,
+ 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));
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
index 941dfb5..0c0780b 100644
--- a/scripts/inspect_creative_fixture.py
+++ b/scripts/inspect_creative_fixture.py
@@ -5,19 +5,54 @@
"""
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"}
+ "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
@@ -25,14 +60,15 @@ def redirect_request(self, req, fp, code, msg, headers, newurl):
class FixtureTransport:
def __init__(self, documents, max_bytes=65536):
- # Explicit exact URLs come from the local fixture server, not the XML.
+ # 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(documents)
+ 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})
@@ -48,6 +84,7 @@ def get(self, url):
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}
@@ -57,13 +94,19 @@ def record(name, outcome, reason, url=None):
url = root_url
for _ in range(max_documents):
- if url in event_urls:
+ 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 url in visited:
+ if identity in visited:
record("wrapper_resolution", "failed", "wrapper cycle in retrieved documents", url)
break
- visited.add(url)
+ visited.add(identity)
try:
data = transport.get(url)
except Unchecked as error:
@@ -71,7 +114,8 @@ def record(name, outcome, reason, url=None):
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, "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)})
+ documents.append({"url": url, "request_url": identity,
+ "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)})
try:
xml = data.decode("utf-8")
except UnicodeDecodeError:
@@ -95,7 +139,14 @@ def record(name, outcome, reason, url=None):
break
for element in root.iter():
if element.tag in EVENT_ELEMENTS and element.text and element.text.strip():
- event_urls.add(element.text.strip())
+ event_url = element.text.strip()
+ event_urls.add(event_url)
+ try:
+ event_requests.add(request_url(event_url))
+ except Unchecked:
+ # Unsupported event URIs are never retrieval candidates.
+ # This does not validate them or expand their macros.
+ pass
ads = root.findall("Ad")
if len(ads) != 1:
record("wrapper_resolution", "not_checked", "fixture profile requires exactly one Ad", url)
@@ -125,11 +176,12 @@ def record(name, outcome, reason, url=None):
("seller_approval", "no seller decision was supplied"),
):
record(name, "not_checked", reason)
- return {"receipt_profile": "creative-inspection-discussion-1", "asset_id": asset_id,
+ return {"receipt_profile": "creative-inspection-discussion-2", "asset_id": asset_id,
"inspected_at": datetime.now(timezone.utc).isoformat(),
"context": {"root_url": root_url, "scope": "single-ad UTF-8 fixture document inspection",
"max_documents": max_documents, "max_document_bytes": transport.max_bytes,
- "redirect_policy": "blocked", "macro_expansion": "not_performed"},
+ "redirect_policy": "blocked", "macro_expansion": "not_performed",
+ "url_identity": "HTTP authority and default port normalized; fragments removed; path and query preserved"},
"documents": documents, "identified_event_urls": sorted(event_urls),
"requests": transport.requests, "checks": checks, "readiness": "not_assessed"}