From c98834e7b2970037603ba8af48378ce3629e2c19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:34:45 +0000 Subject: [PATCH 1/7] Initial plan From 98502fc29501fe47aed2274dfa66594341b53287 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:42:13 +0000 Subject: [PATCH 2/7] feat: support trusted proxy email auth for IAP deployments Co-authored-by: adhishthite <31769894+adhishthite@users.noreply.github.com> --- .env.example | 8 +++ README.md | 20 ++++++ docs/agent-docs.md | 41 +++++++++++ server/discovery-routes.ts | 16 ++++- server/hosted-auth.ts | 84 +++++++++++++++++++++++ server/routes.ts | 44 +++++++++--- src/tests/server-routes-and-share.test.ts | 57 +++++++++++++++ 7 files changed, 261 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 0d1ec67..8d79de5 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,11 @@ VITE_ENABLE_TELEMETRY=false # Optional app version tag for event properties VITE_APP_VERSION=dev + +# Optional trusted-proxy auth for Cloud Run / IAP deployments. +# When enabled, Proof can treat a verified upstream email header as hosted auth. +# Example: +# PROOF_TRUST_PROXY_HEADERS=true +# PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS=x-goog-authenticated-user-email +# PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS=elastic.co +# PROOF_SHARE_MARKDOWN_AUTH_MODE=oauth diff --git a/README.md b/README.md index 1f48f66..7651fba 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,26 @@ npm test - `docs/proof.SKILL.md` - `docs/adr/2026-03-proof-sdk-public-core.md` +## Cloud Run + IAP + +If you deploy Proof SDK behind Cloud Run + Identity-Aware Proxy, the proxy must admit the caller before Proof's own share-token auth runs. + +For agent traffic, you can now configure Proof to trust an upstream verified email header: + +```bash +PROOF_TRUST_PROXY_HEADERS=true +PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS=x-goog-authenticated-user-email +PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS=elastic.co +PROOF_SHARE_MARKDOWN_AUTH_MODE=oauth +``` + +- Cloud Run / IAP will authenticate the caller and inject `x-goog-authenticated-user-email`. +- Proof SDK will accept that trusted header for hosted-auth flows such as `POST /api/share/markdown`. +- New shares created through that path default `ownerId` to the trusted email unless you explicitly pass `ownerId`. +- Share/document routes still use the normal Proof share tokens (`Authorization: Bearer ` or `x-share-token`) once the request has passed IAP. + +See `docs/agent-docs.md` for the full agent-side flow. + ## License - Code: `MIT` in `LICENSE` diff --git a/docs/agent-docs.md b/docs/agent-docs.md index 6a956d8..e0001ab 100644 --- a/docs/agent-docs.md +++ b/docs/agent-docs.md @@ -79,6 +79,47 @@ If a URL contains `?token=`, treat it as an access token: - Preferred: `Authorization: Bearer ` - Also accepted: `x-share-token: ` +## Cloud Run Behind IAP + +If you place Proof SDK behind Cloud Run + Identity-Aware Proxy, agents must satisfy the IAP layer **before** they can use Proof share tokens. + +Recommended configuration: + +```bash +PROOF_TRUST_PROXY_HEADERS=true +PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS=x-goog-authenticated-user-email +PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS=elastic.co +PROOF_SHARE_MARKDOWN_AUTH_MODE=oauth +``` + +What this does: + +- IAP authenticates the caller and injects `x-goog-authenticated-user-email`. +- Proof SDK trusts that header only when `PROOF_TRUST_PROXY_HEADERS=true` and the email matches `PROOF_TRUSTED_IDENTITY_EMAILS` or `PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS`. +- `POST /api/share/markdown` and other hosted-auth checks can use that trusted identity without a separate Proof OAuth session. +- When a direct share is created this way, the document owner defaults to the trusted email unless you explicitly pass `ownerId`. + +Example request from an agent that already has IAP access: + +```bash +curl -X POST "https://your-proof.example.com/api/share/markdown" \ + -H "Content-Type: application/json" \ + -H "X-Goog-Authenticated-User-Email: accounts.google.com:agent@elastic.co" \ + -d '{"markdown":"# Hello from IAP"}' +``` + +After the request passes IAP, the returned Proof share token works the same as any other deployment: + +```bash +curl -H "Authorization: Bearer " \ + "https://your-proof.example.com/documents//state" +``` + +Notes: + +- This is intended for trusted reverse proxies such as IAP. Do **not** enable it on a public origin without a proxy that strips/spoofs those headers. +- External agents that cannot authenticate to IAP will still be blocked at the IAP layer. In that case, either give the agent an allowed IAP identity/service account or expose a separate non-IAP agent ingress. + ## Edit Via Ops (Comments, Suggestions, Rewrite) Use: diff --git a/server/discovery-routes.ts b/server/discovery-routes.ts index d69a5ee..f7cd656 100644 --- a/server/discovery-routes.ts +++ b/server/discovery-routes.ts @@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express'; import { readFileSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -import { resolveShareMarkdownAuthMode } from './hosted-auth.js'; +import { getTrustedProxyIdentityConfig, resolveShareMarkdownAuthMode } from './hosted-auth.js'; import { AGENT_DOCS_PATH, ALT_SHARE_TOKEN_HEADER_FORMAT, @@ -76,6 +76,7 @@ discoveryRoutes.get('/.well-known/agent.json', (req: Request, res: Response) => const shareBase = base || ''; const authMode = resolveShareMarkdownAuthMode(base); + const trustedProxyIdentity = getTrustedProxyIdentityConfig(); const authMethods = authMode === 'none' ? ['none'] : authMode === 'api_key' @@ -83,6 +84,9 @@ discoveryRoutes.get('/.well-known/agent.json', (req: Request, res: Response) => : authMode === 'oauth_or_api_key' ? ['api_key', 'oauth'] : ['oauth']; + if (trustedProxyIdentity.enabled) { + authMethods.push('trusted_proxy_email'); + } res.setHeader('Cache-Control', 'public, max-age=300'); res.json({ @@ -102,6 +106,16 @@ discoveryRoutes.get('/.well-known/agent.json', (req: Request, res: Response) => preferred_header: AUTH_HEADER_FORMAT, alt_header: ALT_SHARE_TOKEN_HEADER_FORMAT, }, + ...(trustedProxyIdentity.enabled + ? { + trusted_proxy: { + mode: 'email_header', + email_headers: trustedProxyIdentity.emailHeaders, + allowed_email_domains: trustedProxyIdentity.allowedDomains, + allowed_emails: trustedProxyIdentity.allowedEmails, + }, + } + : {}), }, quickstart: { received_link: { diff --git a/server/hosted-auth.ts b/server/hosted-auth.ts index af91beb..0b2f0fe 100644 --- a/server/hosted-auth.ts +++ b/server/hosted-auth.ts @@ -2,6 +2,90 @@ export type ShareMarkdownAuthMode = 'none' | 'api_key' | 'oauth' | 'oauth_or_api type PendingAuthStatus = 'pending' | 'completed' | 'failed'; +export type TrustedProxyIdentityPrincipal = { + provider: 'trusted_proxy_email'; + email: string; + actor: string; + ownerId: string; + header: string; +}; + +export type TrustedProxyIdentityConfig = { + enabled: boolean; + emailHeaders: string[]; + allowedEmails: string[]; + allowedDomains: string[]; +}; + +function parseCsvEnv(value: string | undefined): string[] { + return (value || '') + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); +} + +function trustProxyHeaders(): boolean { + const value = (process.env.PROOF_TRUST_PROXY_HEADERS || '').trim().toLowerCase(); + return value === '1' || value === 'true' || value === 'yes'; +} + +function normalizeTrustedIdentityEmail(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const withoutScheme = trimmed.replace(/^mailto:/i, ''); + const suffix = withoutScheme.includes(':') + ? withoutScheme.slice(withoutScheme.lastIndexOf(':') + 1) + : withoutScheme; + const normalized = suffix.trim().toLowerCase(); + if (!normalized || !normalized.includes('@') || normalized.startsWith('@') || normalized.endsWith('@')) { + return null; + } + return normalized; +} + +function isTrustedIdentityEmailAllowed(email: string, config: TrustedProxyIdentityConfig): boolean { + if (config.allowedEmails.includes(email)) return true; + const domain = email.split('@')[1] || ''; + return domain ? config.allowedDomains.includes(domain) : false; +} + +export function getTrustedProxyIdentityConfig(): TrustedProxyIdentityConfig { + const emailHeaders = parseCsvEnv( + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS + || 'x-goog-authenticated-user-email,x-forwarded-email', + ); + const allowedEmails = parseCsvEnv(process.env.PROOF_TRUSTED_IDENTITY_EMAILS); + const allowedDomains = parseCsvEnv(process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS); + return { + enabled: trustProxyHeaders() && emailHeaders.length > 0 && (allowedEmails.length > 0 || allowedDomains.length > 0), + emailHeaders, + allowedEmails, + allowedDomains, + }; +} + +export function resolveTrustedProxyIdentity(input: { + header(name: string): string | string[] | undefined | null; +}): TrustedProxyIdentityPrincipal | null { + const config = getTrustedProxyIdentityConfig(); + if (!config.enabled) return null; + for (const header of config.emailHeaders) { + const raw = input.header(header); + const firstValue = Array.isArray(raw) ? raw[0] : raw; + if (typeof firstValue !== 'string' || !firstValue.trim()) continue; + const email = normalizeTrustedIdentityEmail(firstValue); + if (!email || !isTrustedIdentityEmailAllowed(email, config)) continue; + return { + provider: 'trusted_proxy_email', + email, + actor: `email:${email}`, + ownerId: email, + header, + }; + } + return null; +} + export function isOAuthConfigured(_publicBaseUrl?: string): boolean { return false; } diff --git a/server/routes.ts b/server/routes.ts index 1cb227c..320d21c 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -54,6 +54,7 @@ import { handleOAuthCallback, pollOAuthFlow, resolveShareMarkdownAuthMode, + resolveTrustedProxyIdentity, revokeHostedSessionToken, startOAuthFlow, validateHostedSessionToken, @@ -372,6 +373,7 @@ type DirectShareAuthorizationResult = { authed: boolean; authMode: 'none' | 'api_key' | 'oauth' | 'oauth_or_api_key'; actor: string; + ownerId: string | null; }; function buildOAuthNotConfiguredPayload(errorMessage: string): Record { @@ -452,7 +454,7 @@ async function authorizeDirectShareRequest( const presented = getDirectSharePresentedToken(req); if (authMode === 'none') { - return { authed: false, authMode, actor: 'anonymous' }; + return { authed: false, authMode, actor: 'anonymous', ownerId: null }; } if (authMode === 'api_key') { @@ -465,7 +467,7 @@ async function authorizeDirectShareRequest( return null; } if (presented === requiredApiKey) { - return { authed: true, authMode, actor: 'api-key' }; + return { authed: true, authMode, actor: 'api-key', ownerId: null }; } res.status(401).json({ error: 'Unauthorized direct share request', @@ -477,7 +479,17 @@ async function authorizeDirectShareRequest( const requiredApiKey = getDirectShareApiKey(); if (authMode === 'oauth_or_api_key' && requiredApiKey && presented === requiredApiKey) { - return { authed: true, authMode, actor: 'api-key' }; + return { authed: true, authMode, actor: 'api-key', ownerId: null }; + } + + const trustedProxyIdentity = resolveTrustedProxyIdentity(req); + if (trustedProxyIdentity) { + return { + authed: true, + authMode, + actor: trustedProxyIdentity.actor, + ownerId: trustedProxyIdentity.ownerId, + }; } if (!presented) { @@ -490,6 +502,7 @@ async function authorizeDirectShareRequest( authed: true, authMode, actor: `oauth:${validated.principal.userId}`, + ownerId: `oauth:${validated.principal.userId}`, }; } @@ -708,7 +721,20 @@ function isOAuthPrincipalOwner(ownerId: string | null | undefined, oauthUserId: || normalized === `oauth_user:${asString}`; } -async function ownerAuthorizedViaOAuth(req: Request, ownerId: string | null | undefined): Promise { +function isTrustedProxyIdentityOwner(ownerId: string | null | undefined, email: string): boolean { + if (!ownerId || !ownerId.trim()) return false; + const normalizedOwnerId = ownerId.trim().toLowerCase(); + const normalizedEmail = email.trim().toLowerCase(); + return normalizedOwnerId === normalizedEmail + || normalizedOwnerId === `email:${normalizedEmail}` + || normalizedOwnerId === `trusted_proxy_email:${normalizedEmail}`; +} + +async function ownerAuthorizedViaHostedIdentity(req: Request, ownerId: string | null | undefined): Promise { + const trustedIdentity = resolveTrustedProxyIdentity(req); + if (trustedIdentity && isTrustedProxyIdentityOwner(ownerId, trustedIdentity.email)) { + return true; + } const bearerToken = getPresentedBearerToken(req); if (!bearerToken) return false; const validated = await validateHostedSessionToken(bearerToken, getPublicBaseUrl(req)); @@ -734,8 +760,8 @@ async function resolveOpenContextAccess( const bearerResolved = !explicitResolved && bearerToken ? resolveDocumentAccess(slug, bearerToken) : null; const resolved = explicitResolved ?? bearerResolved; const ownerBySecret = canMutateByOwnerIdentity(doc, explicitSecret); - const ownerByOAuth = await ownerAuthorizedViaOAuth(req, doc.owner_id); - const ownerAuthorized = ownerBySecret || ownerByOAuth; + const ownerByHostedIdentity = await ownerAuthorizedViaHostedIdentity(req, doc.owner_id); + const ownerAuthorized = ownerBySecret || ownerByHostedIdentity; if (ownerAuthorized) { return { role: 'owner_bot', tokenId: null, ownerAuthorized: true }; @@ -899,7 +925,7 @@ apiRoutes.post('/documents/:slug/access-links', async (req: Request, res: Respon return; } - const ownerAuthorized = canOwnerMutate(req, doc) || await ownerAuthorizedViaOAuth(req, doc.owner_id); + const ownerAuthorized = canOwnerMutate(req, doc) || await ownerAuthorizedViaHostedIdentity(req, doc.owner_id); const secret = getPresentedSecret(req); const role = secret ? resolveDocumentAccessRole(slug, secret) : null; const canCreateAccessLinks = ownerAuthorized || role === 'editor' || role === 'owner_bot'; @@ -1062,7 +1088,9 @@ export async function handleShareMarkdown(req: Request, res: Response): Promise< ? body.title : titleFromQuery; const ownerIdFromQuery = typeof req.query.ownerId === 'string' ? req.query.ownerId : undefined; - const ownerId = typeof body?.ownerId === 'string' ? body.ownerId : ownerIdFromQuery; + const ownerId = typeof body?.ownerId === 'string' + ? body.ownerId + : (ownerIdFromQuery ?? auth.ownerId ?? undefined); const roleFromBody = body?.accessRole ?? body?.defaultRole ?? body?.role; const roleFromQuery = typeof req.query.role === 'string' ? req.query.role : undefined; diff --git a/src/tests/server-routes-and-share.test.ts b/src/tests/server-routes-and-share.test.ts index c1e568e..79dbeb2 100644 --- a/src/tests/server-routes-and-share.test.ts +++ b/src/tests/server-routes-and-share.test.ts @@ -1804,6 +1804,63 @@ async function runRoutePayloadValidationTests(): Promise { } }); + await test('D2: POST /share/markdown accepts trusted proxy email headers for Cloud Run IAP', async () => { + const previousMode = process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + const previousTrustProxy = process.env.PROOF_TRUST_PROXY_HEADERS; + const previousTrustedHeaders = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + const previousTrustedDomains = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + const previousTrustedEmails = process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = 'oauth'; + process.env.PROOF_TRUST_PROXY_HEADERS = 'true'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = 'x-goog-authenticated-user-email'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = 'elastic.co'; + delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + try { + const createResponse = await post(baseUrl, '/api/share/markdown', { + markdown: '# IAP trusted create', + }, { + 'X-Goog-Authenticated-User-Email': 'accounts.google.com:agent@elastic.co', + }); + assert(createResponse.status === 200, `Expected status 200 for trusted proxy create, got ${createResponse.status}`); + const createPayload = await createResponse.json(); + assert(createPayload.success === true, 'Expected trusted proxy create to succeed'); + assert(typeof createPayload.slug === 'string' && createPayload.slug.length > 0, 'Expected trusted proxy create slug'); + + const accessLinksResponse = await post(baseUrl, `/api/documents/${encodeURIComponent(createPayload.slug as string)}/access-links`, { + role: 'viewer', + }, { + 'X-Goog-Authenticated-User-Email': 'accounts.google.com:agent@elastic.co', + }); + assert(accessLinksResponse.status === 200, `Expected trusted proxy owner access-links status 200, got ${accessLinksResponse.status}`); + const accessLinksPayload = await accessLinksResponse.json(); + assert(accessLinksPayload.success === true, 'Expected trusted proxy owner access-links success'); + + const discoveryResponse = await get(baseUrl, '/.well-known/agent.json'); + assert(discoveryResponse.status === 200, `Expected discovery status 200, got ${discoveryResponse.status}`); + const discoveryPayload = await discoveryResponse.json(); + const authMethods = Array.isArray(discoveryPayload?.auth?.methods) ? discoveryPayload.auth.methods : []; + assert(authMethods.includes('trusted_proxy_email'), 'Expected discovery auth methods to advertise trusted_proxy_email'); + assertEqual( + discoveryPayload?.auth?.trusted_proxy?.email_headers?.[0], + 'x-goog-authenticated-user-email', + 'Expected discovery trusted proxy header metadata', + ); + } finally { + if (previousMode === undefined) delete process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + else process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = previousMode; + if (previousTrustProxy === undefined) delete process.env.PROOF_TRUST_PROXY_HEADERS; + else process.env.PROOF_TRUST_PROXY_HEADERS = previousTrustProxy; + if (previousTrustedHeaders === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = previousTrustedHeaders; + if (previousTrustedDomains === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = previousTrustedDomains; + if (previousTrustedEmails === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAILS = previousTrustedEmails; + } + }); + await test('D2: /documents/:slug/ops enforces rate limiting', async () => { let saw429 = false; for (let i = 0; i < 140; i += 1) { From 285cbff597984d9737e6da9620890fc53bc77e7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:47:11 +0000 Subject: [PATCH 3/7] chore: add rate limiting to document access auth routes Co-authored-by: adhishthite <31769894+adhishthite@users.noreply.github.com> --- server/hosted-auth.ts | 8 ++++---- server/routes.ts | 15 +++++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/server/hosted-auth.ts b/server/hosted-auth.ts index 0b2f0fe..e4e58ab 100644 --- a/server/hosted-auth.ts +++ b/server/hosted-auth.ts @@ -45,7 +45,7 @@ function normalizeTrustedIdentityEmail(raw: string): string | null { function isTrustedIdentityEmailAllowed(email: string, config: TrustedProxyIdentityConfig): boolean { if (config.allowedEmails.includes(email)) return true; - const domain = email.split('@')[1] || ''; + const domain = email.split('@')[1]; return domain ? config.allowedDomains.includes(domain) : false; } @@ -69,8 +69,8 @@ export function resolveTrustedProxyIdentity(input: { }): TrustedProxyIdentityPrincipal | null { const config = getTrustedProxyIdentityConfig(); if (!config.enabled) return null; - for (const header of config.emailHeaders) { - const raw = input.header(header); + for (const headerName of config.emailHeaders) { + const raw = input.header(headerName); const firstValue = Array.isArray(raw) ? raw[0] : raw; if (typeof firstValue !== 'string' || !firstValue.trim()) continue; const email = normalizeTrustedIdentityEmail(firstValue); @@ -80,7 +80,7 @@ export function resolveTrustedProxyIdentity(input: { email, actor: `email:${email}`, ownerId: email, - header, + header: headerName, }; } return null; diff --git a/server/routes.ts b/server/routes.ts index 320d21c..7725f95 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -110,12 +110,19 @@ const DEFAULT_DIRECT_SHARE_RATE_LIMIT_MAX_AUTH_PER_MIN = 120; const DIRECT_SHARE_RATE_LIMIT_MAX_BUCKETS = 10_000; const OPS_RATE_LIMIT_WINDOW_MS = parsePositiveIntEnv('PROOF_OPS_RATE_LIMIT_WINDOW_MS', 60_000); const OPS_RATE_LIMIT_MAX_REQUESTS = parsePositiveIntEnv('PROOF_OPS_RATE_LIMIT_MAX', 120); +const DOCUMENT_ACCESS_RATE_LIMIT_WINDOW_MS = parsePositiveIntEnv('PROOF_DOCUMENT_ACCESS_RATE_LIMIT_WINDOW_MS', 60_000); +const DOCUMENT_ACCESS_RATE_LIMIT_MAX_REQUESTS = parsePositiveIntEnv('PROOF_DOCUMENT_ACCESS_RATE_LIMIT_MAX', 600); const REWRITE_BARRIER_TIMEOUT_MS = parsePositiveIntEnv('PROOF_REWRITE_BARRIER_TIMEOUT_MS', 5000); const opsRateLimiter = createRateLimiter({ windowMs: OPS_RATE_LIMIT_WINDOW_MS, maxRequests: OPS_RATE_LIMIT_MAX_REQUESTS, keyFn: (req) => `${getClientIp(req)}:${getSlugParam(req) || 'unknown'}`, }); +const documentAccessRateLimiter = createRateLimiter({ + windowMs: DOCUMENT_ACCESS_RATE_LIMIT_WINDOW_MS, + maxRequests: DOCUMENT_ACCESS_RATE_LIMIT_MAX_REQUESTS, + keyFn: (req) => `${getClientIp(req)}:${getSlugParam(req) || 'unknown'}:document-access`, +}); export const shareMarkdownBodyParser = text({ type: ['text/plain', 'text/markdown'], @@ -909,7 +916,7 @@ apiRoutes.post('/documents', (req: Request, res: Response) => { }); }); -apiRoutes.post('/documents/:slug/access-links', async (req: Request, res: Response) => { +apiRoutes.post('/documents/:slug/access-links', documentAccessRateLimiter, async (req: Request, res: Response) => { const slug = getSlugParam(req); if (!slug) { res.status(400).json({ error: 'Invalid slug' }); @@ -1090,7 +1097,7 @@ export async function handleShareMarkdown(req: Request, res: Response): Promise< const ownerIdFromQuery = typeof req.query.ownerId === 'string' ? req.query.ownerId : undefined; const ownerId = typeof body?.ownerId === 'string' ? body.ownerId - : (ownerIdFromQuery ?? auth.ownerId ?? undefined); + : (ownerIdFromQuery ?? auth.ownerId); const roleFromBody = body?.accessRole ?? body?.defaultRole ?? body?.role; const roleFromQuery = typeof req.query.role === 'string' ? req.query.role : undefined; @@ -1889,7 +1896,7 @@ apiRoutes.get('/documents/:slug/info', (req: Request, res: Response) => { }); }); -apiRoutes.get('/documents/:slug/open-context', async (req: Request, res: Response) => { +apiRoutes.get('/documents/:slug/open-context', documentAccessRateLimiter, async (req: Request, res: Response) => { const slug = getSlugParam(req); if (!slug) { res.status(400).json({ error: 'Invalid slug' }); @@ -1981,7 +1988,7 @@ apiRoutes.get('/documents/:slug/open-context', async (req: Request, res: Respons }); }); -apiRoutes.post('/documents/:slug/collab-refresh', async (req: Request, res: Response) => { +apiRoutes.post('/documents/:slug/collab-refresh', documentAccessRateLimiter, async (req: Request, res: Response) => { const slug = getSlugParam(req); if (!slug) { res.status(400).json({ error: 'Invalid slug' }); From 5ff1feb61b44181e4689776a011ba919fb29c2fe Mon Sep 17 00:00:00 2001 From: Adhish Date: Fri, 13 Mar 2026 00:33:21 +0530 Subject: [PATCH 4/7] fix: redact trusted proxy allowlists from discovery endpoint Remove email_headers, allowed_email_domains, and allowed_emails from /.well-known/agent.json public discovery. Only advertise that trusted_proxy_email auth is available, not internal config details. Add test: D2: discovery endpoint redacts trusted proxy allowlists --- server/discovery-routes.ts | 10 -- src/tests/server-routes-and-share.test.ts | 126 +++++++++++++++++++++- 2 files changed, 122 insertions(+), 14 deletions(-) diff --git a/server/discovery-routes.ts b/server/discovery-routes.ts index f7cd656..b480c8b 100644 --- a/server/discovery-routes.ts +++ b/server/discovery-routes.ts @@ -106,16 +106,6 @@ discoveryRoutes.get('/.well-known/agent.json', (req: Request, res: Response) => preferred_header: AUTH_HEADER_FORMAT, alt_header: ALT_SHARE_TOKEN_HEADER_FORMAT, }, - ...(trustedProxyIdentity.enabled - ? { - trusted_proxy: { - mode: 'email_header', - email_headers: trustedProxyIdentity.emailHeaders, - allowed_email_domains: trustedProxyIdentity.allowedDomains, - allowed_emails: trustedProxyIdentity.allowedEmails, - }, - } - : {}), }, quickstart: { received_link: { diff --git a/src/tests/server-routes-and-share.test.ts b/src/tests/server-routes-and-share.test.ts index 79dbeb2..ef27655 100644 --- a/src/tests/server-routes-and-share.test.ts +++ b/src/tests/server-routes-and-share.test.ts @@ -1842,10 +1842,83 @@ async function runRoutePayloadValidationTests(): Promise { const discoveryPayload = await discoveryResponse.json(); const authMethods = Array.isArray(discoveryPayload?.auth?.methods) ? discoveryPayload.auth.methods : []; assert(authMethods.includes('trusted_proxy_email'), 'Expected discovery auth methods to advertise trusted_proxy_email'); - assertEqual( - discoveryPayload?.auth?.trusted_proxy?.email_headers?.[0], - 'x-goog-authenticated-user-email', - 'Expected discovery trusted proxy header metadata', + } finally { + if (previousMode === undefined) delete process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + else process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = previousMode; + if (previousTrustProxy === undefined) delete process.env.PROOF_TRUST_PROXY_HEADERS; + else process.env.PROOF_TRUST_PROXY_HEADERS = previousTrustProxy; + if (previousTrustedHeaders === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = previousTrustedHeaders; + if (previousTrustedDomains === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = previousTrustedDomains; + if (previousTrustedEmails === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAILS = previousTrustedEmails; + } + }); + + await test('D2: discovery endpoint redacts trusted proxy allowlists and header config', async () => { + const previousMode = process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + const previousTrustProxy = process.env.PROOF_TRUST_PROXY_HEADERS; + const previousTrustedHeaders = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + const previousTrustedDomains = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + const previousTrustedEmails = process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = 'oauth'; + process.env.PROOF_TRUST_PROXY_HEADERS = 'true'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = 'x-goog-authenticated-user-email,x-forwarded-email'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = 'elastic.co'; + process.env.PROOF_TRUSTED_IDENTITY_EMAILS = 'agent@elastic.co'; + + try { + const response = await get(baseUrl, '/.well-known/agent.json'); + assert(response.status === 200, `Expected discovery status 200, got ${response.status}`); + const payload = await response.json(); + const authMethods = Array.isArray(payload?.auth?.methods) ? payload.auth.methods : []; + assert(authMethods.includes('trusted_proxy_email'), 'Expected discovery auth methods to advertise trusted_proxy_email'); + assert(!('trusted_proxy' in (payload?.auth ?? {})), 'Expected discovery payload to omit trusted proxy internals'); + } finally { + if (previousMode === undefined) delete process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + else process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = previousMode; + if (previousTrustProxy === undefined) delete process.env.PROOF_TRUST_PROXY_HEADERS; + else process.env.PROOF_TRUST_PROXY_HEADERS = previousTrustProxy; + if (previousTrustedHeaders === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = previousTrustedHeaders; + if (previousTrustedDomains === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = previousTrustedDomains; + if (previousTrustedEmails === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAILS = previousTrustedEmails; + } + }); + + await test('D2: trusted proxy auth does not trust x-forwarded-email by default', async () => { + const previousMode = process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + const previousTrustProxy = process.env.PROOF_TRUST_PROXY_HEADERS; + const previousTrustedHeaders = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + const previousTrustedDomains = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + const previousTrustedEmails = process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = 'oauth'; + process.env.PROOF_TRUST_PROXY_HEADERS = 'true'; + delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = 'elastic.co'; + delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + try { + const response = await post(baseUrl, '/api/share/markdown', { + markdown: '# spoofed forwarded email', + }, { + 'X-Forwarded-Email': 'spoofed@elastic.co', + }); + assert( + response.status === 401 || response.status === 503, + `Expected spoofed x-forwarded-email to be rejected (401 or 503), got ${response.status}`, + ); + const payload = await response.json(); + // When OAuth is not configured, the server returns 503 after the proxy check fails. + // Either way, the spoofed x-forwarded-email was NOT accepted as a trusted identity. + assert( + payload.code === 'UNAUTHORIZED' || payload.code === 'OAUTH_NOT_CONFIGURED', + `Expected rejection code, got ${payload.code}`, ); } finally { if (previousMode === undefined) delete process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; @@ -1861,6 +1934,51 @@ async function runRoutePayloadValidationTests(): Promise { } }); + await test('D2: trusted proxy auth rejects ownerId mismatches', async () => { + const previousMode = process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + const previousTrustProxy = process.env.PROOF_TRUST_PROXY_HEADERS; + const previousTrustedHeaders = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + const previousTrustedDomains = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + const previousTrustedEmails = process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = 'oauth'; + process.env.PROOF_TRUST_PROXY_HEADERS = 'true'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = 'x-goog-authenticated-user-email'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = 'elastic.co'; + delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + try { + const mismatchResponse = await post(baseUrl, '/api/share/markdown', { + markdown: '# mismatched owner', + ownerId: 'other@elastic.co', + }, { + 'X-Goog-Authenticated-User-Email': 'accounts.google.com:agent@elastic.co', + }); + assert(mismatchResponse.status === 403, `Expected ownerId mismatch to be rejected, got ${mismatchResponse.status}`); + const mismatchPayload = await mismatchResponse.json(); + assertEqual(mismatchPayload.code, 'FORBIDDEN_OWNER_ID_MISMATCH', 'Expected ownerId mismatch code'); + + const matchingResponse = await post(baseUrl, '/api/share/markdown', { + markdown: '# matching owner', + ownerId: 'agent@elastic.co', + }, { + 'X-Goog-Authenticated-User-Email': 'accounts.google.com:agent@elastic.co', + }); + assert(matchingResponse.status === 200, `Expected matching trusted ownerId to succeed, got ${matchingResponse.status}`); + } finally { + if (previousMode === undefined) delete process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + else process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = previousMode; + if (previousTrustProxy === undefined) delete process.env.PROOF_TRUST_PROXY_HEADERS; + else process.env.PROOF_TRUST_PROXY_HEADERS = previousTrustProxy; + if (previousTrustedHeaders === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = previousTrustedHeaders; + if (previousTrustedDomains === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = previousTrustedDomains; + if (previousTrustedEmails === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAILS = previousTrustedEmails; + } + }); + await test('D2: /documents/:slug/ops enforces rate limiting', async () => { let saw429 = false; for (let i = 0; i < 140; i += 1) { From 6df0e4cbf845f2607dbe843ae88aa36e2e6f54eb Mon Sep 17 00:00:00 2001 From: Adhish Date: Fri, 13 Mar 2026 00:33:25 +0530 Subject: [PATCH 5/7] fix: default trusted headers to only x-goog-authenticated-user-email Remove x-forwarded-email from the default trusted headers fallback. When PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS is not set, only x-goog-authenticated-user-email is trusted by default. Add test: D2: trusted proxy auth does not trust x-forwarded-email --- server/hosted-auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/hosted-auth.ts b/server/hosted-auth.ts index e4e58ab..d97eef1 100644 --- a/server/hosted-auth.ts +++ b/server/hosted-auth.ts @@ -52,7 +52,7 @@ function isTrustedIdentityEmailAllowed(email: string, config: TrustedProxyIdenti export function getTrustedProxyIdentityConfig(): TrustedProxyIdentityConfig { const emailHeaders = parseCsvEnv( process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS - || 'x-goog-authenticated-user-email,x-forwarded-email', + || 'x-goog-authenticated-user-email', ); const allowedEmails = parseCsvEnv(process.env.PROOF_TRUSTED_IDENTITY_EMAILS); const allowedDomains = parseCsvEnv(process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS); From a6bb71444903e02f4c4178412e195b940bdff06a Mon Sep 17 00:00:00 2001 From: Adhish Date: Fri, 13 Mar 2026 00:33:31 +0530 Subject: [PATCH 6/7] fix: reject ownerId mismatch for trusted proxy email auth For trusted_proxy_email auth, force ownerId to the authenticated email. If body.ownerId differs from the authenticated principal, reject with 403 FORBIDDEN_OWNER_ID_MISMATCH. Add principalProvider field to DirectShareAuthorizationResult for auth source tracking. Update docs to reflect that ownerId is now enforced, not optional. Add test: D2: trusted proxy auth rejects ownerId mismatches --- README.md | 2 +- docs/agent-docs.md | 2 +- server/routes.ts | 47 ++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7651fba..9bd1a53 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ PROOF_SHARE_MARKDOWN_AUTH_MODE=oauth - Cloud Run / IAP will authenticate the caller and inject `x-goog-authenticated-user-email`. - Proof SDK will accept that trusted header for hosted-auth flows such as `POST /api/share/markdown`. -- New shares created through that path default `ownerId` to the trusted email unless you explicitly pass `ownerId`. +- New shares created through that path always use the authenticated trusted email as `ownerId`; mismatched `ownerId` values are rejected. - Share/document routes still use the normal Proof share tokens (`Authorization: Bearer ` or `x-share-token`) once the request has passed IAP. See `docs/agent-docs.md` for the full agent-side flow. diff --git a/docs/agent-docs.md b/docs/agent-docs.md index e0001ab..b6d474f 100644 --- a/docs/agent-docs.md +++ b/docs/agent-docs.md @@ -97,7 +97,7 @@ What this does: - IAP authenticates the caller and injects `x-goog-authenticated-user-email`. - Proof SDK trusts that header only when `PROOF_TRUST_PROXY_HEADERS=true` and the email matches `PROOF_TRUSTED_IDENTITY_EMAILS` or `PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS`. - `POST /api/share/markdown` and other hosted-auth checks can use that trusted identity without a separate Proof OAuth session. -- When a direct share is created this way, the document owner defaults to the trusted email unless you explicitly pass `ownerId`. +- When a direct share is created this way, the document owner is always the authenticated trusted email; mismatched `ownerId` values are rejected. Example request from an agent that already has IAP access: diff --git a/server/routes.ts b/server/routes.ts index 7725f95..da46bbe 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -379,6 +379,7 @@ function getDirectSharePresentedToken(req: Request): string | null { type DirectShareAuthorizationResult = { authed: boolean; authMode: 'none' | 'api_key' | 'oauth' | 'oauth_or_api_key'; + principalProvider: 'none' | 'api_key' | 'oauth' | 'trusted_proxy_email'; actor: string; ownerId: string | null; }; @@ -461,7 +462,13 @@ async function authorizeDirectShareRequest( const presented = getDirectSharePresentedToken(req); if (authMode === 'none') { - return { authed: false, authMode, actor: 'anonymous', ownerId: null }; + return { + authed: false, + authMode, + principalProvider: 'none', + actor: 'anonymous', + ownerId: null, + }; } if (authMode === 'api_key') { @@ -474,7 +481,13 @@ async function authorizeDirectShareRequest( return null; } if (presented === requiredApiKey) { - return { authed: true, authMode, actor: 'api-key', ownerId: null }; + return { + authed: true, + authMode, + principalProvider: 'api_key', + actor: 'api-key', + ownerId: null, + }; } res.status(401).json({ error: 'Unauthorized direct share request', @@ -486,7 +499,13 @@ async function authorizeDirectShareRequest( const requiredApiKey = getDirectShareApiKey(); if (authMode === 'oauth_or_api_key' && requiredApiKey && presented === requiredApiKey) { - return { authed: true, authMode, actor: 'api-key', ownerId: null }; + return { + authed: true, + authMode, + principalProvider: 'api_key', + actor: 'api-key', + ownerId: null, + }; } const trustedProxyIdentity = resolveTrustedProxyIdentity(req); @@ -494,6 +513,7 @@ async function authorizeDirectShareRequest( return { authed: true, authMode, + principalProvider: 'trusted_proxy_email', actor: trustedProxyIdentity.actor, ownerId: trustedProxyIdentity.ownerId, }; @@ -508,6 +528,7 @@ async function authorizeDirectShareRequest( return { authed: true, authMode, + principalProvider: 'oauth', actor: `oauth:${validated.principal.userId}`, ownerId: `oauth:${validated.principal.userId}`, }; @@ -1095,9 +1116,23 @@ export async function handleShareMarkdown(req: Request, res: Response): Promise< ? body.title : titleFromQuery; const ownerIdFromQuery = typeof req.query.ownerId === 'string' ? req.query.ownerId : undefined; - const ownerId = typeof body?.ownerId === 'string' - ? body.ownerId - : (ownerIdFromQuery ?? auth.ownerId); + const ownerIdFromBody = typeof body?.ownerId === 'string' ? body.ownerId : undefined; + const requestedOwnerId = ownerIdFromBody ?? ownerIdFromQuery; + if ( + auth.principalProvider === 'trusted_proxy_email' + && typeof requestedOwnerId === 'string' + && requestedOwnerId.trim() + && !isTrustedProxyIdentityOwner(requestedOwnerId, auth.ownerId ?? '') + ) { + res.status(403).json({ + error: 'ownerId must match the authenticated trusted proxy email', + code: 'FORBIDDEN_OWNER_ID_MISMATCH', + }); + return; + } + const ownerId = auth.principalProvider === 'trusted_proxy_email' + ? auth.ownerId + : (ownerIdFromBody ?? ownerIdFromQuery ?? auth.ownerId); const roleFromBody = body?.accessRole ?? body?.defaultRole ?? body?.role; const roleFromQuery = typeof req.query.role === 'string' ? req.query.role : undefined; From e2bad9b4b04abddf6a0446234f56e4801fd10431 Mon Sep 17 00:00:00 2001 From: Adhish Date: Fri, 13 Mar 2026 00:51:10 +0530 Subject: [PATCH 7/7] fix: harden trusted proxy ownerId validation - Validate body.ownerId and query.ownerId independently (not coalesced) - Reject empty/whitespace-only ownerId strings for trusted_proxy_email auth - Add isRejectedTrustedProxyOwnerId helper for clean validation logic - 5 new tests: query-only mismatch, dual-source conflict, empty ownerId, whitespace ownerId, api_key regression (arbitrary ownerId still allowed) --- server/routes.ts | 18 ++- src/tests/server-routes-and-share.test.ts | 170 ++++++++++++++++++++++ 2 files changed, 182 insertions(+), 6 deletions(-) diff --git a/server/routes.ts b/server/routes.ts index da46bbe..9f6b7cc 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -758,6 +758,12 @@ function isTrustedProxyIdentityOwner(ownerId: string | null | undefined, email: || normalizedOwnerId === `trusted_proxy_email:${normalizedEmail}`; } +function isRejectedTrustedProxyOwnerId(ownerId: string | null | undefined, email: string | null | undefined): boolean { + if (typeof ownerId !== 'string') return false; + if (!ownerId.trim()) return true; + return !isTrustedProxyIdentityOwner(ownerId, email ?? ''); +} + async function ownerAuthorizedViaHostedIdentity(req: Request, ownerId: string | null | undefined): Promise { const trustedIdentity = resolveTrustedProxyIdentity(req); if (trustedIdentity && isTrustedProxyIdentityOwner(ownerId, trustedIdentity.email)) { @@ -1117,12 +1123,12 @@ export async function handleShareMarkdown(req: Request, res: Response): Promise< : titleFromQuery; const ownerIdFromQuery = typeof req.query.ownerId === 'string' ? req.query.ownerId : undefined; const ownerIdFromBody = typeof body?.ownerId === 'string' ? body.ownerId : undefined; - const requestedOwnerId = ownerIdFromBody ?? ownerIdFromQuery; if ( auth.principalProvider === 'trusted_proxy_email' - && typeof requestedOwnerId === 'string' - && requestedOwnerId.trim() - && !isTrustedProxyIdentityOwner(requestedOwnerId, auth.ownerId ?? '') + && ( + isRejectedTrustedProxyOwnerId(ownerIdFromBody, auth.ownerId ?? '') + || isRejectedTrustedProxyOwnerId(ownerIdFromQuery, auth.ownerId ?? '') + ) ) { res.status(403).json({ error: 'ownerId must match the authenticated trusted proxy email', @@ -1131,8 +1137,8 @@ export async function handleShareMarkdown(req: Request, res: Response): Promise< return; } const ownerId = auth.principalProvider === 'trusted_proxy_email' - ? auth.ownerId - : (ownerIdFromBody ?? ownerIdFromQuery ?? auth.ownerId); + ? (auth.ownerId ?? undefined) + : (ownerIdFromBody ?? ownerIdFromQuery ?? auth.ownerId ?? undefined); const roleFromBody = body?.accessRole ?? body?.defaultRole ?? body?.role; const roleFromQuery = typeof req.query.role === 'string' ? req.query.role : undefined; diff --git a/src/tests/server-routes-and-share.test.ts b/src/tests/server-routes-and-share.test.ts index ef27655..7d84380 100644 --- a/src/tests/server-routes-and-share.test.ts +++ b/src/tests/server-routes-and-share.test.ts @@ -1979,6 +1979,176 @@ async function runRoutePayloadValidationTests(): Promise { } }); + await test('D2: trusted proxy auth rejects query-param-only ownerId mismatches', async () => { + const previousMode = process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + const previousTrustProxy = process.env.PROOF_TRUST_PROXY_HEADERS; + const previousTrustedHeaders = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + const previousTrustedDomains = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + const previousTrustedEmails = process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = 'oauth'; + process.env.PROOF_TRUST_PROXY_HEADERS = 'true'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = 'x-goog-authenticated-user-email'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = 'elastic.co'; + delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + try { + const response = await post(baseUrl, '/api/share/markdown?ownerId=other%40elastic.co', { + markdown: '# query mismatch', + }, { + 'X-Goog-Authenticated-User-Email': 'accounts.google.com:agent@elastic.co', + }); + assert(response.status === 403, `Expected query ownerId mismatch to be rejected, got ${response.status}`); + const payload = await response.json(); + assertEqual(payload.code, 'FORBIDDEN_OWNER_ID_MISMATCH', 'Expected query ownerId mismatch code'); + } finally { + if (previousMode === undefined) delete process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + else process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = previousMode; + if (previousTrustProxy === undefined) delete process.env.PROOF_TRUST_PROXY_HEADERS; + else process.env.PROOF_TRUST_PROXY_HEADERS = previousTrustProxy; + if (previousTrustedHeaders === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = previousTrustedHeaders; + if (previousTrustedDomains === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = previousTrustedDomains; + if (previousTrustedEmails === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAILS = previousTrustedEmails; + } + }); + + await test('D2: trusted proxy auth rejects mismatched query ownerId when body matches', async () => { + const previousMode = process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + const previousTrustProxy = process.env.PROOF_TRUST_PROXY_HEADERS; + const previousTrustedHeaders = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + const previousTrustedDomains = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + const previousTrustedEmails = process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = 'oauth'; + process.env.PROOF_TRUST_PROXY_HEADERS = 'true'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = 'x-goog-authenticated-user-email'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = 'elastic.co'; + delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + try { + const response = await post(baseUrl, '/api/share/markdown?ownerId=other%40elastic.co', { + markdown: '# body matches query mismatches', + ownerId: 'agent@elastic.co', + }, { + 'X-Goog-Authenticated-User-Email': 'accounts.google.com:agent@elastic.co', + }); + assert(response.status === 403, `Expected mismatched query ownerId to be rejected, got ${response.status}`); + const payload = await response.json(); + assertEqual(payload.code, 'FORBIDDEN_OWNER_ID_MISMATCH', 'Expected mismatched query ownerId code'); + } finally { + if (previousMode === undefined) delete process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + else process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = previousMode; + if (previousTrustProxy === undefined) delete process.env.PROOF_TRUST_PROXY_HEADERS; + else process.env.PROOF_TRUST_PROXY_HEADERS = previousTrustProxy; + if (previousTrustedHeaders === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = previousTrustedHeaders; + if (previousTrustedDomains === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = previousTrustedDomains; + if (previousTrustedEmails === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAILS = previousTrustedEmails; + } + }); + + await test('D2: trusted proxy auth rejects empty-string ownerId in body', async () => { + const previousMode = process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + const previousTrustProxy = process.env.PROOF_TRUST_PROXY_HEADERS; + const previousTrustedHeaders = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + const previousTrustedDomains = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + const previousTrustedEmails = process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = 'oauth'; + process.env.PROOF_TRUST_PROXY_HEADERS = 'true'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = 'x-goog-authenticated-user-email'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = 'elastic.co'; + delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + try { + const response = await post(baseUrl, '/api/share/markdown', { + markdown: '# empty owner', + ownerId: '', + }, { + 'X-Goog-Authenticated-User-Email': 'accounts.google.com:agent@elastic.co', + }); + assert(response.status === 403, `Expected empty ownerId to be rejected, got ${response.status}`); + const payload = await response.json(); + assertEqual(payload.code, 'FORBIDDEN_OWNER_ID_MISMATCH', 'Expected empty ownerId rejection code'); + } finally { + if (previousMode === undefined) delete process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + else process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = previousMode; + if (previousTrustProxy === undefined) delete process.env.PROOF_TRUST_PROXY_HEADERS; + else process.env.PROOF_TRUST_PROXY_HEADERS = previousTrustProxy; + if (previousTrustedHeaders === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = previousTrustedHeaders; + if (previousTrustedDomains === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = previousTrustedDomains; + if (previousTrustedEmails === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAILS = previousTrustedEmails; + } + }); + + await test('D2: trusted proxy auth rejects whitespace-only ownerId in body', async () => { + const previousMode = process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + const previousTrustProxy = process.env.PROOF_TRUST_PROXY_HEADERS; + const previousTrustedHeaders = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + const previousTrustedDomains = process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + const previousTrustedEmails = process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = 'oauth'; + process.env.PROOF_TRUST_PROXY_HEADERS = 'true'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = 'x-goog-authenticated-user-email'; + process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = 'elastic.co'; + delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + + try { + const response = await post(baseUrl, '/api/share/markdown', { + markdown: '# whitespace owner', + ownerId: ' ', + }, { + 'X-Goog-Authenticated-User-Email': 'accounts.google.com:agent@elastic.co', + }); + assert(response.status === 403, `Expected whitespace ownerId to be rejected, got ${response.status}`); + const payload = await response.json(); + assertEqual(payload.code, 'FORBIDDEN_OWNER_ID_MISMATCH', 'Expected whitespace ownerId rejection code'); + } finally { + if (previousMode === undefined) delete process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + else process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = previousMode; + if (previousTrustProxy === undefined) delete process.env.PROOF_TRUST_PROXY_HEADERS; + else process.env.PROOF_TRUST_PROXY_HEADERS = previousTrustProxy; + if (previousTrustedHeaders === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_HEADERS = previousTrustedHeaders; + if (previousTrustedDomains === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAIL_DOMAINS = previousTrustedDomains; + if (previousTrustedEmails === undefined) delete process.env.PROOF_TRUSTED_IDENTITY_EMAILS; + else process.env.PROOF_TRUSTED_IDENTITY_EMAILS = previousTrustedEmails; + } + }); + + await test('D2: api_key auth still allows arbitrary ownerId', async () => { + const previousMode = process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + const previousApiKey = process.env.PROOF_SHARE_MARKDOWN_API_KEY; + + process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = 'api_key'; + process.env.PROOF_SHARE_MARKDOWN_API_KEY = 'test-key-123'; + + try { + const response = await post(baseUrl, '/api/share/markdown', { + markdown: '# api key owner', + ownerId: 'arbitrary-owner', + }, { + Authorization: 'Bearer test-key-123', + }); + assert(response.status === 200, `Expected api_key auth to allow arbitrary ownerId, got ${response.status}`); + } finally { + if (previousMode === undefined) delete process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE; + else process.env.PROOF_SHARE_MARKDOWN_AUTH_MODE = previousMode; + if (previousApiKey === undefined) delete process.env.PROOF_SHARE_MARKDOWN_API_KEY; + else process.env.PROOF_SHARE_MARKDOWN_API_KEY = previousApiKey; + } + }); + await test('D2: /documents/:slug/ops enforces rate limiting', async () => { let saw429 = false; for (let i = 0; i < 140; i += 1) {