Skip to content

fix: centralize error message sanitization in createApiError#2718

Open
amikofalvy wants to merge 4 commits intomainfrom
worktree-error-sanitization
Open

fix: centralize error message sanitization in createApiError#2718
amikofalvy wants to merge 4 commits intomainfrom
worktree-error-sanitization

Conversation

@amikofalvy
Copy link
Collaborator

@amikofalvy amikofalvy commented Mar 17, 2026

Summary

Implements PRD-6258 — centralizes error message sanitization inside createApiError for 500-level errors.

Problem: createApiError() pre-bakes the HTTP response body inside the HTTPException, bypassing the global error handler's regex sanitizer. Any call site passing raw error.message for a 500-level error is a potential information leak (IPs, connection strings, file paths, credentials).

Solution: A file-private sanitizeErrorMessage() helper that redacts sensitive patterns, applied automatically inside createApiError for status >= 500. The existing inline regex in handleApiError is replaced with the shared helper (no duplicate logic).

Changes

  • packages/agents-core/src/utils/error.ts

    • Add sanitizeErrorMessage() — redacts IPv4 addresses, PostgreSQL connection strings, server file paths, and sensitive keywords (password, token, key, secret, auth, credential)
    • Apply sanitization in createApiError for status >= 500 (4xx messages preserved unchanged)
    • Replace inline regex in handleApiError with the shared helper
  • packages/agents-core/src/utils/__tests__/error.test.ts

    • 12 new tests: sanitizer pattern coverage (IPv4, connection strings, paths, keywords, safe passthrough, multi-pattern) + createApiError integration tests (500 sanitizes, 400/404 preserve)
  • 3 existing test files updated to match new sanitized 500-level output:

    • credentialStores.test.ts — "Credential" replaced with "[REDACTED]"
    • invitations.test.ts — "Auth" replaced with "[REDACTED]"
    • passwordResetLinks.test.ts — "Auth" replaced with "[REDACTED]"

What the sanitizer catches

Pattern Example Replacement
IPv4 with optional port 10.0.0.5:5432 [REDACTED_HOST]
PostgreSQL connection strings postgresql://user:pass@host/db [REDACTED_CONNECTION]
Server file paths /var/task/packages/... [REDACTED_PATH]
Sensitive keywords password, token, key, secret, auth, credential [REDACTED]

Design decisions

  • 5xx only — 4xx messages are developer-crafted and intentionally descriptive for API consumers
  • File-private — helper is not exported; only needed within error.ts
  • Regex approach — preserves diagnostic value while redacting sensitive patterns (80/20 defense-in-depth)
  • credential added to keyword list beyond the original 5, matching codebase patterns

Test plan

  • 28 tests pass in error.test.ts (12 new + 16 existing)
  • 3 existing test files updated for new sanitized output
  • pnpm check passes (typecheck, lint, test, format)

amikofalvy and others added 2 commits March 17, 2026 02:07
Add file-private sanitizeErrorMessage function that redacts IPv4 addresses,
PostgreSQL connection strings, server file paths, and sensitive keywords.
Wire it into createApiError for status >= 500. Add 9 unit tests covering
all sanitization patterns and safe-message passthrough.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace inline regex in handleApiError with shared sanitizeErrorMessage helper.
Add integration tests for 500 vs 4xx sanitization behavior.
Update API tests to expect sanitized 500-level error messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot
Copy link

changeset-bot bot commented Mar 17, 2026

🦋 Changeset detected

Latest commit: 5471208

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@inkeep/agents-core Patch
@inkeep/agents-api Patch
@inkeep/agents-manage-ui Patch
@inkeep/agents-cli Patch
@inkeep/agents-sdk Patch
@inkeep/agents-work-apps Patch
@inkeep/ai-sdk-provider Patch
@inkeep/create-agents Patch
@inkeep/agents-email Patch
@inkeep/agents-mcp Patch

Not sure what this means? Click here to learn what changesets are.

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

@vercel
Copy link

vercel bot commented Mar 17, 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 17, 2026 9:38am
agents-docs Ready Ready Preview, Comment Mar 17, 2026 9:38am
agents-manage-ui Ready Ready Preview, Comment Mar 17, 2026 9:38am

Request Review

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
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.

The sanitization refactoring is structurally clean and the 5xx-only gating is a sound design choice. However, there are a few concrete issues worth addressing before merge — primarily around regex ordering (connection strings with embedded IPs leak credentials) and keyword over-matching destroying diagnostic value in common 500 messages.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

- Fix regex ordering: connection string regex now runs before IP regex
  to prevent credential leakage in URIs with embedded IPs
- Broaden connection string schemes (postgres, mysql, mongodb, redis, amqp)
- Add test for ordering edge case and other DB schemes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pullfrog
Copy link
Contributor

pullfrog bot commented Mar 17, 2026

Verified that the regex ordering fix from commit 5471208 is already applied — connection string regex now runs before IP regex in sanitizeErrorMessage(). Resolved the thread.

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

@github-actions github-actions bot deleted a comment from claude bot Mar 17, 2026
@pullfrog
Copy link
Contributor

pullfrog bot commented Mar 17, 2026

Reviewed all 5 comment threads from the initial review. The PR author has already handled everything:

  • 2 accepted & fixed (regex ordering, broadened connection string schemes) — committed in 5471208
  • 1 accepted & added (ordering edge-case test) — committed in 5471208
  • 2 declined (keyword over-matching, IPv6) — with clear rationale referencing the spec's design decisions

All threads are resolved. No further action needed from me.

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

@pullfrog
Copy link
Contributor

pullfrog bot commented Mar 17, 2026

No action needed — the single review thread on packages/agents-core/src/utils/error.ts:86 is already resolved. The PR author confirmed the fix in 5471208 (broadened connection string regex to cover postgres://, mysql://, mongodb://, mongodb+srv://, redis://, rediss://, and amqp://).

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

@pullfrog
Copy link
Contributor

pullfrog bot commented Mar 17, 2026

No action needed. The review thread on error.test.ts is already resolved — the reviewer confirmed the fix in commit 5471208, which adds the missing test for the IP-inside-connection-string ordering edge case. The branch is up to date.

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

@pullfrog
Copy link
Contributor

pullfrog bot commented Mar 17, 2026

Error

agent completed without reporting progress

Pullfrog  | Rerun failed job ➔View workflow run | Triggered by Pullfrogpullfrog.com𝕏

Copy link
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

(4) Total Issues | Risk: Medium

🟠⚠️ Major (2) 🟠⚠️

Inline Comments:

  • 🟠 Major: error.ts:86 Missing IPv6 address sanitization — internal IPv6 addresses will leak in error responses
  • 🟠 Major: error.test.ts:116 Missing tests for handleApiError sanitization path — secondary sanitization entry point has no coverage

🟡 Minor (2) 🟡

🟡 1) error.ts:82-85 Missing HTTP(S) URLs with embedded credentials

Issue: The sanitizer catches database connection strings but does not catch HTTP/HTTPS URLs with embedded credentials like https://user:password@api.internal.com/endpoint.

Why: HTTP URLs with embedded credentials can leak service account passwords or API tokens when HTTP client errors occur (e.g., connection refused, SSL errors).

Fix: Add HTTP(S) with credentials pattern before the existing connection string pattern:

.replace(/https?:\/\/[^:@\s]+:[^@\s]+@[^\s]+/gi, '[REDACTED_URL]')

Refs:


🟡 2) error.ts:87 Missing /app and /srv path prefixes

Issue: The path sanitizer only catches /var, /tmp, /home, /usr, /etc, /opt prefixes but misses common container/deployment paths like /app/node_modules/... (common in Docker) and /srv/....

Why: Stack traces in error messages can leak deployment structure and dependency versions via node_modules paths.

Fix: Expand the pattern:

.replace(/\/(?:var|tmp|home|usr|etc|opt|app|srv)\\/\\S+/g, '[REDACTED_PATH]')

Refs:

💭 Consider (1) 💭

💭 1) error.ts:88 Keyword regex aggressiveness tradeoff

Issue: The keyword pattern matches common non-sensitive terms: key appears in "primary key violation", auth appears in "Auth not configured" (confirmed in test updates where "Auth not configured" → "[REDACTED] not configured").

Why: While this successfully prevents credential leaks, it reduces diagnostic value. An operator seeing "[REDACTED] not configured" has less context than "Auth not configured". Server-side logs retain the original message for debugging.

Fix: This is a reasonable security-first tradeoff. If diagnostic friction becomes measurable, consider:

  1. Match only when followed by sensitive context: \b(password|secret)\s*[:=]\s*\S+
  2. Remove auth, key from the bare-word list — these words alone don't leak information; the values do

🧹 While You're Here (1) 🧹

🧹 1) agents-api/src/openapi.ts:116-122 OpenAPI endpoint bypasses error sanitization

Issue: The /openapi.json endpoint has a manual error handler that returns unsanitized error details including full stack traces directly to the client, bypassing the new sanitization:

const errorDetails = error instanceof Error
  ? { message: error.message, stack: error.stack }
  : JSON.stringify(error, null, 2);
return c.json({ error: 'Failed to generate OpenAPI document', details: errorDetails }, 500);

Why: Stack traces leak internal file paths, dependency versions, and execution flow.

Fix: Replace with createApiError or throw and let the global error handler process it. This is out of scope for this PR but represents a gap the new sanitization was intended to close.

🕐 Pending Recommendations (2)

  • 🟠 error.ts:88 Keyword regex too aggressive on common diagnostic words (pullfrog)
  • 🟡 error.ts:81 Missing IPv6 address redaction (pullfrog)

🚫 REQUEST CHANGES

Summary: The error sanitization approach is sound and the implementation is well-structured. However, there are two major gaps: (1) IPv6 addresses are not sanitized, which means internal network topology can still leak in error responses, and (2) the handleApiError code path has no test coverage despite being a critical sanitization entry point. The minor issues (HTTP URLs with credentials, additional path prefixes) are good defense-in-depth improvements. The keyword aggressiveness is a reasonable security/debuggability tradeoff. Please address the Major items before merge.

Discarded (3)
Location Issue Reason Discarded
error.ts:156-169 JSON parsing error caught and discarded without logging Pre-existing issue not introduced by this PR
error.ts:88 Keyword over-redaction (precision review) Duplicate of pullfrog feedback and Consider item
error.test.ts:101 Test clarity improvement for 400 preservation Informational only, existing tests are correct
Reviewers (5)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-security-iam 5 2 1 1 1 0 0
pr-review-tests 4 1 0 0 1 0 1
pr-review-errors 2 0 0 0 0 0 2
pr-review-precision 1 0 0 0 0 0 1
pr-review-standards 0 0 0 0 0 0 0
Total 12 3 1 1 2 0 4

Note: 2 items from pullfrog's prior review remain pending (keyword aggressiveness, IPv6).

/(?:postgresql|postgres|mysql|mongodb(?:\+srv)?|redis|rediss|amqp):\/\/[^\s,)]+/gi,
'[REDACTED_CONNECTION]'
)
.replace(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?\b/g, '[REDACTED_HOST]')
Copy link
Contributor

Choose a reason for hiding this comment

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

🟠 MAJOR: Missing IPv6 address sanitization

Issue: The sanitizer only catches IPv4 addresses but completely misses IPv6 addresses. Error messages like connect ECONNREFUSED [::1]:5432 or connect to [2001:db8::1]:5432 will leak internal infrastructure addresses.

Why: Internal IPv6 addresses in error responses reveal network topology to attackers, enabling reconnaissance for lateral movement or targeted attacks against internal services.

Fix: Add IPv6 pattern before the IPv4 pattern. Note that IPv6 detection is non-trivial — consider a simplified pattern for bracketed IPv6 literals which are most common in connection errors:

.replace(/\[[:0-9a-fA-F]+\](:\d+)?/g, '[REDACTED_HOST]') // bracketed IPv6
.replace(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?\b/g, '[REDACTED_HOST]') // IPv4

Refs:

const body = JSON.parse(await exception.getResponse().text());
expect(body.detail).toBe(message);
});
});
Copy link
Contributor

Choose a reason for hiding this comment

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

🟠 MAJOR: Missing tests for handleApiError sanitization path

Issue: The handleApiError function (lines 211-212 in error.ts) applies sanitizeErrorMessage when processing non-HTTPException errors, but this code path has zero direct test coverage. The tests only cover createApiError which is a separate entry point.

Why: If handleApiError is refactored to accidentally bypass sanitization for raw Error objects, sensitive information could leak in 500 responses. This is particularly concerning because handleApiError is called from the global error handler for all unhandled exceptions.

Fix: Add tests for the handleApiError sanitization path:

import { handleApiError } from '../error';

describe('handleApiError sanitization', () => {
  it('sanitizes raw Error messages containing sensitive data', async () => {
    const error = new Error('connect ECONNREFUSED 10.0.0.5:5432');
    const result = await handleApiError(error, 'req-123');
    expect(result.detail).not.toContain('10.0.0.5');
    expect(result.detail).toContain('[REDACTED_HOST]');
  });

  it('sanitizes connection strings in raw errors', async () => {
    const error = new Error('postgresql://user:pass@host/db failed');
    const result = await handleApiError(error, 'req-123');
    expect(result.detail).not.toContain('user');
    expect(result.detail).toContain('[REDACTED_CONNECTION]');
  });
});

Refs:

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

itoqa bot commented Mar 17, 2026

Ito Test Report ❌

16 test cases ran. 14 passed, 2 failed.

🔍 The run confirms core invitation, authorization, and sanitization paths are working, while two high-impact race-condition scenarios show repeated side effects under rapid repeated actions. Code review supports both failures as real production defects caused by missing idempotency/concurrency guards in write endpoints.

✅ Passed (14)
Test Case Summary Timestamp Screenshot
ROUTE-2 Admin-authenticated password reset link request returned 200 and a reset URL payload for the target email. 3:50 ROUTE-2_3-50.png
ROUTE-3 Editor-capable session created a credential successfully in memory-default and returned 201 with created key metadata. 16:34 ROUTE-3_16-34.png
EDGE-1 Endpoint returned 400 with exact 'Email parameter is required' message and clean unsanitized 4xx payload. 0:00 EDGE-1_0-00.png
EDGE-2 Expired invitation request returned business-safe 404 with 'Invitation has expired' and UI stayed in invalid invitation state. 0:00 EDGE-2_0-00.png
EDGE-3 Endpoint returned 400 with explicit 'Email is required' validation message and no redaction token. 3:50 EDGE-3_3-50.png
EDGE-4 Unavailable-store flow returned 500 and redacted sensitive keywords in both detail and error.message fields. 16:34 EDGE-4_16-34.png
EDGE-5 Storage-set failure produced 500 while replacing sensitive credential-related terms with [REDACTED] in returned error payload. 16:34 EDGE-5_16-34.png
EDGE-6 Non-existent store request returned 404 with exact store identifier in message and preserved actionable 4xx wording. 16:34 EDGE-6_16-34.png
ADV-1 Script payload did not execute (no dialogs fired) and payload was not rendered as executable markup in UI. 0:00 ADV-1_0-00.png
ADV-2 Authenticated non-admin member session in default tenant was established, and password-reset link creation was correctly denied with 403 Admin access required. 22:53 ADV-2_22-53.png
ADV-3 Injection-like email payload was rejected with controlled 403 response and no stack trace, SQL detail, host, or path leakage in the body. 3:50 ADV-3_3-50.png
ADV-4 Viewer-context credential creation attempt was denied at API boundary (project hidden/blocked) and no credential was created. 16:34 ADV-4_16-34.png
JOURNEY-2 After explicit sign-out, follow-up reset-link attempt was rejected with 401 Unauthorized, confirming privileged action is not allowed with stale session state. 3:50 JOURNEY-2_3-50.png
SEC-1 Two distinct 500 responses were captured and both preserved sanitization by replacing sensitive keyword tokens with [REDACTED] in detail and error.message. 19:32 SEC-1_19-32.png
❌ Failed (2)
Test Case Summary Timestamp Screenshot
RAPID-1 Rapid burst produced multiple successful reset URLs, indicating repeated side effects were not gated to a single effective action. 3:50 RAPID-1_3-50.png
RAPID-2 Rapid submission produced multiple successful creates instead of gating to a single effective create, indicating duplicate side effects are possible. 16:34 RAPID-2_16-34.png
Rapid reset-password clicks do not create inconsistent state – Failed
  • Where: Tenant settings member-management password reset action (/tenants/:tenantId/password-reset-links)

  • Steps to reproduce: Open a tenant settings members page as admin and trigger reset-link creation 5+ times rapidly for the same member.

  • What failed: Multiple requests each returned 200 with different reset URLs; expected behavior is a single effective action while in-flight requests are gated.

  • Code analysis: I reviewed the reset-link route and found no dedupe/idempotency guard, no in-flight lock, and no per-target cooldown. Each request independently waits for and returns a new link.

  • Relevant code:

    agents-api/src/domains/manage/routes/passwordResetLinks.ts (lines 60-74)

    const linkPromise = waitForPasswordResetLink(email);
    
    await auth.api.requestPasswordReset({
      body: {
        email,
        redirectTo,
      },
    });
    
    try {
      const link = await linkPromise;
      return c.json({ url: link.url });
    } catch {
      throw createApiError({ code: 'internal_server_error', message: 'Reset link not available' });
    }

    agents-api/src/domains/manage/routes/passwordResetLinks.ts (lines 16-36)

    passwordResetLinksRoutes.post('/', async (c) => {
      const tenantId = c.req.param('tenantId');
      const { email } = (await c.req.json().catch(() => ({}))) as { email?: string };
      const userId = c.get('userId');
    
      if (!tenantId) {
        throw createApiError({ code: 'bad_request', message: 'Tenant ID is required' });
      }
      if (!userId) {
        throw createApiError({ code: 'unauthorized', message: 'Authentication required' });
      }
      if (!email) {
        throw createApiError({ code: 'bad_request', message: 'Email is required' });
      }
  • Why this is likely a bug: The endpoint processes every concurrent submission as a fresh side effect and returns multiple distinct reset links for the same target, which violates expected single-action semantics for this privileged operation.

  • Introduced by this PR: No - pre-existing bug (code not changed in this PR)

  • Timestamp: 3:50

Credential submit mashing avoids duplicate/partial creation – Failed
  • Where: New bearer credential flow and credential store create endpoint (/credential-stores/{id}/credentials)

  • Steps to reproduce: Open new credential form and submit rapidly (Enter/click spam) with the same logical operation.

  • What failed: Multiple 201 responses were returned and multiple credentials were created from rapid submissions instead of one effective write.

  • Code analysis: The backend create route performs unconditional writes with no idempotency key or duplicate guard, and the page flow generates a fresh credential identifier for each submit path.

  • Relevant code:

    agents-api/src/domains/manage/routes/credentialStores.ts (lines 116-128)

    // Set the credential in the store
    await store.set(key, value, metadata ?? {});
    
    return c.json(
      {
        data: {
          key,
          storeId,
          createdAt: new Date().toISOString(),
        },
      },
      201
    );

    agents-manage-ui/src/app/[tenantId]/projects/[projectId]/credentials/new/bearer/page.tsx (lines 35-52)

    const handleCreateCredential = async (data: CredentialFormOutput) => {
      try {
        const newCredentialId = generateId();
    
        let newCredential: Credential | undefined;
        let credentialKeyToSet: string;
        let credentialValueToSet: string;
        let retrievalParams: Record<string, string>;
    
        switch (data.credentialStoreType) {
          case CredentialStoreType.nango: {
            credentialKeyToSet = JSON.stringify({
              connectionId: newCredentialId,
  • Why this is likely a bug: The write path lacks server-side idempotency/concurrency protection, so rapid repeated submits create multiple persisted credentials for what should be a single user action.

  • Introduced by this PR: No - pre-existing bug (code not changed in this PR)

  • Timestamp: 16:34

📋 View Recording

Screen Recording

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant