🔒 fix: Remove Owner Email from Agent owner_contact Fallback (#14541)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

* 🔒 fix: Remove Owner Email from Agent `owner_contact` Fallback

The owner-contact fallback for agents without an explicit support_contact
exposed the owner's private account email to any VIEW-level caller via
GET /agents/:id and GET /agents. The fallback now resolves a display name
only (name/username/authorName): the User query no longer projects email,
the resolver never returns one, and the shared AgentOwnerContact type drops
the field. Emails are only served when the owner opts in via support_contact.

* 🔒 fix: Reject Email-Shaped Owner Display Names in Contact Fallback

OpenID and SAML strategies fall back to the account email for the user's
name and username when no display-name claims exist, so the name-only
owner fallback could still surface the email through those fields. The
resolver now rejects email-shaped display-name candidates entirely.

* 🔒 fix: Treat Any @-Containing Display Name as Email-Derived

RFC-5321 quoted local parts may contain whitespace and the User schema
email validator is an unanchored substring match, so such addresses can
reach the name/username fields via SSO fallbacks. Rejecting on '@'
presence covers every legal email form without re-fetching the account
email.
This commit is contained in:
Danny Avila 2026-07-30 23:46:22 -04:00 committed by GitHub
parent ad74a282d1
commit 8e165eb451
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 112 additions and 68 deletions

View file

@ -603,10 +603,30 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(mockRes.status).toHaveBeenCalledWith(200);
const response = mockRes.json.mock.calls[0][0];
expect(response.owner_contact).toEqual({
name: 'Primary Owner',
email: 'primary.owner@example.com',
expect(response.owner_contact).toEqual({ name: 'Primary Owner' });
expect(response.owner_contact).not.toHaveProperty('email');
});
test('should omit owner_contact when the owner name and username are the account email', async () => {
const email = 'sso.owner@example.com';
const owner = await createOwner({ name: email, username: email, email });
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
name: 'SSO Owner Agent',
description: 'Owner has email-shaped name from SSO fallback',
provider: 'openai',
model: 'gpt-4',
author: owner._id,
});
await grantAgentOwner({ agent, owner });
mockReq.params = { id: agent.id };
await getAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const response = mockRes.json.mock.calls[0][0];
expect(response.owner_contact).toBeUndefined();
});
test('should not return owner_contact when support_contact is present', async () => {
@ -1556,10 +1576,8 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
await getListAgentsHandler(mockReq, mockRes);
const response = mockRes.json.mock.calls[0][0];
expect(response.data[0].owner_contact).toEqual({
name: 'List Owner',
email: 'list.owner@example.com',
});
expect(response.data[0].owner_contact).toEqual({ name: 'List Owner' });
expect(response.data[0].owner_contact).not.toHaveProperty('email');
});
test('should use the first ACL owner when an agent has multiple owners', async () => {
@ -1589,10 +1607,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
await getListAgentsHandler(mockReq, mockRes);
const response = mockRes.json.mock.calls[0][0];
expect(response.data[0].owner_contact).toEqual({
name: 'First Owner',
email: 'first.owner@example.com',
});
expect(response.data[0].owner_contact).toEqual({ name: 'First Owner' });
});
test('should omit owner_contact when no owner user can be resolved', async () => {

View file

@ -59,7 +59,7 @@ const attachOwnerContacts = async (agents) => {
let ownersById = new Map();
if (ownerIds.length > 0) {
try {
const users = await db.findUsers({ _id: { $in: ownerIds } }, 'name username email');
const users = await db.findUsers({ _id: { $in: ownerIds } }, 'name username');
ownersById = new Map(users.map((user) => [user?._id?.toString(), user]));
} catch (error) {
logger.warn('[/Agents] Failed to resolve agent owner users', error);

View file

@ -12,12 +12,11 @@ export default function AgentContact({ agent, className = '' }: AgentContactProp
const supportName = agent?.support_contact?.name?.trim() ?? '';
const supportEmail = agent?.support_contact?.email?.trim() ?? '';
const ownerName = agent?.owner_contact?.name?.trim() ?? '';
const ownerEmail = agent?.owner_contact?.email?.trim() ?? '';
let contact: { name: string; email: string } | null = null;
if (supportName || supportEmail) {
contact = { name: supportName, email: supportEmail };
} else if (ownerName || ownerEmail) {
contact = { name: ownerName, email: ownerEmail };
} else if (ownerName) {
contact = { name: ownerName, email: '' };
}
const label = contact?.name || contact?.email || localize('com_agents_no_contact_available');

View file

@ -324,7 +324,6 @@ describe('AgentCard', () => {
authorName: 'John Doe',
owner_contact: {
name: 'Owner User',
email: 'owner@example.com',
},
};
@ -335,10 +334,8 @@ describe('AgentCard', () => {
);
expect(screen.getByText('Contact:')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
'href',
'mailto:owner@example.com',
);
expect(screen.getByText('Owner User')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Owner User' })).not.toBeInTheDocument();
expect(screen.queryByText('by John Doe')).not.toBeInTheDocument();
});

View file

@ -24,7 +24,7 @@ describe('AgentContact', () => {
agent={
{
support_contact: { name: 'Support Team', email: 'support@example.com' },
owner_contact: { name: 'Owner User', email: 'owner@example.com' },
owner_contact: { name: 'Owner User' },
} as any
}
/>,
@ -38,7 +38,7 @@ describe('AgentContact', () => {
expect(screen.queryByText('Owner User')).not.toBeInTheDocument();
});
it('falls back to owner contact', () => {
it('falls back to owner contact as a plain name without a mailto link', () => {
render(
<AgentContact
agent={
@ -50,15 +50,6 @@ describe('AgentContact', () => {
/>,
);
expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
'href',
'mailto:owner@example.com',
);
});
it('renders a plain name when no email is available', () => {
render(<AgentContact agent={{ owner_contact: { name: 'Owner User' } } as any} />);
expect(screen.getByText('Owner User')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});

View file

@ -101,7 +101,7 @@ describe('AgentDetailContent', () => {
{
...baseAgent,
support_contact: { name: 'Support Team', email: 'support@example.com' },
owner_contact: { name: 'Owner User', email: 'owner@example.com' },
owner_contact: { name: 'Owner User' },
} as any
}
/>,
@ -121,15 +121,13 @@ describe('AgentDetailContent', () => {
agent={
{
...baseAgent,
owner_contact: { name: 'Owner User', email: 'owner@example.com' },
owner_contact: { name: 'Owner User' },
} as any
}
/>,
);
expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
'href',
'mailto:owner@example.com',
);
expect(screen.getByText('Owner User')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Owner User' })).not.toBeInTheDocument();
});
});

View file

@ -106,7 +106,7 @@ describe('Landing agent contact', () => {
id: 'agent-1',
name: 'Portal Remote Agent',
description: 'Remote Agent Showcase',
owner_contact: { name: 'Owner User', email: 'owner@example.com' },
owner_contact: { name: 'Owner User' },
},
};
@ -115,10 +115,8 @@ describe('Landing agent contact', () => {
expect(screen.getByText('Portal Remote Agent')).toBeInTheDocument();
expect(screen.getByText('Remote Agent Showcase')).toBeInTheDocument();
expect(screen.getByText('Contact:')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
'href',
'mailto:owner@example.com',
);
expect(screen.getByText('Owner User')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Owner User' })).not.toBeInTheDocument();
});
it('does not show contact when the selected agent is missing from agentsMap', () => {

View file

@ -1,10 +1,11 @@
import type { AgentOwnerContactSource } from './contact';
import { resolveAgentOwnerContact } from './contact';
describe('resolveAgentOwnerContact', () => {
it('omits owner fallback when support contact has a name', () => {
const result = resolveAgentOwnerContact(
{ support_contact: { name: 'Support Team' } },
{ name: 'Agent Owner', email: 'owner@example.com' },
{ name: 'Agent Owner' },
);
expect(result).toBeUndefined();
@ -13,19 +14,56 @@ describe('resolveAgentOwnerContact', () => {
it('omits owner fallback when support contact has an email', () => {
const result = resolveAgentOwnerContact(
{ support_contact: { email: 'support@example.com' } },
{ name: 'Agent Owner', email: 'owner@example.com' },
{ name: 'Agent Owner' },
);
expect(result).toBeUndefined();
});
it('uses owner name and email when support contact is empty', () => {
it('uses owner name when support contact is empty', () => {
const result = resolveAgentOwnerContact(
{ support_contact: { name: ' ', email: '' } },
{ name: ' Agent Owner ', email: ' owner@example.com ' },
{ name: ' Agent Owner ' },
);
expect(result).toEqual({ name: 'Agent Owner', email: 'owner@example.com' });
expect(result).toEqual({ name: 'Agent Owner' });
});
it('never exposes an owner account email', () => {
const result = resolveAgentOwnerContact({}, {
name: 'Agent Owner',
email: 'owner.private@example.com',
} as AgentOwnerContactSource);
expect(result).toEqual({ name: 'Agent Owner' });
expect(result).not.toHaveProperty('email');
});
it('skips email-shaped owner names from auth-strategy fallbacks', () => {
const result = resolveAgentOwnerContact(
{},
{ name: 'owner.private@example.com', username: 'owner.user' },
);
expect(result).toEqual({ name: 'owner.user' });
});
it('omits owner contact when every display-name candidate is email-shaped', () => {
const result = resolveAgentOwnerContact(
{ authorName: 'owner.private@example.com' },
{ name: 'owner.private@example.com', username: 'owner.private@example.com' },
);
expect(result).toBeUndefined();
});
it('skips emails with quoted local parts containing whitespace', () => {
const result = resolveAgentOwnerContact(
{},
{ name: '"given family"@example.com', username: 'owner.user' },
);
expect(result).toEqual({ name: 'owner.user' });
});
it('falls back to username for owner display name', () => {
@ -35,12 +73,9 @@ describe('resolveAgentOwnerContact', () => {
});
it('falls back to authorName when owner has no display name', () => {
const result = resolveAgentOwnerContact(
{ authorName: 'Legacy Author' },
{ email: 'owner@example.com' },
);
const result = resolveAgentOwnerContact({ authorName: 'Legacy Author' }, { name: '' });
expect(result).toEqual({ name: 'Legacy Author', email: 'owner@example.com' });
expect(result).toEqual({ name: 'Legacy Author' });
});
it('omits owner contact when no owner can be resolved', () => {
@ -49,11 +84,12 @@ describe('resolveAgentOwnerContact', () => {
expect(result).toBeUndefined();
});
it('omits owner contact when all candidate fields are empty', () => {
const result = resolveAgentOwnerContact(
{ authorName: ' ' },
{ name: '', username: ' ', email: ' ' },
);
it('omits owner contact when no display name is available', () => {
const result = resolveAgentOwnerContact({ authorName: ' ' }, {
name: '',
username: ' ',
email: 'owner.private@example.com',
} as AgentOwnerContactSource);
expect(result).toBeUndefined();
});

View file

@ -11,7 +11,6 @@ export interface AgentContactSource {
export interface AgentOwnerContactSource {
name?: string | null;
username?: string | null;
email?: string | null;
}
const normalizeContactValue = (value?: string | null): string | undefined => {
@ -22,6 +21,17 @@ const normalizeContactValue = (value?: string | null): string | undefined => {
return trimmed.length > 0 ? trimmed : undefined;
};
/** Auth strategies fall back to the account email for name/username, and legal
* email forms include quoted local parts with whitespace, so any '@'-containing
* value is treated as email-derived and never used as a public display name. */
const normalizeDisplayName = (value?: string | null): string | undefined => {
const normalized = normalizeContactValue(value);
if (normalized == null || normalized.includes('@')) {
return undefined;
}
return normalized;
};
export const hasSupportContact = (agent: AgentContactSource): boolean => {
const support = agent.support_contact;
if (!support) {
@ -30,6 +40,11 @@ export const hasSupportContact = (agent: AgentContactSource): boolean => {
return !!normalizeContactValue(support.name) || !!normalizeContactValue(support.email);
};
/**
* Resolves a display-only owner contact for agents without an explicit support contact.
* Never includes the owner's account email, nor email-shaped display names; emails are
* only exposed when the owner opts in by configuring `support_contact`.
*/
export function resolveAgentOwnerContact(
agent: AgentContactSource,
owner: AgentOwnerContactSource | null,
@ -39,17 +54,13 @@ export function resolveAgentOwnerContact(
}
const name =
normalizeContactValue(owner.name) ??
normalizeContactValue(owner.username) ??
normalizeContactValue(agent.authorName);
const email = normalizeContactValue(owner.email);
normalizeDisplayName(owner.name) ??
normalizeDisplayName(owner.username) ??
normalizeDisplayName(agent.authorName);
if (!name && !email) {
if (!name) {
return undefined;
}
return {
...(name ? { name } : {}),
...(email ? { email } : {}),
};
return { name };
}

View file

@ -211,7 +211,6 @@ export type SupportContact = {
export type AgentOwnerContact = {
name?: string;
email?: string;
};
/**