diff --git a/.changeset/strict-brand-domains.md b/.changeset/strict-brand-domains.md new file mode 100644 index 0000000000..bbd07cd2f4 --- /dev/null +++ b/.changeset/strict-brand-domains.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": major +--- + +Require dotted domains in BrandRef and BrandKey wire schemas, define explicit reserved-domain development exceptions for SDKs, and reserve public registrability plus DNS/SSRF checks for production resolution. diff --git a/docs/brand-protocol/key-concepts.mdx b/docs/brand-protocol/key-concepts.mdx index a42e5fa7f4..8ae17b0e69 100644 --- a/docs/brand-protocol/key-concepts.mdx +++ b/docs/brand-protocol/key-concepts.mdx @@ -155,6 +155,19 @@ There are three ways to resolve brand identity, each returning the same data str Regardless of source, the result is a brand identity that can be referenced by any AdCP task via a brand reference (`{ "domain": "...", "brand_id": "..." }`). +`BrandRef.domain` is a lowercase dotted domain, not a local hostname alias. Bare +names such as `localhost`, `unknown`, and `intranet` are invalid because another +agent cannot resolve them in the same DNS context. Production resolvers require +a hostname with a registrable ICANN or private-PSL parent and apply the protocol's DNS and SSRF checks before fetching +`/.well-known/brand.json`. + +For local development and deterministic fixtures, SDKs may explicitly allow +dotted names under `.localhost`, `.test`, `.example`, or `.invalid` (including +the reserved `example.com`, `example.net`, and `example.org` names). This must be +an explicit development option, never inferred from the process environment. +Bare `localhost` remains invalid, and `.local` is not a development exception +because it participates in multicast DNS. + ## Use cases ### Creative generation diff --git a/scripts/build-protocol-tarball.cjs b/scripts/build-protocol-tarball.cjs index b1b81754c8..916d2699d8 100644 --- a/scripts/build-protocol-tarball.cjs +++ b/scripts/build-protocol-tarball.cjs @@ -168,6 +168,30 @@ function pinPublishedVersion(filePath, publishedVersion) { }); } +function pinSchemaTreeVersion(schemaDir, publishedVersion) { + const latestMarker = '/schemas/latest/'; + const pinnedMarker = `/schemas/${publishedVersion}/`; + + function pinReferences(value) { + if (!value || typeof value !== 'object') return false; + let changed = false; + for (const [key, child] of Object.entries(value)) { + if ((key === '$id' || key === '$ref') && typeof child === 'string' && child.includes(latestMarker)) { + value[key] = child.replace(latestMarker, pinnedMarker); + changed = true; + } else if (pinReferences(child)) { + changed = true; + } + } + return changed; + } + + for (const relativePath of walk(schemaDir)) { + if (!relativePath.endsWith('.json')) continue; + updateJsonFile(path.join(schemaDir, relativePath), pinReferences); + } +} + function writeBundleReadme(bundleDir, version, isDev) { const extractedDir = isDev ? 'adcp-latest' : `adcp-${version}`; const quickstart = isDev @@ -304,6 +328,7 @@ function stageBundle( pinGeneratedAt(path.join(schemasDst, 'manifest.json'), metadata.generatedAt); if (publishedVersion !== version) { pinPublishedVersion(path.join(schemasDst, 'index.json'), publishedVersion); + pinSchemaTreeVersion(schemasDst, publishedVersion); } const complianceSource = path.join(DIST_COMPLIANCE, version); @@ -472,6 +497,7 @@ module.exports = { buildTarball, pinGeneratedAt, pinPublishedVersion, + pinSchemaTreeVersion, resolveBuildMetadata, writeIntegritySidecars }; diff --git a/scripts/lint-storyboard-sample-request-schema.cjs b/scripts/lint-storyboard-sample-request-schema.cjs index 4ae0d6a402..2095a4ce89 100644 --- a/scripts/lint-storyboard-sample-request-schema.cjs +++ b/scripts/lint-storyboard-sample-request-schema.cjs @@ -181,6 +181,9 @@ function placeholderFor(schema, depth = 0) { if (resolved.pattern === '^sha256:[A-Za-z0-9_-]{43}$') return SHA256_DIGEST_PLACEHOLDER; if (resolved.pattern === '^[A-Fa-f0-9]{64}$') return 'a'.repeat(64); if (resolved.pattern === '^[A-Z]{3}$') return 'USD'; + if (resolved.pattern === '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$') { + return 'placeholder.example'; + } // Object variant nested inside a oneOf/anyOf at a location where the // author's substitution will resolve to that shape at runtime. Synthesize // the concrete shape instead of returning a string that fails required. diff --git a/server/src/brand-manager.ts b/server/src/brand-manager.ts index e6ba41fb46..23921e9709 100644 --- a/server/src/brand-manager.ts +++ b/server/src/brand-manager.ts @@ -13,7 +13,7 @@ import type { } from './types'; import { AAO_UA_VALIDATOR } from './config/user-agents.js'; import { withSdkSafeTransport } from './utils/sdk-safe-fetch.js'; -import { assertValidBrandDomain } from './services/identifier-normalization.js'; +import { assertRegistrableBrandDomain } from './services/identifier-normalization.js'; import { observeBrandRelationshipDeclaration, type BrandRelationshipDeclaration, @@ -203,15 +203,19 @@ export class BrandManager { // Cache for failed lookups (5 minutes, never longer than a resolution miss) private failedLookupCache: Cache; private observeRelationshipDeclaration: RelationshipDeclarationObserver; + private allowDevelopmentDomains: boolean; constructor(options: { observeRelationshipDeclaration?: RelationshipDeclarationObserver; + /** Admit only the protocol's reserved dotted development names. */ + allowDevelopmentDomains?: boolean; } = {}) { this.validationCache = new Cache(BRAND_MANAGER_CACHE_TTL_SECONDS.origin / 60, BRAND_CACHE_MAX_ENTRIES); this.resolutionCache = new Cache(BRAND_MANAGER_CACHE_TTL_SECONDS.origin / 60, BRAND_CACHE_MAX_ENTRIES); this.failedLookupCache = new Cache(BRAND_MANAGER_CACHE_TTL_SECONDS.negative / 60, BRAND_FAILED_CACHE_MAX_ENTRIES); this.observeRelationshipDeclaration = options.observeRelationshipDeclaration ?? observeBrandRelationshipDeclaration; + this.allowDevelopmentDomains = options.allowDevelopmentDomains === true; } /** @@ -273,7 +277,9 @@ export class BrandManager { .replace(/^https?:\/\//, '') .replace(/\/$/, ''); try { - assertValidBrandDomain(normalized); + assertRegistrableBrandDomain(normalized, { + allowDevelopmentDomains: this.allowDevelopmentDomains, + }); return normalized; } catch { return null; diff --git a/server/src/services/identifier-normalization.ts b/server/src/services/identifier-normalization.ts index 3d2c337907..9d28e07c1d 100644 --- a/server/src/services/identifier-normalization.ts +++ b/server/src/services/identifier-normalization.ts @@ -1,3 +1,5 @@ +import { parse as parseTld } from 'tldts'; + /** * Identifier normalization for the property catalog. * @@ -22,6 +24,23 @@ export function canonicalizeBrandDomain(raw: string): string { } const BRAND_DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; +const DEVELOPMENT_DOMAIN_SUFFIXES = ['localhost', 'test', 'example', 'invalid'] as const; +const DEVELOPMENT_DOMAIN_EXACT_NAMES = new Set(['example.com', 'example.net', 'example.org']); +const SPECIAL_USE_DOMAIN_SUFFIXES = [ + 'alt', '6tisch.arpa', 'eap.arpa', 'eap-noob.arpa', 'home.arpa', + 'in-addr.arpa', 'ip6.arpa', 'ipv4only.arpa', 'resolver.arpa', 'service.arpa', + 'example', 'example.com', 'example.net', 'example.org', 'invalid', 'local', + 'localhost', 'onion', 'test', +] as const; + +function hasDomainSuffix(domain: string, suffix: string): boolean { + return domain === suffix || domain.endsWith(`.${suffix}`); +} + +export function isDevelopmentBrandDomain(domain: string): boolean { + return DEVELOPMENT_DOMAIN_EXACT_NAMES.has(domain) + || DEVELOPMENT_DOMAIN_SUFFIXES.some((suffix) => domain.endsWith(`.${suffix}`)); +} /** * Throw if the canonicalized value isn't a plausible domain (multi-label, @@ -30,9 +49,46 @@ const BRAND_DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a- * profile fields. */ export function assertValidBrandDomain(canonical: string): void { - if (!BRAND_DOMAIN_RE.test(canonical) || canonical.length > 253) { + if ( + !BRAND_DOMAIN_RE.test(canonical) + || canonical.length > 253 + || canonical.split('.').some((label) => label.length > 63) + ) { throw new Error(`"${canonical}" is not a valid brand domain.`); } + +} + +/** + * Throw unless a syntactically valid domain has a registrable parent in the + * ICANN or private Public Suffix List. Reserved dotted names may be admitted + * only by an explicit development-only caller option. + */ +export function assertRegistrableBrandDomain( + canonical: string, + options: { allowDevelopmentDomains?: boolean } = {}, +): void { + assertValidBrandDomain(canonical); + + const developmentDomain = isDevelopmentBrandDomain(canonical); + if (developmentDomain && options.allowDevelopmentDomains === true) return; + + const parsed = parseTld(canonical, { + allowPrivateDomains: true, + detectSpecialUse: true, + extractHostname: false, + }); + const specialUse = SPECIAL_USE_DOMAIN_SUFFIXES.some((suffix) => hasDomainSuffix(canonical, suffix)); + if ( + developmentDomain + || specialUse + || parsed.isIp + || parsed.isSpecialUse + || !parsed.domain + || (!parsed.isIcann && !parsed.isPrivate) + ) { + throw new Error(`"${canonical}" is not a registrable production brand domain.`); + } } /** diff --git a/server/tests/unit/brand-manager-cache.test.ts b/server/tests/unit/brand-manager-cache.test.ts index ed74378bc2..db2e66c121 100644 --- a/server/tests/unit/brand-manager-cache.test.ts +++ b/server/tests/unit/brand-manager-cache.test.ts @@ -19,6 +19,7 @@ describe('BrandManager caching', () => { let relationshipDeclarations: Map; const createManager = () => new BrandManager({ + allowDevelopmentDomains: true, observeRelationshipDeclaration: async (declaration) => { const key = [ declaration.houseDomain.toLowerCase(), @@ -50,6 +51,21 @@ describe('BrandManager caching', () => { }); describe('validateDomain caching', () => { + it.each([ + '1.2.3.4', + 'co.uk', + 'brand.unknown', + 'brand.localhost', + 'brand.local', + 'brand.10.in-addr.arpa', + 'example.com', + ])('rejects non-production domain %s before network access', async (domain) => { + const productionManager = new BrandManager(); + const result = await productionManager.validateDomain(domain); + expect(result.valid).toBe(false); + expect(mockedSafeFetch).not.toHaveBeenCalled(); + }); + it('caches successful validation results', async () => { const mockBrandJson = { $schema: 'https://adcontextprotocol.org/schemas/latest/brand.json', @@ -1299,6 +1315,7 @@ describe('BrandManager caching', () => { it('fails closed when a missing effective_at cannot be durably observed', async () => { manager = new BrandManager({ + allowDevelopmentDomains: true, observeRelationshipDeclaration: async () => { throw new Error('database unavailable'); }, @@ -1317,6 +1334,7 @@ describe('BrandManager caching', () => { it('can evaluate explicit effective_at while durable storage is unavailable', async () => { const effectiveAt = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); manager = new BrandManager({ + allowDevelopmentDomains: true, observeRelationshipDeclaration: async () => { throw new Error('database unavailable'); }, diff --git a/server/tests/unit/canonicalize-brand-domain.test.ts b/server/tests/unit/canonicalize-brand-domain.test.ts index 312fa3e47b..5987509fba 100644 --- a/server/tests/unit/canonicalize-brand-domain.test.ts +++ b/server/tests/unit/canonicalize-brand-domain.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { canonicalizeBrandDomain, assertValidBrandDomain, assertClaimableBrandDomain } from '../../src/services/identifier-normalization.js'; +import { canonicalizeBrandDomain, assertValidBrandDomain, assertRegistrableBrandDomain, assertClaimableBrandDomain } from '../../src/services/identifier-normalization.js'; describe('canonicalizeBrandDomain', () => { it('strips https:// protocol', () => { @@ -40,7 +40,7 @@ describe('canonicalizeBrandDomain', () => { }); }); -describe('assertValidBrandDomain', () => { +describe('brand domain validation', () => { it('accepts a typical apex domain', () => { expect(() => assertValidBrandDomain('kyber1.com')).not.toThrow(); }); @@ -49,6 +49,32 @@ describe('assertValidBrandDomain', () => { expect(() => assertValidBrandDomain('app.kyber1.com')).not.toThrow(); }); + it('rejects public suffixes, unknown suffixes, IP literals, and special-use names', () => { + for (const domain of [ + 'co.uk', + 'brand.unknown', + '1.2.3.4', + 'brand.local', + 'brand.10.in-addr.arpa', + 'example.com', + ]) { + expect(() => assertRegistrableBrandDomain(domain)).toThrow(); + } + }); + + it('admits only narrow dotted development names with explicit opt-in', () => { + for (const domain of ['brand.localhost', 'brand.test', 'brand.example', 'brand.invalid', 'example.com']) { + expect(() => assertRegistrableBrandDomain(domain)).toThrow(); + expect(() => assertRegistrableBrandDomain(domain, { allowDevelopmentDomains: true })).not.toThrow(); + } + expect(() => assertRegistrableBrandDomain('localhost', { allowDevelopmentDomains: true })).toThrow(); + expect(() => assertRegistrableBrandDomain('brand.local', { allowDevelopmentDomains: true })).toThrow(); + }); + + it('rejects labels longer than 63 octets', () => { + expect(() => assertValidBrandDomain(`${'a'.repeat(64)}.com`)).toThrow(); + }); + it('rejects a single-label hostname', () => { expect(() => assertValidBrandDomain('localhost')).toThrow(); }); @@ -145,7 +171,7 @@ describe('assertClaimableBrandDomain', () => { it('does NOT match domains that merely look like a suffix substring', () => { // The suffix matcher requires a leading `.`; otherwise `xhubspotusercontent.com` // would falsely match `hubspotusercontent.com`. - expect(() => assertClaimableBrandDomain('foo.example.com')).not.toThrow(); + expect(() => assertClaimableBrandDomain('foo.example-corp.com')).not.toThrow(); expect(() => assertClaimableBrandDomain('myhubspotusercontent.com')).not.toThrow(); }); diff --git a/static/schemas/source/core/brand-key.json b/static/schemas/source/core/brand-key.json index a57e3bc40f..b8b26df80a 100644 --- a/static/schemas/source/core/brand-key.json +++ b/static/schemas/source/core/brand-key.json @@ -7,8 +7,9 @@ "properties": { "domain": { "type": "string", - "description": "Domain that hosts /.well-known/brand.json or is registered for the brand.", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + "maxLength": 253, + "description": "Lowercase dotted domain that hosts /.well-known/brand.json or is registered for the brand. Single-label names such as localhost and intranet aliases are invalid. This schema checks portable wire syntax only; production resolvers additionally require a registrable domain and reject IP literals, IANA special-use names, and private or reserved resolution targets. SDK development overrides may admit only explicitly configured dotted test names and do not make those names production-conformant.", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" }, "brand_id": { "$ref": "/schemas/core/brand-id.json", diff --git a/static/schemas/source/core/brand-ref.json b/static/schemas/source/core/brand-ref.json index e13c3aec71..518283819a 100644 --- a/static/schemas/source/core/brand-ref.json +++ b/static/schemas/source/core/brand-ref.json @@ -7,8 +7,9 @@ "properties": { "domain": { "type": "string", - "description": "Domain where /.well-known/brand.json is hosted, or the brand's operating domain", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + "maxLength": 253, + "description": "Lowercase dotted domain where /.well-known/brand.json is hosted, or the brand's operating domain. Single-label names such as localhost and intranet aliases are invalid. This schema checks portable wire syntax only; production resolvers additionally require a registrable domain and reject IP literals, IANA special-use names, and private or reserved resolution targets. SDK development overrides may admit only explicitly configured dotted test names and do not make those names production-conformant.", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" }, "brand_id": { "$ref": "/schemas/core/brand-id.json", diff --git a/tests/lint-storyboard-sample-request-schema.test.cjs b/tests/lint-storyboard-sample-request-schema.test.cjs index 9fa2dcdc85..de2f9e9aee 100644 --- a/tests/lint-storyboard-sample-request-schema.test.cjs +++ b/tests/lint-storyboard-sample-request-schema.test.cjs @@ -289,6 +289,13 @@ test('normalizeSubstitutions produces a schema-valid currency placeholder', () = }), 'USD'); }); +test('normalizeSubstitutions produces a schema-valid dotted domain placeholder', () => { + assert.equal(normalizeSubstitutions('$context.brand_domain', { + type: 'string', + pattern: '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$', + }), 'placeholder.example'); +}); + // Object-typed substitution synthesis — the lint change landed in this PR. // A substitution that lands at an object location (plain or inside a // discriminated oneOf) must produce a shape-valid placeholder or ajv will diff --git a/tests/schema-validation.test.cjs b/tests/schema-validation.test.cjs index 3242ebc063..51c348a1c5 100644 --- a/tests/schema-validation.test.cjs +++ b/tests/schema-validation.test.cjs @@ -2958,6 +2958,30 @@ async function runTests() { return true; }); + await test('brand identity references require portable dotted domains', async () => { + for (const schemaFile of ['core/brand-ref.json', 'core/brand-key.json']) { + const schema = loadSchema(path.join(SCHEMA_BASE_DIR, schemaFile)); + const testAjv = new Ajv({ allErrors: true, strict: false, loadSchema: loadExternalSchema }); + addFormats(testAjv); + const validate = await testAjv.compileAsync(schema); + + for (const domain of ['brand.example', 'ads.brand.co.uk', 'brand.localhost']) { + if (!validate({ domain })) { + return `${schemaFile} rejected dotted wire domain ${domain}: ${JSON.stringify(validate.errors)}`; + } + } + for (const domain of ['localhost', 'unknown', 'intranet']) { + if (validate({ domain })) { + return `${schemaFile} accepted single-label domain ${domain}`; + } + } + if (validate({ domain: `${'a'.repeat(63)}.${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(62)}` })) { + return `${schemaFile} accepted a domain longer than 253 characters`; + } + } + return true; + }); + // Test 13: Validate schema examples against their schemas await test('Schema examples validate against their own schemas', async () => { // Skip schemas that require format-aware validation (creative manifests need format context) diff --git a/tests/sign-protocol-tarball.test.cjs b/tests/sign-protocol-tarball.test.cjs index 117dea8ff4..0c7fd263a7 100644 --- a/tests/sign-protocol-tarball.test.cjs +++ b/tests/sign-protocol-tarball.test.cjs @@ -12,6 +12,7 @@ const { buildTarball, pinGeneratedAt, pinPublishedVersion, + pinSchemaTreeVersion, resolveBuildMetadata, writeIntegritySidecars, } = require('../scripts/build-protocol-tarball.cjs'); @@ -257,4 +258,29 @@ describe('build-protocol-tarball.cjs', () => { fs.rmSync(tmp, { recursive: true, force: true }); } }); + + it('pins schema ids and references in release-compatible PR bundles', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'adcp-protocol-schema-tree-')); + try { + fs.mkdirSync(path.join(tmp, 'core'), { recursive: true }); + const schemaPath = path.join(tmp, 'core', 'manifest.json'); + fs.writeFileSync(schemaPath, JSON.stringify({ + $id: 'https://adcontextprotocol.org/schemas/latest/core/manifest.json', + properties: { + entry: { $ref: '/schemas/latest/core/entry.json' }, + }, + })); + + pinSchemaTreeVersion(tmp, '3.2.0-beta.9'); + + assert.deepEqual(JSON.parse(fs.readFileSync(schemaPath, 'utf8')), { + $id: 'https://adcontextprotocol.org/schemas/3.2.0-beta.9/core/manifest.json', + properties: { + entry: { $ref: '/schemas/3.2.0-beta.9/core/entry.json' }, + }, + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); });