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
41 changes: 41 additions & 0 deletions .changeset/managerdomain-scope-property-level.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
---

fix(adagents): accept property-level publisher_domain in managerdomain scope gate

The explicit-publisher-scoping gate from #4173 only inspected per-agent
paths (`authorized_agents[].publisher_properties[].publisher_domain` and
`authorized_agents[].collections[].publisher_domain`). Probing real
production manifests after #4251 landed showed every managed-network
manager rejects under the gate — Mediavine, the only manager currently
serving an adagents.json against a publisher with a managerdomain
pointer (`homestratosphere.com → mediavine.com`), uses property-level
scoping with tag-based agent references:

```json
"properties": [{ "publisher_domain": "thehollywoodgossip.com",
"tags": ["scope3-aee"] }],
"authorized_agents": [{ "authorization_type": "property_tags",
"property_tags": ["scope3-aee"] }]
```

The cross-publisher commitment is expressly declared — just routed
through the property layer rather than re-spelled per-agent.

Gate now accepts either shape:

- Per-agent paths (existing): `publisher_properties[].publisher_domain`
or `collections[].publisher_domain` directly names the publisher.
- Property-level paths (new): a `properties[]` entry carries
`publisher_domain` matching the source, AND at least one
`authorized_agents[]` entry references that property indirectly via
`property_ids` or `property_tags`.

Cross-publisher confusion attacks still fail closed — a property
belonging to a different publisher can't satisfy the gate, and an agent
referencing a tag none of the publisher's properties carry can't
satisfy it either.

Tests added for: property_tags + property-level publisher_domain
(Mediavine pattern), property_ids + property-level publisher_domain,
foreign-property rejection, no-matching-tag rejection.
56 changes: 55 additions & 1 deletion server/src/adagents-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,18 +377,72 @@ export class AdAgentsManager {
return managers;
}

/**
* Verify that a manager-served adagents.json explicitly authorizes the
* source publisher domain. Required by the ads.txt MANAGERDOMAIN
* fallback (#4173) so a manager-side compromise can't auto-implicate
* publishers that merely point at the manager via ads.txt.
*
* Two ways the manifest can express that scope:
*
* 1. **Per-agent paths.** An authorized_agents[] entry directly names
* the publisher under publisher_properties[].publisher_domain or
* collections[].publisher_domain.
*
* 2. **Property-level paths.** A top-level properties[] entry carries
* publisher_domain matching the source, AND at least one
* authorized_agents[] entry references that property indirectly via
* property_ids or property_tags. This is the shape Mediavine and
* other managed networks use in production: the property declares
* its publisher_domain once, and many agents reference it through
* a tag without re-spelling the publisher.
*
* Both shapes establish the same invariant: the manager has
* positively named the publisher in its own manifest. Inline or
* implicit references that don't tie back to a publisher_domain
* field do not satisfy the gate — fail closed.
*/
private hasExplicitPublisherScope(rawData: unknown, publisherDomain: string): boolean {
if (!rawData || typeof rawData !== 'object') return false;
const data = rawData as AdAgentsJsonInline;
const agents = Array.isArray(data.authorized_agents) ? data.authorized_agents : [];
const properties = Array.isArray(data.properties) ? data.properties : [];
const normalizedPublisher = publisherDomain.toLowerCase();

// Index properties by id and by tag for the per-agent reference lookup.
// Both indexes filter to properties whose publisher_domain matches the
// source — properties belonging to other publishers can't satisfy the
// gate even if an agent references them.
const matchingPropertyIds = new Set<string>();
const matchingPropertyTags = new Set<string>();
for (const prop of properties) {
if (typeof prop?.publisher_domain !== 'string') continue;
if (prop.publisher_domain.toLowerCase() !== normalizedPublisher) continue;
if (typeof prop.property_id === 'string' && prop.property_id.length > 0) {
matchingPropertyIds.add(prop.property_id);
}
if (Array.isArray(prop.tags)) {
for (const tag of prop.tags) {
if (typeof tag === 'string' && tag.length > 0) matchingPropertyTags.add(tag);
}
}
}

return agents.some((agent) => {
const hasPublisherProperties = Array.isArray(agent.publisher_properties)
&& agent.publisher_properties.some((p) => p.publisher_domain.toLowerCase() === normalizedPublisher);
const hasCollections = Array.isArray(agent.collections)
&& agent.collections.some((c) => c.publisher_domain.toLowerCase() === normalizedPublisher);
return hasPublisherProperties || hasCollections;

// Property-level scoping: the agent reaches a property whose
// publisher_domain matches the source. by_id walks property_ids;
// by_tag walks property_tags.
const hasPropertyIdLink = Array.isArray(agent.property_ids)
&& agent.property_ids.some((id) => typeof id === 'string' && matchingPropertyIds.has(id));
const hasPropertyTagLink = Array.isArray(agent.property_tags)
&& agent.property_tags.some((tag) => typeof tag === 'string' && matchingPropertyTags.has(tag));

return hasPublisherProperties || hasCollections || hasPropertyIdLink || hasPropertyTagLink;
});
}

Expand Down
152 changes: 152 additions & 0 deletions server/tests/unit/adagents-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,158 @@ describe('AdAgentsManager', () => {
expect(result.errors.some(e => e.field === 'managerdomain_scope')).toBe(true);
});

it('accepts managerdomain fallback when manager scopes via property_tags + property-level publisher_domain (Mediavine pattern)', async () => {
// Real-world shape: properties[] carries publisher_domain, agents
// reference properties indirectly via property_tags. The cross-
// publisher commitment is declared, just routed through the
// property layer.
mockedSafeFetch.mockImplementation(async (url) => {
if (url === 'https://publisher.example/.well-known/adagents.json') {
return { status: 404, data: 'Not Found', headers: { 'content-type': 'text/plain' } };
}
if (url === 'https://publisher.example/ads.txt') {
return { status: 200, data: Buffer.from('MANAGERDOMAIN=manager.example\n'), headers: { 'content-type': 'text/plain' } };
}
if (url === 'https://manager.example/.well-known/adagents.json') {
return {
status: 200,
data: buf({
properties: [{
property_id: 'pub_main_site',
property_type: 'website',
publisher_domain: 'publisher.example',
tags: ['scope3-aee', 'managed_network'],
}],
authorized_agents: [{
url: 'https://agent.example',
authorized_for: 'Display via tag',
authorization_type: 'property_tags',
property_tags: ['scope3-aee'],
}],
}),
headers: { 'content-type': 'application/json' },
};
}
throw new Error(`Unexpected URL: ${url}`);
});

const result = await manager.validateDomain('publisher.example');
expect(result.valid).toBe(true);
expect(result.discovery_method).toBe('ads_txt_managerdomain');
expect(result.manager_domain).toBe('manager.example');
});

it('accepts managerdomain fallback when manager scopes via property_ids + property-level publisher_domain', async () => {
mockedSafeFetch.mockImplementation(async (url) => {
if (url === 'https://publisher.example/.well-known/adagents.json') {
return { status: 404, data: 'Not Found', headers: { 'content-type': 'text/plain' } };
}
if (url === 'https://publisher.example/ads.txt') {
return { status: 200, data: Buffer.from('MANAGERDOMAIN=manager.example\n'), headers: { 'content-type': 'text/plain' } };
}
if (url === 'https://manager.example/.well-known/adagents.json') {
return {
status: 200,
data: buf({
properties: [{
property_id: 'pub_main_site',
property_type: 'website',
publisher_domain: 'publisher.example',
}],
authorized_agents: [{
url: 'https://agent.example',
authorized_for: 'Display via id',
authorization_type: 'property_ids',
property_ids: ['pub_main_site'],
}],
}),
headers: { 'content-type': 'application/json' },
};
}
throw new Error(`Unexpected URL: ${url}`);
});

const result = await manager.validateDomain('publisher.example');
expect(result.valid).toBe(true);
expect(result.discovery_method).toBe('ads_txt_managerdomain');
});

it('rejects fallback when property-level publisher_domain belongs to a different publisher', async () => {
// The property carries publisher_domain, the agent points at it
// by tag — but the property belongs to another publisher.
// Cross-publisher confusion attack must still fail closed.
mockedSafeFetch.mockImplementation(async (url) => {
if (url === 'https://publisher.example/.well-known/adagents.json') {
return { status: 404, data: 'Not Found', headers: { 'content-type': 'text/plain' } };
}
if (url === 'https://publisher.example/ads.txt') {
return { status: 200, data: Buffer.from('MANAGERDOMAIN=manager.example\n'), headers: { 'content-type': 'text/plain' } };
}
if (url === 'https://manager.example/.well-known/adagents.json') {
return {
status: 200,
data: buf({
properties: [{
property_id: 'someone_elses_site',
publisher_domain: 'other-publisher.example',
tags: ['scope3-aee'],
}],
authorized_agents: [{
url: 'https://agent.example',
authorized_for: 'Display via tag',
authorization_type: 'property_tags',
property_tags: ['scope3-aee'],
}],
}),
headers: { 'content-type': 'application/json' },
};
}
throw new Error(`Unexpected URL: ${url}`);
});

const result = await manager.validateDomain('publisher.example');
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.field === 'managerdomain_scope')).toBe(true);
});

it('rejects fallback when agent references a tag with no publisher-scoped property carrying it', async () => {
// The publisher's property exists, but the agent points at a tag
// that none of the publisher's properties carry. Must fail closed
// — the agent has no scoping path back to the publisher.
mockedSafeFetch.mockImplementation(async (url) => {
if (url === 'https://publisher.example/.well-known/adagents.json') {
return { status: 404, data: 'Not Found', headers: { 'content-type': 'text/plain' } };
}
if (url === 'https://publisher.example/ads.txt') {
return { status: 200, data: Buffer.from('MANAGERDOMAIN=manager.example\n'), headers: { 'content-type': 'text/plain' } };
}
if (url === 'https://manager.example/.well-known/adagents.json') {
return {
status: 200,
data: buf({
properties: [{
property_id: 'pub_main_site',
publisher_domain: 'publisher.example',
tags: ['display'],
}],
authorized_agents: [{
url: 'https://agent.example',
authorized_for: 'Video via different tag',
authorization_type: 'property_tags',
property_tags: ['video'],
}],
}),
headers: { 'content-type': 'application/json' },
};
}
throw new Error(`Unexpected URL: ${url}`);
});

const result = await manager.validateDomain('publisher.example');
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.field === 'managerdomain_scope')).toBe(true);
});

it('does not trigger manager fallback on non-404 adagents responses', async () => {
let calledAdsTxt = false;
mockedSafeFetch.mockImplementation(async (url) => {
Expand Down
Loading