Skip to content

Fix work app MCP URL trust validation#2816

Open
robert-inkeep wants to merge 2 commits intomainfrom
codex/fix-work-app-mcp-domain-validation-issue
Open

Fix work app MCP URL trust validation#2816
robert-inkeep wants to merge 2 commits intomainfrom
codex/fix-work-app-mcp-domain-validation-issue

Conversation

@robert-inkeep
Copy link
Copy Markdown
Collaborator

Motivation

  • The previous isTrustedWorkAppMcpUrl used a suffix-based hostname check derived from the last two labels of the base hostname which allowed public-suffix bypasses (e.g., co.uk) and ignored scheme/port, risking leakage of privileged MCP API keys to attacker-controlled hosts.
  • The intent is to harden MCP URL validation so only exact same-origin (scheme + hostname + port) endpoints with the canonical MCP path are considered trusted, preventing credential exfiltration.

Description

  • Replace permissive suffix-based domain logic in isTrustedWorkAppMcpUrl with an exact origin equality check using toolUrl.origin === base.origin and require exact path match via toolUrl.pathname === path in packages/agents-core/src/utils/work-app-mcp.ts.
  • Remove the baseDomain derivation and endsWith checks that permitted public-suffix or subdomain matches.
  • Add focused unit tests in packages/agents-core/src/utils/__tests__/work-app-mcp.test.ts that assert a valid trusted origin passes and that attacker-controlled multi-part TLD domains, mismatched scheme, mismatched port, and mismatched path are rejected.

Testing

  • Ran formatting and static checks with biome check on the modified files, which passed (biome check src/utils/work-app-mcp.ts src/utils/__tests__/work-app-mcp.test.ts).
  • Ran unit tests with vitest for the new test file, and all tests passed (pnpm --filter agents-core exec vitest --run src/utils/__tests__/work-app-mcp.test.ts: 1 test file, 5 tests, all passed).

Codex Task

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agents-api Ready Ready Preview, Comment Mar 24, 2026 4:45pm
agents-docs Ready Ready Preview, Comment Mar 24, 2026 4:45pm
agents-manage-ui Ready Ready Preview, Comment Mar 24, 2026 4:45pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 24, 2026

⚠️ No Changeset found

Latest commit: 2464e3f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog bot commented Mar 24, 2026

TL;DR — Replaces a domain-suffix trust check in isTrustedWorkAppMcpUrl with a strict origin comparison, closing a validation gap where an attacker-controlled domain sharing a TLD suffix (e.g. evil.co.uk vs api.example.co.uk) could pass the trust gate. Adds a targeted test suite for the function.

Key changes

  • Replace domain-suffix matching with strict origin check in isTrustedWorkAppMcpUrl — the old logic extracted the last two hostname labels and used .endsWith(), which is unsound for multi-part TLDs; the new logic compares URL.origin (scheme + host + port) directly.
  • Add unit tests for isTrustedWorkAppMcpUrl — covers matching origin, mismatched scheme, mismatched port, wrong path, and the multi-part TLD attack vector.

Summary | 2 files | 2 commits | base: maincodex/fix-work-app-mcp-domain-validation-issue


Strict origin validation for work app MCP URLs

Before: isTrustedWorkAppMcpUrl split the base hostname on ., took the last two labels as the "domain", and accepted any URL whose hostname ended with that suffix — meaning evil.co.uk would be trusted when the base was api.example.co.uk.
After: The function compares toolUrl.origin === base.origin, which checks scheme, full hostname, and port in one step. Subdomain or TLD suffix matches no longer pass.

URL.origin is the browser-standard composite of scheme + '://' + host + ':' + port (port omitted when default), so this single equality check is both simpler and strictly correct.

Why was the old check vulnerable?

.split('.').slice(-2).join('.') produces co.uk for a hostname like api.example.co.uk. Any attacker-owned domain ending in .co.uk (e.g. evil.co.uk) would satisfy the .endsWith('.co.uk') test and be treated as trusted. Comparing full origins eliminates this class of bypass entirely.

work-app-mcp.ts · work-app-mcp.test.ts

Pullfrog  | View workflow run | Triggered by Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, correct security fix. The toolUrl.origin === base.origin check properly validates scheme + hostname + port in one comparison, eliminating the public-suffix bypass (co.uk, com.au, etc.) and the overly permissive subdomain matching. Tests cover the important vectors. No issues found.

Pullfrog  | View workflow runpullfrog.com𝕏

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(2) Total Issues | Risk: Low


🟠⚠️ Major (1) 🟠⚠️

Inline Comments:

  • 🟠 Major: work-app-mcp.test.ts:55 Missing test for undefined baseUrl parameter — guards against credential leakage during misconfiguration

🟡 Minor (1) 🟡

Inline Comments:

  • 🟡 Minor: work-app-mcp.test.ts:55 Missing test for malformed URL input — defensive error handling coverage

💡 APPROVE WITH SUGGESTIONS

Summary: This is a well-executed security fix that correctly addresses the public-suffix bypass vulnerability. The implementation using URL.origin equality is sound — it properly validates scheme, hostname, and port together per the WHATWG URL Standard. The security reviewers confirmed no bypass vectors exist in the new implementation.

The two suggestions above are test coverage improvements that would protect these security-critical code paths from accidental regression. They're straightforward additions and don't block merge.

Great work hardening the MCP URL validation! 🔒


Reviewers (3)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-security-iam 0 0 0 0 0 0 0
pr-review-standards 0 0 0 0 0 0 0
pr-review-tests 2 0 0 0 2 0 0
Total 2 0 0 0 2 0 0

Note: Security reviewer confirmed the origin equality check is robust — userinfo, query strings, and fragments do not affect origin comparison, and path traversal/encoding is normalized by the URL parser.

'https://api.example.com'
)
).toBe(false);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR: Missing test for undefined baseUrl parameter

Issue: The explicit early return if (!baseUrl) return false (line 11 of implementation) is untested.

Why: This branch is security-critical: if INKEEP_AGENTS_API_URL is undefined in production (misconfiguration), the function should reject ALL URLs to prevent credential leakage. Without a test, a future refactor could accidentally remove this guard.

Fix:

it('returns false when baseUrl is undefined', () => {
  expect(
    isTrustedWorkAppMcpUrl(
      'https://api.example.com/work-apps/slack/mcp',
      path,
      undefined
    )
  ).toBe(false);
});

Refs:

'https://api.example.com'
)
).toBe(false);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor: Missing test for malformed URL input

Issue: The catch block (lines 16-17 of implementation) that handles URL parsing errors is untested.

Why: If an attacker supplies a malformed URL that causes URL parsing to throw, the function should return false (safe default). The current implementation does this correctly, but without a test, this defensive behavior could be accidentally removed during refactoring.

Fix:

it('returns false for malformed URL input', () => {
  expect(
    isTrustedWorkAppMcpUrl(
      'not-a-valid-url',
      path,
      'https://api.example.com'
    )
  ).toBe(false);
});

Refs:

@github-actions github-actions bot deleted a comment from claude bot Mar 24, 2026
@itoqa
Copy link
Copy Markdown

itoqa bot commented Mar 24, 2026

Ito Test Report ❌

19 test cases ran. 2 failed, 17 passed.

Overall, 19 test cases ran with 17 passing and 2 failing, indicating core MCP/manage functionality is largely stable: project and tool access PUT/GET persistence worked for GitHub/Slack, deep-link routing and refresh/back-forward remained stable, strict origin+path trust checks behaved correctly (including query/hash handling), spoofed URL attacks were treated as untrusted, invalid GitHub API keys returned 401, and cross-tenant/cross-project abuse attempts were blocked with no unauthorized state changes.
The two confirmed medium, pre-existing defects were that rapid multi-click on MCP creation can create duplicate tools due to missing submit/idempotency guards, and Slack MCP auth failures are incorrectly surfaced as HTTP 500 instead of 401 because its error handler overrides unauthorized status mapping.

❌ Failed (2)
Category Summary Screenshot
Adversarial 🟠 Unauthenticated direct Slack MCP calls are converted to 500 instead of returning 401 Unauthorized. ADV-4
Edge 🟠 Rapid multi-click can submit MCP creation more than once, creating duplicate tool records. EDGE-6
🟠 Slack MCP unauthorized requests return 500 instead of 401
  • What failed: The auth middleware raises unauthorized errors for missing headers, but the app-level error handler always responds with HTTP 500 instead of preserving the expected 401 Unauthorized status.
  • Impact: Authentication failures are misreported as server errors, which can break client error handling and generate misleading operational alerts. This weakens security boundary observability because unauthorized access attempts appear as internal failures.
  • Introduced by this PR: No – pre-existing bug (code not changed in this PR)
  • Steps to reproduce:
    1. Send a POST request to /work-apps/slack/mcp without Authorization, x-inkeep-tool-id, x-inkeep-tenant-id, and x-inkeep-project-id headers.
    2. Observe that auth middleware throws unauthorized API errors for missing headers.
    3. Verify the final HTTP response status is 500 instead of the expected 401 Unauthorized.
  • Code analysis: I reviewed Slack MCP auth middleware and endpoint error handling, then compared it with shared API error status mapping. The middleware throws createApiError({ code: 'unauthorized' }), which maps to HTTP 401, but Slack MCP app.onError overrides all thrown errors and hardcodes a 500 response.
  • Why this is likely a bug: Production code explicitly maps unauthorized auth failures to 401, but Slack MCP wraps those errors in a blanket 500 handler, causing an incorrect status code on real requests.

Relevant code:

packages/agents-work-apps/src/slack/mcp/auth.ts (lines 56-68)

const authHeader = c.req.header('Authorization');
if (!authHeader) {
  throw createApiError({
    code: 'unauthorized',
    message: 'Missing required header: Authorization',
    extensions: {
      parameter: {
        in: 'header',
        name: 'Authorization',
      },
    },
  });
}

packages/agents-work-apps/src/slack/mcp/index.ts (lines 324-328)

app.onError((err, c) => {
  const message = err.message || 'Internal server error';
  logger.error({ error: err }, 'Slack MCP error');
  return c.json({ jsonrpc: '2.0', error: { code: -32603, message }, id: null }, 500);
});

packages/agents-core/src/utils/error.ts (lines 16-25)

const errorCodeToHttpStatus: Record<z.infer<typeof ErrorCode>, number> = {
  bad_request: 400,
  unauthorized: 401,
  forbidden: 403,
  not_found: 404,
  conflict: 409,
  too_many_requests: 429,
  unprocessable_entity: 422,
  internal_server_error: 500,
};
🟠 Rapid double-submit on MCP creation does not create duplicates
  • What failed: Multiple create submissions can be accepted, resulting in duplicate MCP tools; expected behavior is a single persisted tool per user submit action.
  • Impact: Users can accidentally create duplicate MCP tools, causing confusing list state and redundant configuration records. Repeated accidental duplication also increases cleanup overhead and operational mistakes.
  • Introduced by this PR: No – pre-existing bug (code not changed in this PR)
  • Steps to reproduce:
    1. Open New MCP server and choose a work-app MCP flow with valid inputs.
    2. Enter a valid tool name and URL, then click Create multiple times in rapid succession (~200ms).
    3. Check the tools list and observe that multiple duplicate MCP tools are created from one rapid interaction.
  • Code analysis: I inspected both work-app create dialogs and the backend create flow. The UI sets isSubmitting state but does not synchronously guard re-entry in handleSubmit, so concurrent clicks can invoke multiple creates before the disabled state takes effect; the backend route and data-access insert path accept each request independently with no idempotency/deduplication guard.
  • Why this is likely a bug: The production code permits multiple in-flight create requests from one rapid user action and persists each without deduplication, which directly explains duplicate records.

Relevant code:

agents-manage-ui/src/components/mcp-servers/selection/work-app-slack-channel-config-dialog.tsx (lines 203-213)

const handleSubmit = async () => {
  if (!isFormValid) return;

  setIsSubmitting(true);
  try {
    const slackMcpUrl = `${PUBLIC_INKEEP_AGENTS_API_URL}/work-apps/slack/mcp`;

    const toolId = generateId();
    const newTool = await createMCPTool(tenantId, projectId, {
      id: toolId,
      name: name.trim(),

agents-api/src/domains/manage/routes/tools.ts (lines 226-233)

const id = body.id || generateId();

const tool = await createTool(db)({
  ...body,
  tenantId,
  projectId,
  id,
});

packages/agents-core/src/data-access/manage/tools.ts (lines 567-579)

export const createTool = (db: AgentsManageDatabaseClient) => async (params: ToolInsert) => {
  const now = new Date().toISOString();

  const [created] = await db
    .insert(tools)
    .values({
      ...params,
      createdAt: now,
      updatedAt: now,
    })
    .returning();
✅ Passed (17)
Category Summary Screenshot
Adversarial Spoof URL with public-suffix domain was stored but discovery remained unhealthy with fetch-failed error and no privileged tool discovery. ADV-1
Adversarial Subdomain-spoof URL remained untrusted in practice: discovery returned unhealthy/fetch-failed and did not expose any discovered privileged tools. ADV-2
Adversarial Userinfo spoof URL was rejected at request construction level (credentials in URL), remained unhealthy, and produced no privileged discovery. ADV-3
Adversarial Invalid bearer token with required MCP headers returns 401 Unauthorized as expected. ADV-5
Adversarial Cross-tenant and cross-project tool access abuse attempts were blocked, with no unauthorized config mutation. ADV-6
Edge Scheme-mismatch URL remained unhealthy with fetch failure and did not show trusted downstream behavior. EDGE-1
Edge Port-mismatch variant did not behave as trusted and resolved unhealthy with endpoint error signature. EDGE-2
Edge Extra path segment variant was unhealthy with 404 endpoint error and showed no trusted behavior. EDGE-3
Edge Trailing-slash variant was unhealthy with 404 endpoint error and did not receive trusted behavior. EDGE-4
Edge Slack query/hash URL variant with exact origin+path behaved like canonical trusted flow without auth-gate signature. EDGE-5
Flow Deep-link refresh/back-forward preserved consistent Slack MCP detail state with no error loop. FLOW-1
Logic Canonical GitHub MCP tool read returned a non-auth downstream failure signature after proper local key setup. LOGIC-1
Logic Canonical Slack MCP tool read returned non-auth downstream key-configuration behavior, not trusted-header auth-gate failure. LOGIC-2
Happy-path After authenticating in local Manage UI and creating a missing project fixture (qa-project), project-level GitHub access PUT/GET calls were stable and returned mode=all with empty repositories. ROUTE-1
Happy-path Created GitHub work-app MCP tool and verified github-access PUT/GET persisted mode=all with empty repositories using the current Tool API payload shape. ROUTE-2
Happy-path Created Slack work-app MCP tool and verified slack-access PUT/GET persisted channelAccessMode=all, dmEnabled=true, channelIds=[]. ROUTE-3
Happy-path Deep-linked canonical GitHub and Slack MCP detail routes and verified access-control sections rendered without route errors. ROUTE-4

Commit: 1a4701f

View Full Run


Tell us how we did: Give Ito Feedback

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 31, 2026

Deployment failed with the following error:

Invalid request: `attribution.gitUser` should NOT have additional property `isBot`.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

(0) New Issues | Risk: Low


🕐 Pending Recommendations (2)

  • 🟠 work-app-mcp.test.ts:55 Missing test for undefined baseUrl parameter — guards against credential leakage during misconfiguration
  • 🟡 work-app-mcp.test.ts:55 Missing test for malformed URL input — defensive error handling coverage

💡 APPROVE WITH SUGGESTIONS

Summary: This delta review covers only the merge commit (2464e3ff) — the actual PR files (work-app-mcp.ts and work-app-mcp.test.ts) are unchanged since the prior review. No new issues introduced.

The two test coverage suggestions from the previous review remain pending. They're straightforward additions that would protect security-critical code paths from regression, but don't block merge.


Reviewers (0)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
Total 0 0 0 0 0 2 0

Note: No subagents dispatched — delta contained only a merge commit with no changes to PR files.

@github-actions github-actions bot deleted a comment from claude bot Mar 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants