Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strict-brand-domains.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions docs/brand-protocol/key-concepts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions scripts/build-protocol-tarball.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -472,6 +497,7 @@ module.exports = {
buildTarball,
pinGeneratedAt,
pinPublishedVersion,
pinSchemaTreeVersion,
resolveBuildMetadata,
writeIntegritySidecars
};
3 changes: 3 additions & 0 deletions scripts/lint-storyboard-sample-request-schema.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions server/src/brand-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -203,15 +203,19 @@ export class BrandManager {
// Cache for failed lookups (5 minutes, never longer than a resolution miss)
private failedLookupCache: Cache<BrandValidationResult>;
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<BrandValidationResult>(BRAND_MANAGER_CACHE_TTL_SECONDS.origin / 60, BRAND_CACHE_MAX_ENTRIES);
this.resolutionCache = new Cache<ResolvedBrand | null>(BRAND_MANAGER_CACHE_TTL_SECONDS.origin / 60, BRAND_CACHE_MAX_ENTRIES);
this.failedLookupCache = new Cache<BrandValidationResult>(BRAND_MANAGER_CACHE_TTL_SECONDS.negative / 60, BRAND_FAILED_CACHE_MAX_ENTRIES);
this.observeRelationshipDeclaration = options.observeRelationshipDeclaration
?? observeBrandRelationshipDeclaration;
this.allowDevelopmentDomains = options.allowDevelopmentDomains === true;
}

/**
Expand Down Expand Up @@ -273,7 +277,9 @@ export class BrandManager {
.replace(/^https?:\/\//, '')
.replace(/\/$/, '');
try {
assertValidBrandDomain(normalized);
assertRegistrableBrandDomain(normalized, {
allowDevelopmentDomains: this.allowDevelopmentDomains,
});
return normalized;
} catch {
return null;
Expand Down
58 changes: 57 additions & 1 deletion server/src/services/identifier-normalization.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { parse as parseTld } from 'tldts';

/**
* Identifier normalization for the property catalog.
*
Expand All @@ -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,
Expand All @@ -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.`);
}
}

/**
Expand Down
18 changes: 18 additions & 0 deletions server/tests/unit/brand-manager-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe('BrandManager caching', () => {
let relationshipDeclarations: Map<string, number>;

const createManager = () => new BrandManager({
allowDevelopmentDomains: true,
observeRelationshipDeclaration: async (declaration) => {
const key = [
declaration.houseDomain.toLowerCase(),
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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');
},
Expand All @@ -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');
},
Expand Down
32 changes: 29 additions & 3 deletions server/tests/unit/canonicalize-brand-domain.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -40,7 +40,7 @@ describe('canonicalizeBrandDomain', () => {
});
});

describe('assertValidBrandDomain', () => {
describe('brand domain validation', () => {
it('accepts a typical apex domain', () => {
expect(() => assertValidBrandDomain('kyber1.com')).not.toThrow();
});
Expand All @@ -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();
});
Expand Down Expand Up @@ -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();
});

Expand Down
5 changes: 3 additions & 2 deletions static/schemas/source/core/brand-key.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions static/schemas/source/core/brand-ref.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions tests/lint-storyboard-sample-request-schema.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions tests/schema-validation.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading