From 2c7c8dbb5c2dea5245a0c21005f7846372a94aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Wed, 29 Jul 2026 15:32:20 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=BF=A0=ED=82=A4=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20API=20CSRF=20=EA=B2=BD=EA=B3=84=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/dashboard/settings/page.test.tsx | 5 + apps/client/app/dashboard/settings/page.tsx | 142 +++++---- .../app/features/auth/api/authApi.test.ts | 76 +++++ apps/client/app/features/auth/api/authApi.ts | 5 + .../knowledge/api/knowledgeApi.test.ts | 10 +- .../features/knowledge/api/knowledgeApi.ts | 20 +- .../knowledge-search-modal/index.test.tsx | 59 ++-- .../knowledge-search-modal/index.tsx | 19 +- .../app/features/workflow/api/webhookApi.ts | 2 + .../workflow/api/workflowApi.stream.test.ts | 5 + .../app/features/workflow/api/workflowApi.ts | 4 +- .../components/modals/CodeWizardModal.tsx | 13 +- .../components/modals/PromptWizardModal.tsx | 54 +++- .../components/modals/TemplateWizardModal.tsx | 124 +++++--- .../modals/WizardModalOrganization.test.tsx | 13 +- .../workflows/[workflowId]/route.test.ts | 12 +- .../workflows/[workflowId]/route.ts | 25 +- apps/client/lib/apiClient.ts | 5 + apps/client/lib/csrfToken.test.ts | 227 +++++++++++++ apps/client/lib/csrfToken.ts | 232 ++++++++++++++ apps/gateway/adapters/csrf/__init__.py | 1 + apps/gateway/adapters/csrf/observability.py | 65 ++++ apps/gateway/api/v1/endpoints/auth.py | 153 ++++++++- apps/gateway/application/csrf/__init__.py | 1 + apps/gateway/application/csrf/models.py | 69 ++++ apps/gateway/application/csrf/token.py | 250 +++++++++++++++ apps/gateway/composition/csrf.py | 297 ++++++++++++++++++ apps/gateway/main.py | 67 ++-- apps/gateway/middleware/csrf.py | 259 +++++++++++++++ apps/gateway/tests/api/test_auth_csrf.py | 106 +++++++ .../application/csrf/test_token_service.py | 180 +++++++++++ .../architecture/test_csrf_route_inventory.py | 92 ++++++ .../test_http_middleware_order.py | 28 ++ .../composition/test_csrf_configuration.py | 29 ++ apps/gateway/tests/conftest.py | 1 + .../middleware/test_csrf_content_types.py | 133 ++++++++ .../tests/middleware/test_csrf_protection.py | 275 ++++++++++++++++ apps/shared/schemas/csrf.py | 10 + docs/architecture.md | 4 + ...-cookie-authenticated-api-csrf-boundary.md | 99 ++++++ docs/features/auth/api_spec.md | 79 ++++- docs/features/auth/component_spec.md | 30 ++ docs/features/auth/requirements.md | 25 +- docs/features/auth/test_cases.md | 28 ++ docs/features/chatbot-deployment/api_spec.md | 4 +- .../chatbot-deployment/requirements.md | 4 +- docs/features/conversation-memory/api_spec.md | 2 +- .../conversation-memory/requirements.md | 4 +- 48 files changed, 3139 insertions(+), 208 deletions(-) create mode 100644 apps/client/app/features/auth/api/authApi.test.ts create mode 100644 apps/client/lib/csrfToken.test.ts create mode 100644 apps/client/lib/csrfToken.ts create mode 100644 apps/gateway/adapters/csrf/__init__.py create mode 100644 apps/gateway/adapters/csrf/observability.py create mode 100644 apps/gateway/application/csrf/__init__.py create mode 100644 apps/gateway/application/csrf/models.py create mode 100644 apps/gateway/application/csrf/token.py create mode 100644 apps/gateway/composition/csrf.py create mode 100644 apps/gateway/middleware/csrf.py create mode 100644 apps/gateway/tests/api/test_auth_csrf.py create mode 100644 apps/gateway/tests/application/csrf/test_token_service.py create mode 100644 apps/gateway/tests/architecture/test_csrf_route_inventory.py create mode 100644 apps/gateway/tests/architecture/test_http_middleware_order.py create mode 100644 apps/gateway/tests/composition/test_csrf_configuration.py create mode 100644 apps/gateway/tests/middleware/test_csrf_content_types.py create mode 100644 apps/gateway/tests/middleware/test_csrf_protection.py create mode 100644 apps/shared/schemas/csrf.py create mode 100644 docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md diff --git a/apps/client/app/dashboard/settings/page.test.tsx b/apps/client/app/dashboard/settings/page.test.tsx index 46717c418..aecc005e7 100644 --- a/apps/client/app/dashboard/settings/page.test.tsx +++ b/apps/client/app/dashboard/settings/page.test.tsx @@ -7,6 +7,11 @@ import { } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('@/lib/csrfToken', () => ({ + csrfFetch: (input: RequestInfo | URL, init?: RequestInit) => + fetch(input, init), +})); + const activeOrganizationMock = vi.hoisted(() => ({ organizationId: 'org-1', setActiveOrganizationId: vi.fn(), diff --git a/apps/client/app/dashboard/settings/page.tsx b/apps/client/app/dashboard/settings/page.tsx index dce954230..20ccfc04c 100644 --- a/apps/client/app/dashboard/settings/page.tsx +++ b/apps/client/app/dashboard/settings/page.tsx @@ -19,6 +19,7 @@ import { resolveActiveOrganizationId, setActiveOrganizationId, } from '@/lib/activeOrganization'; +import { csrfFetch } from '@/lib/csrfToken'; import { ActiveOrganizationMemberPicker } from '@/app/features/organization/components/ActiveOrganizationMemberPicker'; import { OrganizationAuthBadge } from '@/app/features/organization/components/OrganizationAuthBadge'; import type { @@ -107,7 +108,7 @@ const AUTH_STATES: AuthState[] = ['viewer', 'operator', 'builder', 'manager']; async function apiRequest(path: string, init?: RequestInit): Promise { const organizationId = getStoredActiveOrganizationId(); - const response = await fetch(`${API_BASE_URL}${path}`, { + const response = await csrfFetch(`${API_BASE_URL}${path}`, { credentials: 'include', ...init, headers: { @@ -285,7 +286,9 @@ export default function SettingsPage() { apiRequest('/llm/providers'), apiRequest('/llm/credentials'), apiRequest('/apps'), - org.is_manager ? knowledgeApi.getKnowledgeBases().catch(() => []) : [], + org.is_manager + ? knowledgeApi.getKnowledgeBases().catch(() => []) + : [], ]); setProviders(providerData); @@ -299,7 +302,8 @@ export default function SettingsPage() { ''; const firstKnowledgeBaseId = selectedKnowledgeBaseId || knowledgeData[0]?.id || ''; - const firstCredentialId = selectedCredentialId || credentialData[0]?.id || ''; + const firstCredentialId = + selectedCredentialId || credentialData[0]?.id || ''; setSelectedWorkflowId(firstWorkflowId); setSelectedKnowledgeBaseId(firstKnowledgeBaseId); setSelectedCredentialId(firstCredentialId); @@ -318,7 +322,9 @@ export default function SettingsPage() { const [memberData, teamData] = await Promise.all([ apiRequest(`/organizations/${org.id}/members`), - apiRequest(`/teams?organization_id=${org.id}&limit=100`), + apiRequest( + `/teams?organization_id=${org.id}&limit=100`, + ), ]); setOrganizationMembers(memberData); setTeams(teamData); @@ -337,7 +343,8 @@ export default function SettingsPage() { ); setTeamMembers(Object.fromEntries(teamMemberEntries)); setMemberForm((prev) => ({ - teamId: prev.teamId || teamData.find((team) => team.is_active)?.id || '', + teamId: + prev.teamId || teamData.find((team) => team.is_active)?.id || '', userId: prev.userId, })); setPermissionForm((prev) => ({ @@ -345,7 +352,8 @@ export default function SettingsPage() { granteeId: prev.granteeId || teamData.find((team) => team.is_active)?.id || - memberData.find((member) => member.membership_state === 'active')?.user_id || + memberData.find((member) => member.membership_state === 'active') + ?.user_id || '', })); @@ -370,7 +378,11 @@ export default function SettingsPage() { ), ]); } catch (err) { - setError(err instanceof Error ? err.message : '설정 데이터를 불러오지 못했습니다.'); + setError( + err instanceof Error + ? err.message + : '설정 데이터를 불러오지 못했습니다.', + ); } finally { setLoading(false); } @@ -484,7 +496,9 @@ export default function SettingsPage() { const handleRemoveMember = async (teamId: string, userId: string) => { if (!organization?.is_manager) return; - await apiRequest(`/teams/${teamId}/members/${userId}`, { method: 'DELETE' }); + await apiRequest(`/teams/${teamId}/members/${userId}`, { + method: 'DELETE', + }); const refreshed = await apiRequest( `/teams/${teamId}/members`, ); @@ -589,9 +603,7 @@ export default function SettingsPage() { )}

- {organization - ? organization.name - : 'Organization 확인 중'} + {organization ? organization.name : 'Organization 확인 중'}

- -
- {(teamMembers[team.id] || []).map((member) => ( - handleDeactivateTeam(team.id)} + disabled={!team.is_active} + className="rounded-md p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-gray-400" + title="팀 비활성화" > - {member.name} - +
+
+ {(teamMembers[team.id] || []).map((member) => ( + - - - - ))} + {member.name} + + + ))} +
- )) )} @@ -927,7 +943,9 @@ export default function SettingsPage() { )} diff --git a/apps/client/app/features/workflow/components/modals/TemplateWizardModal.tsx b/apps/client/app/features/workflow/components/modals/TemplateWizardModal.tsx index 688009a44..0549759f1 100644 --- a/apps/client/app/features/workflow/components/modals/TemplateWizardModal.tsx +++ b/apps/client/app/features/workflow/components/modals/TemplateWizardModal.tsx @@ -1,9 +1,19 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; -import { X, Sparkles, Copy, Check, Loader2, ArrowRight, Info, ChevronDown, Code } from 'lucide-react'; +import { + X, + Sparkles, + Copy, + Check, + Loader2, + ArrowRight, + Info, + ChevronDown, + Code, +} from 'lucide-react'; import { getStoredActiveOrganizationId } from '@/lib/activeOrganization'; - +import { csrfFetch } from '@/lib/csrfToken'; // 템플릿 타입 정의 type TemplateType = 'email' | 'message' | 'report' | 'custom'; @@ -12,16 +22,36 @@ interface TemplateWizardModalProps { isOpen: boolean; onClose: () => void; originalTemplate: string; - registeredVariables: string[]; // Template Node의 등록된 변수명 + registeredVariables: string[]; // Template Node의 등록된 변수명 onApply: (improvedTemplate: string) => void; organizationId?: string | null; } -const TEMPLATE_TYPE_OPTIONS: { value: TemplateType; label: string; description: string }[] = [ - { value: 'email', label: '이메일/알림', description: '이메일, 뉴스레터, 알림 템플릿' }, - { value: 'message', label: '챗봇/메시지', description: '챗봇 응답, 알림 메시지' }, - { value: 'report', label: '보고서/문서', description: '보고서, 문서, 마크다운' }, - { value: 'custom', label: '직접 설명', description: '원하는 개선 방향을 직접 설명' }, +const TEMPLATE_TYPE_OPTIONS: { + value: TemplateType; + label: string; + description: string; +}[] = [ + { + value: 'email', + label: '이메일/알림', + description: '이메일, 뉴스레터, 알림 템플릿', + }, + { + value: 'message', + label: '챗봇/메시지', + description: '챗봇 응답, 알림 메시지', + }, + { + value: 'report', + label: '보고서/문서', + description: '보고서, 문서, 마크다운', + }, + { + value: 'custom', + label: '직접 설명', + description: '원하는 개선 방향을 직접 설명', + }, ]; export function TemplateWizardModal({ @@ -32,7 +62,6 @@ export function TemplateWizardModal({ onApply, organizationId, }: TemplateWizardModalProps) { - // 상태 관리 const [currentTemplate, setCurrentTemplate] = useState(originalTemplate); const [improvedTemplate, setImprovedTemplate] = useState(''); @@ -63,10 +92,13 @@ export function TemplateWizardModal({ const query = resolvedOrganizationId ? `?organization_id=${encodeURIComponent(resolvedOrganizationId)}` : ''; - const res = await fetch(`/api/v1/template-wizard/check-credentials${query}`, { - method: 'GET', - credentials: 'include', - }); + const res = await fetch( + `/api/v1/template-wizard/check-credentials${query}`, + { + method: 'GET', + credentials: 'include', + }, + ); if (res.ok) { const data = await res.json(); setHasCredentials(data.has_credentials); @@ -97,7 +129,9 @@ export function TemplateWizardModal({ } if (isOrganizationScopePending) { - setError('워크플로우 조직 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요.'); + setError( + '워크플로우 조직 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요.', + ); return; } @@ -107,7 +141,7 @@ export function TemplateWizardModal({ try { const resolvedOrganizationId = getWizardOrganizationId(); - const res = await fetch('/api/v1/template-wizard/improve', { + const res = await csrfFetch('/api/v1/template-wizard/improve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', @@ -115,14 +149,18 @@ export function TemplateWizardModal({ template_type: templateType, original_template: currentTemplate, registered_variables: registeredVariables, - custom_instructions: templateType === 'custom' ? customInstructions : null, + custom_instructions: + templateType === 'custom' ? customInstructions : null, organization_id: resolvedOrganizationId ?? undefined, }), }); if (!res.ok) { const errorData = await res.json(); - const message = errorData.detail?.message || errorData.detail || '템플릿 개선에 실패했습니다.'; + const message = + errorData.detail?.message || + errorData.detail || + '템플릿 개선에 실패했습니다.'; throw new Error(message); } @@ -164,7 +202,9 @@ export function TemplateWizardModal({ if (!isOpen) return null; - const selectedType = TEMPLATE_TYPE_OPTIONS.find(t => t.value === templateType); + const selectedType = TEMPLATE_TYPE_OPTIONS.find( + (t) => t.value === templateType, + ); return (
-

- 템플릿 마법사 -

-

- Jinja2 템플릿 개선 -

+

템플릿 마법사

+

Jinja2 템플릿 개선

- + {showTypeDropdown && (
{TEMPLATE_TYPE_OPTIONS.map((option) => ( @@ -237,15 +280,19 @@ export function TemplateWizardModal({ templateType === option.value ? 'bg-pink-50' : '' }`} > -
{option.label}
-
{option.description}
+
+ {option.label} +
+
+ {option.description} +
))}
)} - + {/* custom 타입일 때 추가 설명 입력 */} {templateType === 'custom' && (
@@ -293,7 +340,7 @@ export function TemplateWizardModal({
)} - + {/* 왼쪽 하단 버튼 영역 */}
{hasCredentials === false ? ( @@ -308,7 +355,11 @@ export function TemplateWizardModal({
)} diff --git a/apps/client/app/features/workflow/components/modals/WizardModalOrganization.test.tsx b/apps/client/app/features/workflow/components/modals/WizardModalOrganization.test.tsx index 48951b7b2..a82171649 100644 --- a/apps/client/app/features/workflow/components/modals/WizardModalOrganization.test.tsx +++ b/apps/client/app/features/workflow/components/modals/WizardModalOrganization.test.tsx @@ -1,6 +1,17 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +vi.mock('@/lib/csrfToken', () => ({ + csrfFetch: (input: RequestInfo | URL, init?: RequestInit) => + fetch(input, init), +})); + import { CodeWizardModal } from './CodeWizardModal'; import { PromptWizardModal } from './PromptWizardModal'; import { TemplateWizardModal } from './TemplateWizardModal'; diff --git a/apps/client/app/stream-api/workflows/[workflowId]/route.test.ts b/apps/client/app/stream-api/workflows/[workflowId]/route.test.ts index d60af70f8..8a36cb50d 100644 --- a/apps/client/app/stream-api/workflows/[workflowId]/route.test.ts +++ b/apps/client/app/stream-api/workflows/[workflowId]/route.test.ts @@ -40,7 +40,10 @@ describe('workflow stream proxy route', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - Cookie: 'auth_token=session', + Cookie: 'auth_token=session; csrf_token=stale', + Origin: 'http://localhost:3000', + 'Sec-Fetch-Site': 'same-origin', + 'X-CSRF-Token': 'v1.123.nonce.signature', 'X-Organization-Id': 'org-1', 'X-Request-Id': 'request-1', 'X-Correlation-Id': 'corr-1', @@ -58,7 +61,12 @@ describe('workflow stream proxy route', () => { const init = getFetchInit(fetchMock); const headers = new Headers(init.headers); expect(headers.get('Content-Type')).toBe('application/json'); - expect(headers.get('Cookie')).toBe('auth_token=session'); + expect(headers.get('Cookie')).toBe( + 'auth_token=session; csrf_token=v1.123.nonce.signature', + ); + expect(headers.get('Origin')).toBe('http://localhost:3000'); + expect(headers.get('Sec-Fetch-Site')).toBe('same-origin'); + expect(headers.get('X-CSRF-Token')).toBe('v1.123.nonce.signature'); expect(headers.get('X-Organization-Id')).toBe('org-1'); expect(headers.get('X-Request-Id')).toBe('request-1'); expect(headers.get('X-Correlation-Id')).toBe('corr-1'); diff --git a/apps/client/app/stream-api/workflows/[workflowId]/route.ts b/apps/client/app/stream-api/workflows/[workflowId]/route.ts index 48a3e3126..95fc5919f 100644 --- a/apps/client/app/stream-api/workflows/[workflowId]/route.ts +++ b/apps/client/app/stream-api/workflows/[workflowId]/route.ts @@ -1,10 +1,22 @@ import { NextRequest } from 'next/server'; const CONTEXT_HEADER_ALLOWLIST = [ + 'Origin', + 'Sec-Fetch-Site', + 'X-CSRF-Token', 'X-Organization-Id', 'X-Request-Id', 'X-Correlation-Id', ]; +const CSRF_TOKEN_PATTERN = /^[A-Za-z0-9._-]{1,256}$/; + +const withoutCsrfCookie = (cookieHeader: string) => + cookieHeader + .split(';') + .map((cookie) => cookie.trim()) + .filter((cookie) => !cookie.toLowerCase().startsWith('csrf_token=')) + .filter(Boolean) + .join('; '); const normalizeBackendUrl = (url: string) => url.replace(/\/+$/, '').replace(/\/api\/v1$/i, ''); @@ -53,9 +65,16 @@ export async function POST( } const headers = new Headers(); - const cookie = request.headers.get('cookie'); - if (cookie) { - headers.set('Cookie', cookie); + const cookie = request.headers.get('cookie') || ''; + const csrfToken = request.headers.get('X-CSRF-Token'); + const forwardedCookie = withoutCsrfCookie(cookie); + if (csrfToken && CSRF_TOKEN_PATTERN.test(csrfToken)) { + headers.set( + 'Cookie', + [forwardedCookie, `csrf_token=${csrfToken}`].filter(Boolean).join('; '), + ); + } else if (forwardedCookie) { + headers.set('Cookie', forwardedCookie); } if (!isFormData) { headers.set('Content-Type', 'application/json'); diff --git a/apps/client/lib/apiClient.ts b/apps/client/lib/apiClient.ts index a381f2ad5..85d6f71fb 100644 --- a/apps/client/lib/apiClient.ts +++ b/apps/client/lib/apiClient.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import { attachActiveOrganizationHeader } from './activeOrganization'; +import { attachCsrfProtection } from './csrfToken'; import { claimLoginRedirectPath, getCurrentAuthReturnPath } from './authReturn'; import { resolvePublicApiBaseUrl } from './publicApiOrigin'; @@ -42,6 +43,10 @@ export const publicApiClient = createApiClient(); export const apiClient = createApiClient(); +// Axios request interceptors run last-in-first-out. Register CSRF first so the +// active organization header exists before the scoped token is bootstrapped. +attachCsrfProtection(publicApiClient); +attachCsrfProtection(apiClient); attachActiveOrganizationHeader(apiClient); attachAuthRedirectInterceptor(publicApiClient); diff --git a/apps/client/lib/csrfToken.test.ts b/apps/client/lib/csrfToken.test.ts new file mode 100644 index 000000000..0cd8231ba --- /dev/null +++ b/apps/client/lib/csrfToken.test.ts @@ -0,0 +1,227 @@ +import axios, { + AxiosHeaders, + type AxiosAdapter, + type AxiosResponse, + type InternalAxiosRequestConfig, +} from 'axios'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + attachCsrfProtection, + csrfFetch, + getCsrfToken, + invalidateCsrfToken, +} from './csrfToken'; + +const csrfResponse = (token = 'csrf-token', expiresInMs = 600_000) => + new Response( + JSON.stringify({ + token, + expires_at: new Date(Date.now() + expiresInMs).toISOString(), + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + +beforeEach(() => { + invalidateCsrfToken(); + window.localStorage.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('getCsrfToken', () => { + it('single-flights concurrent bootstrap and stores the token only in memory', async () => { + const storageSpy = vi.spyOn(Storage.prototype, 'setItem'); + let resolveFetch: ((response: Response) => void) | undefined; + const fetchMock = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const first = getCsrfToken('organization-a'); + const second = getCsrfToken('organization-a'); + resolveFetch?.(csrfResponse()); + + await expect(Promise.all([first, second])).resolves.toEqual([ + 'csrf-token', + 'csrf-token', + ]); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(storageSpy).not.toHaveBeenCalled(); + }); + + it('uses a new bootstrap after active organization scope changes', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(csrfResponse('organization-a-token')) + .mockResolvedValueOnce(csrfResponse('organization-b-token')); + vi.stubGlobal('fetch', fetchMock); + + await expect(getCsrfToken('organization-a')).resolves.toBe( + 'organization-a-token', + ); + await expect(getCsrfToken('organization-b')).resolves.toBe( + 'organization-b-token', + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const secondHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers); + expect(secondHeaders.get('X-Organization-Id')).toBe('organization-b'); + }); + + it('retries bootstrap once after the Gateway clears an invalid auth cookie', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('{}', { status: 401 })) + .mockResolvedValueOnce(csrfResponse('anonymous-recovery-token')); + vi.stubGlobal('fetch', fetchMock); + + await expect(getCsrfToken(null)).resolves.toBe('anonymous-recovery-token'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it('rejects malformed bootstrap responses without persisting raw values', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{"token":42}', { status: 200 })), + ); + + await expect(getCsrfToken(null)).rejects.toThrow( + 'CSRF token bootstrap failed', + ); + expect(window.localStorage.length).toBe(0); + }); +}); + +describe('attachCsrfProtection', () => { + const success = ( + config: InternalAxiosRequestConfig, + ): AxiosResponse> => ({ + data: { ok: true }, + status: 200, + statusText: 'OK', + headers: {}, + config, + }); + + it('attaches a token to unsafe requests after organization headers resolve', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => csrfResponse('scoped-token')), + ); + const seen: InternalAxiosRequestConfig[] = []; + const adapter: AxiosAdapter = async (config) => { + seen.push(config); + return success(config); + }; + const client = axios.create({ adapter, withCredentials: true }); + attachCsrfProtection(client); + client.interceptors.request.use((config) => { + const headers = AxiosHeaders.from(config.headers); + headers.set('X-Organization-Id', 'organization-a'); + config.headers = headers; + return config; + }); + + await client.post('/protected', { value: 1 }); + + expect(AxiosHeaders.from(seen[0]?.headers).get('X-CSRF-Token')).toBe( + 'scoped-token', + ); + }); + + it('refreshes at most once for replay-safe requests', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(csrfResponse('first-token')) + .mockResolvedValueOnce(csrfResponse('second-token')), + ); + let attempts = 0; + const adapter: AxiosAdapter = async (config) => { + attempts += 1; + if (attempts === 1) { + return Promise.reject({ + isAxiosError: true, + config, + response: { + status: 403, + data: { + error: { code: 'auth.csrf_validation_failed' }, + }, + }, + }); + } + return success(config); + }; + const client = axios.create({ adapter, withCredentials: true }); + attachCsrfProtection(client); + + await expect(client.put('/protected', { value: 1 })).resolves.toMatchObject( + { + status: 200, + }, + ); + expect(attempts).toBe(2); + }); + + it('does not automatically replay non-idempotent requests', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => csrfResponse()), + ); + let attempts = 0; + const adapter: AxiosAdapter = async (config) => { + attempts += 1; + return Promise.reject({ + isAxiosError: true, + config, + response: { + status: 403, + data: { + error: { code: 'auth.csrf_validation_failed' }, + }, + }, + }); + }; + const client = axios.create({ adapter, withCredentials: true }); + attachCsrfProtection(client); + + await expect(client.post('/protected', { value: 1 })).rejects.toBeTruthy(); + expect(attempts).toBe(1); + }); +}); + +describe('csrfFetch', () => { + it('attaches the scoped token to a protected direct fetch', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(csrfResponse('direct-fetch-token')) + .mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await csrfFetch('/api/v1/protected', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Organization-Id': 'organization-a', + }, + body: '{"value":1}', + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const requestHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers); + expect(requestHeaders.get('X-CSRF-Token')).toBe('direct-fetch-token'); + expect(requestHeaders.get('X-Organization-Id')).toBe('organization-a'); + expect(fetchMock.mock.calls[1]?.[1]?.credentials).toBe('include'); + }); +}); diff --git a/apps/client/lib/csrfToken.ts b/apps/client/lib/csrfToken.ts new file mode 100644 index 000000000..dc853c59d --- /dev/null +++ b/apps/client/lib/csrfToken.ts @@ -0,0 +1,232 @@ +import { + AxiosHeaders, + type AxiosError, + type AxiosInstance, + type InternalAxiosRequestConfig, +} from 'axios'; + +import { + ACTIVE_ORGANIZATION_CHANGED_EVENT, + getStoredActiveOrganizationId, +} from './activeOrganization'; +import { resolvePublicApiBaseUrl } from './publicApiOrigin'; + +const CSRF_HEADER_NAME = 'X-CSRF-Token'; +const ORGANIZATION_HEADER_NAME = 'X-Organization-Id'; +const CSRF_FAILURE_CODE = 'auth.csrf_validation_failed'; +const EXPIRY_SKEW_MS = 30_000; +const MAX_TOKEN_LENGTH = 256; +const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); +const REPLAY_SAFE_METHODS = new Set(['PUT', 'DELETE']); + +const apiBaseUrl = resolvePublicApiBaseUrl( + process.env.NEXT_PUBLIC_API_URL, + process.env.NODE_ENV, +); + +type CachedToken = { + token: string; + expiresAtMs: number; + scope: string; +}; + +type RetryableRequestConfig = InternalAxiosRequestConfig & { + _csrfRetried?: boolean; +}; + +let cachedToken: CachedToken | null = null; +const inFlightByScope = new Map>(); + +const normalizeScope = (organizationId?: string | null) => + organizationId?.trim() || ''; + +const isUnsafeMethod = (method?: string) => + UNSAFE_METHODS.has((method || 'GET').toUpperCase()); + +const isFixedCsrfFailure = (error: AxiosError) => { + const data = error.response?.data; + if (typeof data !== 'object' || data === null || !('error' in data)) { + return false; + } + const envelope = (data as { error?: unknown }).error; + return ( + typeof envelope === 'object' && + envelope !== null && + 'code' in envelope && + (envelope as { code?: unknown }).code === CSRF_FAILURE_CODE + ); +}; + +const isReplaySafe = (config: InternalAxiosRequestConfig) => { + const method = (config.method || 'GET').toUpperCase(); + if (REPLAY_SAFE_METHODS.has(method)) return true; + const headers = AxiosHeaders.from(config.headers); + return headers.has('Idempotency-Key') || headers.has('X-Idempotency-Key'); +}; + +const parseBootstrapResponse = async ( + response: Response, + scope: string, +): Promise => { + if (!response.ok) throw new Error('CSRF token bootstrap failed'); + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new Error('CSRF token bootstrap failed'); + } + if ( + typeof payload !== 'object' || + payload === null || + !('token' in payload) || + !('expires_at' in payload) + ) { + throw new Error('CSRF token bootstrap failed'); + } + const token = (payload as { token?: unknown }).token; + const expiresAt = (payload as { expires_at?: unknown }).expires_at; + const expiresAtMs = + typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN; + if ( + typeof token !== 'string' || + token.length === 0 || + token.length > MAX_TOKEN_LENGTH || + !Number.isFinite(expiresAtMs) || + expiresAtMs <= Date.now() + EXPIRY_SKEW_MS + ) { + throw new Error('CSRF token bootstrap failed'); + } + return { token, expiresAtMs, scope }; +}; + +export const invalidateCsrfToken = () => { + cachedToken = null; + inFlightByScope.clear(); +}; + +export const getCsrfToken = async ( + organizationId?: string | null, +): Promise => { + const scope = normalizeScope(organizationId); + if ( + cachedToken?.scope === scope && + cachedToken.expiresAtMs > Date.now() + EXPIRY_SKEW_MS + ) { + return cachedToken.token; + } + + const existing = inFlightByScope.get(scope); + if (existing) return existing; + + const bootstrap = (async () => { + const headers = new Headers({ Accept: 'application/json' }); + if (scope) headers.set(ORGANIZATION_HEADER_NAME, scope); + const requestToken = () => + fetch(`${apiBaseUrl}/auth/csrf`, { + method: 'GET', + headers, + credentials: 'include', + cache: 'no-store', + }); + let response = await requestToken(); + // The Gateway clears an invalid HttpOnly auth cookie on the first 401. + // Retry once so the browser can establish an anonymous pre-auth binding. + if (response.status === 401) response = await requestToken(); + const parsed = await parseBootstrapResponse(response, scope); + cachedToken = parsed; + return parsed.token; + })().finally(() => { + inFlightByScope.delete(scope); + }); + + inFlightByScope.set(scope, bootstrap); + return bootstrap; +}; + +export const attachCsrfProtection = (client: AxiosInstance) => { + client.interceptors.request.use(async (config) => { + if (!isUnsafeMethod(config.method)) return config; + const headers = AxiosHeaders.from(config.headers); + const organizationId = headers.get(ORGANIZATION_HEADER_NAME); + const token = await getCsrfToken( + typeof organizationId === 'string' ? organizationId : null, + ); + headers.set(CSRF_HEADER_NAME, token); + config.headers = headers; + return config; + }); + + client.interceptors.response.use( + (response) => response, + async (rawError: unknown) => { + const error = rawError as AxiosError; + if (!isFixedCsrfFailure(error)) return Promise.reject(rawError); + + invalidateCsrfToken(); + const config = error.config as RetryableRequestConfig | undefined; + if (!config || config._csrfRetried || !isReplaySafe(config)) { + return Promise.reject(rawError); + } + config._csrfRetried = true; + return client.request(config); + }, + ); +}; + +const responseHasFixedCsrfFailure = async (response: Response) => { + if (response.status !== 403) return false; + try { + const payload = await response.clone().json(); + return payload?.error?.code === CSRF_FAILURE_CODE; + } catch { + return false; + } +}; + +const fetchReplaySafe = (method: string, headers: Headers) => + REPLAY_SAFE_METHODS.has(method) || + headers.has('Idempotency-Key') || + headers.has('X-Idempotency-Key'); + +export const csrfFetch = async ( + input: RequestInfo | URL, + init: RequestInit = {}, +): Promise => { + const method = (init.method || 'GET').toUpperCase(); + if (!UNSAFE_METHODS.has(method)) return fetch(input, init); + + const send = async () => { + const headers = new Headers(init.headers); + const organizationId = + headers.get(ORGANIZATION_HEADER_NAME) ?? getStoredActiveOrganizationId(); + if (organizationId && !headers.has(ORGANIZATION_HEADER_NAME)) { + headers.set(ORGANIZATION_HEADER_NAME, organizationId); + } + headers.set(CSRF_HEADER_NAME, await getCsrfToken(organizationId)); + return fetch(input, { + ...init, + method, + headers, + credentials: init.credentials ?? 'include', + }); + }; + + const firstResponse = await send(); + if (!(await responseHasFixedCsrfFailure(firstResponse))) { + return firstResponse; + } + + invalidateCsrfToken(); + const headers = new Headers(init.headers); + if (!fetchReplaySafe(method, headers)) return firstResponse; + return send(); +}; + +if (typeof window !== 'undefined') { + if (ACTIVE_ORGANIZATION_CHANGED_EVENT) { + window.addEventListener( + ACTIVE_ORGANIZATION_CHANGED_EVENT, + invalidateCsrfToken, + ); + } +} diff --git a/apps/gateway/adapters/csrf/__init__.py b/apps/gateway/adapters/csrf/__init__.py new file mode 100644 index 000000000..6e7f5fa5a --- /dev/null +++ b/apps/gateway/adapters/csrf/__init__.py @@ -0,0 +1 @@ +"""CSRF outer adapters.""" diff --git a/apps/gateway/adapters/csrf/observability.py b/apps/gateway/adapters/csrf/observability.py new file mode 100644 index 000000000..1dc4a0e7f --- /dev/null +++ b/apps/gateway/adapters/csrf/observability.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import logging +from collections import Counter +from typing import ClassVar + +logger = logging.getLogger(__name__) + +try: + from prometheus_client import Counter as PrometheusCounter +except Exception: + PrometheusCounter = None + +try: + CSRF_DENIALS = ( + PrometheusCounter( + "auth_csrf_denials_total", + "Rejected cookie-authenticated browser mutations.", + ["reason", "policy", "method"], + ) + if PrometheusCounter + else None + ) +except ValueError: + CSRF_DENIALS = None + + +class CsrfObservability: + _REASONS = frozenset( + { + "token_missing", + "token_mismatch", + "token_invalid", + "token_expired", + "origin_invalid", + "fetch_metadata_invalid", + "content_type_invalid", + } + ) + _POLICIES = frozenset({"cookie_authenticated", "pre_auth_session"}) + _METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + _local_counters: ClassVar[Counter[tuple[str, str, str]]] = Counter() + + @classmethod + def record(cls, *, reason: str, policy: str, method: str) -> None: + safe_reason = reason if reason in cls._REASONS else "unknown" + safe_policy = policy if policy in cls._POLICIES else "unknown" + safe_method = method if method in cls._METHODS else "unknown" + labels = (safe_reason, safe_policy, safe_method) + cls._local_counters[labels] += 1 + if CSRF_DENIALS is not None: + CSRF_DENIALS.labels( + reason=safe_reason, + policy=safe_policy, + method=safe_method, + ).inc() + logger.info( + "auth.csrf_denied", + extra={ + "event": "auth.csrf_denied", + "reason": safe_reason, + "policy": safe_policy, + "method": safe_method, + }, + ) diff --git a/apps/gateway/api/v1/endpoints/auth.py b/apps/gateway/api/v1/endpoints/auth.py index 52d5057af..9cc9c2df3 100644 --- a/apps/gateway/api/v1/endpoints/auth.py +++ b/apps/gateway/api/v1/endpoints/auth.py @@ -1,5 +1,7 @@ import logging import os +import re +import secrets from collections.abc import Mapping from urllib.parse import urlsplit @@ -15,13 +17,21 @@ PasswordLoginInternalError, ) from apps.gateway.application.authentication.models import PasswordLoginCommand +from apps.gateway.application.csrf.token import ( + CSRF_ANON_COOKIE_NAME, + CSRF_COOKIE_NAME, + CSRF_ORGANIZATION_HEADER_NAME, + CsrfBindingKind, +) from apps.gateway.auth.oauth import oauth from apps.gateway.composition.authentication import ( build_password_login, login_network_resolver, ) +from apps.gateway.composition.csrf import csrf_token_service from apps.gateway.services.auth_return_service import AuthReturnService from apps.gateway.services.auth_service import AuthService +from apps.gateway.utils.api_errors import error_response from apps.shared.audit import record_audit from apps.shared.audit.actions import AuditAction from apps.shared.audit.context import get_current_metadata @@ -33,9 +43,11 @@ SignupRequest, UserResponse, ) +from apps.shared.schemas.csrf import CsrfTokenResponse router = APIRouter() logger = logging.getLogger(__name__) +_ANONYMOUS_SEED_PATTERN = re.compile(r"^[A-Za-z0-9_-]{43}$") def _request_hostname(request: Request) -> str: @@ -148,6 +160,139 @@ def _get_cookie_config(request: Request) -> tuple[bool, str | None]: return is_production, cookie_domain +def _csrf_cookie_options(request: Request) -> dict[str, object]: + is_production, _ = _get_cookie_config(request) + return { + "httponly": True, + "secure": is_production, + "samesite": "none" if is_production else "lax", + "path": "/api/v1", + "max_age": 600, + } + + +def _set_csrf_cookie( + response: Response, + request: Request, + *, + key: str, + value: str, +) -> None: + response.set_cookie( + key=key, + value=value, + **_csrf_cookie_options(request), + ) + + +def _clear_csrf_cookie_family(request: Request, response: Response) -> None: + options = _csrf_cookie_options(request) + for key in (CSRF_COOKIE_NAME, CSRF_ANON_COOKIE_NAME): + response.delete_cookie( + key=key, + path=str(options["path"]), + secure=bool(options["secure"]), + httponly=True, + samesite=str(options["samesite"]), + ) + + +@router.get("/csrf", response_model=CsrfTokenResponse) +def bootstrap_csrf_token( + request: Request, + response: Response, + db: Session = Depends(get_db), +): + auth_cookie = request.cookies.get("auth_token") + anonymous_seed: str | None = None + if auth_cookie: + # Invalid authentication must never downgrade to an anonymous binding. + try: + AuthService.get_user_from_token(db, auth_cookie) + except HTTPException as exc: + if exc.status_code != 401: + raise + record_audit( + action=AuditAction.AUTH_PERMISSION_DENIED, + category="action", + actor_type="system", + status="failure", + metadata={ + "reason": "auth.csrf_bootstrap_invalid_session", + **_request_meta(request), + }, + ) + invalid_response = error_response( + request, + 401, + "auth.invalid", + "Authentication is invalid.", + ) + _, cookie_domain = _get_cookie_config(request) + invalid_response.delete_cookie( + key="auth_token", + path="/", + domain=cookie_domain, + ) + _clear_csrf_cookie_family(request, invalid_response) + invalid_response.headers["Cache-Control"] = "no-store" + invalid_response.headers["Pragma"] = "no-cache" + return invalid_response + binding_kind = CsrfBindingKind.AUTHENTICATED + binding_secret = auth_cookie + else: + candidate = request.cookies.get(CSRF_ANON_COOKIE_NAME) + anonymous_seed = ( + candidate + if candidate and _ANONYMOUS_SEED_PATTERN.fullmatch(candidate) + else secrets.token_urlsafe(32) + ) + binding_kind = CsrfBindingKind.PRE_AUTH + binding_secret = anonymous_seed + + try: + issued = csrf_token_service().issue( + binding_kind=binding_kind, + binding_secret=binding_secret, + organization_scope=request.headers.get(CSRF_ORGANIZATION_HEADER_NAME), + ) + except ValueError: + raise HTTPException( + status_code=400, + detail="Invalid CSRF request context", + ) from None + + _set_csrf_cookie( + response, + request, + key=CSRF_COOKIE_NAME, + value=issued.token, + ) + if anonymous_seed is None: + options = _csrf_cookie_options(request) + response.delete_cookie( + key=CSRF_ANON_COOKIE_NAME, + path=str(options["path"]), + secure=bool(options["secure"]), + httponly=True, + samesite=str(options["samesite"]), + ) + else: + _set_csrf_cookie( + response, + request, + key=CSRF_ANON_COOKIE_NAME, + value=anonymous_seed, + ) + + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + return CsrfTokenResponse( + token=issued.token, + expires_at=issued.expires_at, + ) + + @router.post("/signup", response_model=LoginResponse) def signup( request_obj: Request, @@ -170,7 +315,9 @@ def signup( try: result = AuthService.signup(db, request) except Exception as e: - _record_auth_failure(AuditAction.USER_SIGNUP_FAILED, request_obj, request.email, e) + _record_auth_failure( + AuditAction.USER_SIGNUP_FAILED, request_obj, request.email, e + ) raise _record_auth_success(AuditAction.USER_SIGNUP, request_obj, result.user) @@ -194,6 +341,7 @@ def signup( else: cookie_params["secure"] = False + _clear_csrf_cookie_family(request_obj, response) response.set_cookie(**cookie_params) return result @@ -288,6 +436,7 @@ def login( else: cookie_params["secure"] = False + _clear_csrf_cookie_family(request_obj, response) response.set_cookie(**cookie_params) return response_result @@ -305,6 +454,7 @@ def logout(request_obj: Request, response: Response): delete_params["domain"] = cookie_domain response.delete_cookie(**delete_params) + _clear_csrf_cookie_family(request_obj, response) # 로그아웃은 actor를 시그니처에서 알 수 없어(쿠키 삭제 시점) actor_id 없이 기록한다. record_audit( @@ -467,6 +617,7 @@ async def auth_google_callback( redirect_url = AuthReturnService.build_client_redirect(request, return_path) redirect_response = RedirectResponse(url=redirect_url, status_code=302) + _clear_csrf_cookie_family(request, redirect_response) redirect_response.set_cookie( key="auth_token", value=access_token, diff --git a/apps/gateway/application/csrf/__init__.py b/apps/gateway/application/csrf/__init__.py new file mode 100644 index 000000000..210f4306c --- /dev/null +++ b/apps/gateway/application/csrf/__init__.py @@ -0,0 +1 @@ +"""Cookie-authenticated browser request CSRF policy.""" diff --git a/apps/gateway/application/csrf/models.py b/apps/gateway/application/csrf/models.py new file mode 100644 index 000000000..8d2552988 --- /dev/null +++ b/apps/gateway/application/csrf/models.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from re import Pattern + + +class CsrfRoutePolicyKind(str, Enum): + COOKIE_AUTHENTICATED = "cookie_authenticated" + PRE_AUTH_SESSION = "pre_auth_session" + PUBLIC_ANONYMOUS = "public_anonymous" + SERVER_CREDENTIAL = "server_credential" + OAUTH_STATE = "oauth_state" + + +class CsrfContentKind(str, Enum): + JSON = "json" + BODY_OPTIONAL = "body_optional" + MULTIPART = "multipart" + UNRESTRICTED = "unrestricted" + + +@dataclass(frozen=True) +class CsrfRoutePolicy: + method: str + path_template: str + path_pattern: Pattern[str] + policy_kind: CsrfRoutePolicyKind + content_kind: CsrfContentKind + + @property + def key(self) -> tuple[str, str]: + return (self.method, self.path_template) + + @property + def requires_csrf(self) -> bool: + return self.policy_kind in { + CsrfRoutePolicyKind.COOKIE_AUTHENTICATED, + CsrfRoutePolicyKind.PRE_AUTH_SESSION, + } + + +class CsrfRoutePolicyRegistry: + def __init__(self, policies: tuple[CsrfRoutePolicy, ...]): + by_key: dict[tuple[str, str], CsrfRoutePolicy] = {} + for policy in policies: + normalized_key = (policy.method.upper(), policy.path_template) + if normalized_key in by_key: + raise ValueError(f"Duplicate CSRF route policy: {normalized_key!r}") + by_key[normalized_key] = policy + self._policies = tuple(policies) + self._by_key = by_key + + @property + def policies(self) -> tuple[CsrfRoutePolicy, ...]: + return self._policies + + def by_key(self, method: str, path_template: str) -> CsrfRoutePolicy: + return self._by_key[(method.upper(), path_template)] + + def match(self, method: str, path: str) -> CsrfRoutePolicy | None: + normalized_method = method.upper() + for policy in self._policies: + if ( + policy.method == normalized_method + and policy.path_pattern.fullmatch(path) is not None + ): + return policy + return None diff --git a/apps/gateway/application/csrf/token.py b/apps/gateway/application/csrf/token.py new file mode 100644 index 000000000..406a74b19 --- /dev/null +++ b/apps/gateway/application/csrf/token.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import secrets +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from enum import Enum + + +CSRF_COOKIE_NAME = "csrf_token" +CSRF_ANON_COOKIE_NAME = "csrf_anon_seed" +CSRF_HEADER_NAME = "X-CSRF-Token" +CSRF_ORGANIZATION_HEADER_NAME = "X-Organization-Id" +CSRF_TOKEN_VERSION = "v1" +CSRF_TOKEN_TTL_SECONDS = 600 +_CLOCK_SKEW_SECONDS = 30 +_NONCE_BYTES = 32 +_MAX_TOKEN_LENGTH = 256 +_ACCOUNT_SCOPE = b"account" + + +class CsrfBindingKind(str, Enum): + AUTHENTICATED = "authenticated" + PRE_AUTH = "pre_auth" + + +class CsrfValidationReason(str, Enum): + TOKEN_MISSING = "token_missing" + TOKEN_MISMATCH = "token_mismatch" + TOKEN_INVALID = "token_invalid" + TOKEN_EXPIRED = "token_expired" + ORIGIN_INVALID = "origin_invalid" + FETCH_METADATA_INVALID = "fetch_metadata_invalid" + CONTENT_TYPE_INVALID = "content_type_invalid" + + +@dataclass(frozen=True) +class IssuedCsrfToken: + token: str + expires_at: datetime + + +def _encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") + + +def _decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.b64decode( + f"{value}{padding}".encode("ascii"), + altchars=b"-_", + validate=True, + ) + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class CsrfTokenService: + def __init__( + self, + *, + signing_key: bytes, + binding_key: bytes, + scope_key: bytes, + ttl_seconds: int = CSRF_TOKEN_TTL_SECONDS, + nonce_factory: Callable[[int], bytes] = secrets.token_bytes, + ): + if len(signing_key) < 32 or len(binding_key) < 32 or len(scope_key) < 32: + raise ValueError("CSRF derived keys must be at least 32 bytes") + if not 60 <= ttl_seconds <= 3600: + raise ValueError("CSRF token TTL must be between 60 and 3600 seconds") + self._signing_key = signing_key + self._binding_key = binding_key + self._scope_key = scope_key + self._ttl_seconds = ttl_seconds + self._nonce_factory = nonce_factory + + @classmethod + def from_root_secret( + cls, + root_secret: str, + *, + ttl_seconds: int = CSRF_TOKEN_TTL_SECONDS, + nonce_factory: Callable[[int], bytes] = secrets.token_bytes, + ) -> "CsrfTokenService": + if not root_secret or not root_secret.strip(): + raise ValueError("CSRF root secret is not configured") + root_key = root_secret.encode("utf-8") + + def derive(context: bytes) -> bytes: + return hmac.new(root_key, context, hashlib.sha256).digest() + + return cls( + signing_key=derive(b"nodease.csrf.signing.v1"), + binding_key=derive(b"nodease.csrf.binding.v1"), + scope_key=derive(b"nodease.csrf.scope.v1"), + ttl_seconds=ttl_seconds, + nonce_factory=nonce_factory, + ) + + @property + def ttl_seconds(self) -> int: + return self._ttl_seconds + + def issue( + self, + *, + binding_kind: CsrfBindingKind, + binding_secret: str, + organization_scope: str | None, + now: datetime | None = None, + ) -> IssuedCsrfToken: + issued_at = self._normalize_now(now) + expires_at = issued_at + timedelta(seconds=self._ttl_seconds) + expiry = int(expires_at.timestamp()) + nonce = self._nonce_factory(_NONCE_BYTES) + if len(nonce) != _NONCE_BYTES: + raise ValueError("CSRF nonce factory returned an invalid length") + mac = self._mac( + expiry=expiry, + nonce=nonce, + binding_kind=binding_kind, + binding_secret=binding_secret, + organization_scope=organization_scope, + ) + token = ".".join( + ( + CSRF_TOKEN_VERSION, + str(expiry), + _encode(nonce), + _encode(mac), + ) + ) + return IssuedCsrfToken(token=token, expires_at=expires_at) + + def validate( + self, + *, + header_token: str | None, + cookie_token: str | None, + binding_kind: CsrfBindingKind, + binding_secret: str | None, + organization_scope: str | None, + now: datetime | None = None, + ) -> CsrfValidationReason | None: + if not header_token or not cookie_token: + return CsrfValidationReason.TOKEN_MISSING + if ( + len(header_token) > _MAX_TOKEN_LENGTH + or len(cookie_token) > _MAX_TOKEN_LENGTH + ): + return CsrfValidationReason.TOKEN_INVALID + if not hmac.compare_digest(header_token, cookie_token): + return CsrfValidationReason.TOKEN_MISMATCH + if not binding_secret: + return CsrfValidationReason.TOKEN_INVALID + + try: + version, raw_expiry, raw_nonce, raw_mac = header_token.split(".") + if version != CSRF_TOKEN_VERSION: + return CsrfValidationReason.TOKEN_INVALID + expiry = int(raw_expiry) + nonce = _decode(raw_nonce) + supplied_mac = _decode(raw_mac) + if ( + raw_expiry != str(expiry) + or _encode(nonce) != raw_nonce + or _encode(supplied_mac) != raw_mac + or len(nonce) != _NONCE_BYTES + or len(supplied_mac) != hashlib.sha256().digest_size + ): + return CsrfValidationReason.TOKEN_INVALID + except (binascii.Error, TypeError, ValueError, UnicodeError): + return CsrfValidationReason.TOKEN_INVALID + + current_timestamp = int(self._normalize_now(now).timestamp()) + if expiry <= current_timestamp: + return CsrfValidationReason.TOKEN_EXPIRED + if expiry > current_timestamp + self._ttl_seconds + _CLOCK_SKEW_SECONDS: + return CsrfValidationReason.TOKEN_INVALID + + try: + expected_mac = self._mac( + expiry=expiry, + nonce=nonce, + binding_kind=binding_kind, + binding_secret=binding_secret, + organization_scope=organization_scope, + ) + except ValueError: + return CsrfValidationReason.TOKEN_INVALID + if not hmac.compare_digest(supplied_mac, expected_mac): + return CsrfValidationReason.TOKEN_INVALID + return None + + def _mac( + self, + *, + expiry: int, + nonce: bytes, + binding_kind: CsrfBindingKind, + binding_secret: str, + organization_scope: str | None, + ) -> bytes: + if not binding_secret or len(binding_secret) > 8192: + raise ValueError("Invalid CSRF binding") + binding_digest = hmac.new( + self._binding_key, + binding_secret.encode("utf-8"), + hashlib.sha256, + ).digest() + scope = self._normalize_scope(organization_scope) + scope_digest = hmac.new(self._scope_key, scope, hashlib.sha256).digest() + message = b"\x00".join( + ( + CSRF_TOKEN_VERSION.encode("ascii"), + str(expiry).encode("ascii"), + nonce, + binding_kind.value.encode("ascii"), + binding_digest, + scope_digest, + ) + ) + return hmac.new(self._signing_key, message, hashlib.sha256).digest() + + @staticmethod + def _normalize_scope(organization_scope: str | None) -> bytes: + if organization_scope is None: + return _ACCOUNT_SCOPE + normalized = organization_scope.strip() + if not normalized: + return _ACCOUNT_SCOPE + if len(normalized) > 128 or any( + ord(character) < 32 for character in normalized + ): + raise ValueError("Invalid CSRF organization scope") + return normalized.encode("utf-8") + + @staticmethod + def _normalize_now(now: datetime | None) -> datetime: + value = now or _utc_now() + if value.tzinfo is None: + raise ValueError("CSRF time must be timezone-aware") + return value.astimezone(timezone.utc) diff --git a/apps/gateway/composition/csrf.py b/apps/gateway/composition/csrf.py new file mode 100644 index 000000000..14b4d342a --- /dev/null +++ b/apps/gateway/composition/csrf.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import os +from functools import lru_cache +from typing import Any + +from apps.gateway.adapters.csrf.observability import CsrfObservability +from apps.gateway.application.csrf.models import ( + CsrfContentKind, + CsrfRoutePolicy, + CsrfRoutePolicyKind, + CsrfRoutePolicyRegistry, +) +from apps.gateway.application.csrf.token import CsrfTokenService, CsrfValidationReason +from apps.gateway.auth.dependencies import get_current_user +from apps.gateway.core.http_security import resolve_session_signing_secret +from apps.shared.audit import record_audit +from apps.shared.audit.actions import AuditAction + + +_UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + +_PRE_AUTH_ROUTES = frozenset( + { + ("POST", "/api/v1/auth/signup"), + ("POST", "/api/v1/auth/login"), + ("POST", "/api/v1/auth/logout"), + } +) +_PUBLIC_ANONYMOUS_ROUTES = frozenset( + { + ("POST", "/api/v1/run-public/{url_slug}"), + ("POST", "/api/v1/run-public/{url_slug}/chat"), + } +) +_SERVER_CREDENTIAL_ROUTES = frozenset( + { + ("POST", "/api/v1/run/{url_slug}"), + ("POST", "/api/v1/hooks/{url_slug}"), + } +) +_LEGACY_COOKIE_AUTHENTICATED_ROUTES = frozenset( + { + ("POST", "/api/v1/teams"), + ("PATCH", "/api/v1/teams/{team_id}"), + ("POST", "/api/v1/teams/{team_id}/members"), + ("DELETE", "/api/v1/teams/{team_id}/members/{user_id}"), + ("DELETE", "/api/v1/teams/{team_id}"), + ("POST", "/api/v1/permissions/bulk-grants"), + ( + "PUT", + "/api/v1/permissions/workflows/{workflow_id}/teams/{team_id}", + ), + ( + "PUT", + "/api/v1/permissions/knowledge-bases/{knowledge_base_id}/teams/{team_id}", + ), + ( + "PUT", + "/api/v1/permissions/llm-credentials/{credential_id}/teams/{team_id}", + ), + ( + "PUT", + "/api/v1/permissions/workflows/{workflow_id}/users/{user_id}", + ), + ( + "PUT", + "/api/v1/permissions/knowledge-bases/{knowledge_base_id}/users/{user_id}", + ), + ( + "PUT", + "/api/v1/permissions/llm-credentials/{credential_id}/users/{user_id}", + ), + ( + "DELETE", + "/api/v1/permissions/workflows/{workflow_id}/teams/{team_id}", + ), + ( + "DELETE", + "/api/v1/permissions/knowledge-bases/{knowledge_base_id}/teams/{team_id}", + ), + ( + "DELETE", + "/api/v1/permissions/llm-credentials/{credential_id}/teams/{team_id}", + ), + ( + "DELETE", + "/api/v1/permissions/workflows/{workflow_id}/users/{user_id}", + ), + ( + "DELETE", + "/api/v1/permissions/knowledge-bases/{knowledge_base_id}/users/{user_id}", + ), + ( + "DELETE", + "/api/v1/permissions/llm-credentials/{credential_id}/users/{user_id}", + ), + } +) +_MULTIPART_COOKIE_ROUTES = frozenset( + { + ("POST", "/api/v1/rag/upload"), + ("POST", "/api/v1/workflows/{workflow_id}/stream"), + } +) +_OAUTH_STATE_ROUTES = frozenset( + { + ("GET", "/api/v1/auth/google/login"), + ("GET", "/api/v1/auth/google/callback"), + } +) + + +class CsrfRouteInventoryError(RuntimeError): + pass + + +def _effective_routes(app: Any): + for route in app.routes: + effective_route_contexts = getattr(route, "effective_route_contexts", None) + if callable(effective_route_contexts): + yield from effective_route_contexts() + continue + if ( + getattr(route, "methods", None) + and getattr(route, "path", None) + and getattr(route, "path_regex", None) + ): + yield route + + +def _dependency_calls(dependant: Any): + for child in getattr(dependant, "dependencies", ()): + yield getattr(child, "call", None) + yield from _dependency_calls(child) + + +def _has_cookie_auth_dependency(route: Any) -> bool: + return any( + dependency is get_current_user + for dependency in _dependency_calls(getattr(route, "dependant", None)) + ) + + +def _classify_route( + route: Any, + key: tuple[str, str], +) -> CsrfRoutePolicyKind | None: + if key in _PRE_AUTH_ROUTES: + return CsrfRoutePolicyKind.PRE_AUTH_SESSION + if key in _PUBLIC_ANONYMOUS_ROUTES: + return CsrfRoutePolicyKind.PUBLIC_ANONYMOUS + if key in _SERVER_CREDENTIAL_ROUTES: + return CsrfRoutePolicyKind.SERVER_CREDENTIAL + if key in _LEGACY_COOKIE_AUTHENTICATED_ROUTES or _has_cookie_auth_dependency(route): + return CsrfRoutePolicyKind.COOKIE_AUTHENTICATED + return None + + +def _content_kind( + route: Any, + key: tuple[str, str], + policy_kind: CsrfRoutePolicyKind, +) -> CsrfContentKind: + if policy_kind in { + CsrfRoutePolicyKind.PUBLIC_ANONYMOUS, + CsrfRoutePolicyKind.SERVER_CREDENTIAL, + }: + return CsrfContentKind.UNRESTRICTED + if key in _MULTIPART_COOKIE_ROUTES: + return CsrfContentKind.MULTIPART + if getattr(route, "body_field", None) is None: + return CsrfContentKind.BODY_OPTIONAL + media_type = getattr(route.body_field.field_info, "media_type", None) + if media_type not in {None, "application/json"}: + raise CsrfRouteInventoryError( + f"unsupported protected route media type: {key!r}" + ) + return CsrfContentKind.JSON + + +def build_csrf_route_policy_registry(app: Any) -> CsrfRoutePolicyRegistry: + policies: list[CsrfRoutePolicy] = [] + actual_unsafe_keys: set[tuple[str, str]] = set() + unclassified: list[tuple[str, str]] = [] + oauth_keys: set[tuple[str, str]] = set() + + for route in _effective_routes(app): + methods = {method.upper() for method in route.methods} + for method in methods: + key = (method, route.path) + if key in _OAUTH_STATE_ROUTES: + oauth_keys.add(key) + if method not in _UNSAFE_METHODS: + continue + if key in actual_unsafe_keys: + raise CsrfRouteInventoryError(f"duplicate unsafe route: {key!r}") + actual_unsafe_keys.add(key) + policy_kind = _classify_route(route, key) + if policy_kind is None: + unclassified.append(key) + continue + policies.append( + CsrfRoutePolicy( + method=method, + path_template=route.path, + path_pattern=route.path_regex, + policy_kind=policy_kind, + content_kind=_content_kind(route, key, policy_kind), + ) + ) + + if unclassified: + raise CsrfRouteInventoryError( + f"unclassified unsafe route: {sorted(unclassified)!r}" + ) + + configured_routes = ( + _PRE_AUTH_ROUTES + | _PUBLIC_ANONYMOUS_ROUTES + | _SERVER_CREDENTIAL_ROUTES + | _LEGACY_COOKIE_AUTHENTICATED_ROUTES + | _MULTIPART_COOKIE_ROUTES + ) + stale_routes = configured_routes - actual_unsafe_keys + if stale_routes: + raise CsrfRouteInventoryError( + f"configured CSRF route does not exist: {sorted(stale_routes)!r}" + ) + if oauth_keys != _OAUTH_STATE_ROUTES: + raise CsrfRouteInventoryError( + "OAuth state route inventory does not match the configured callbacks" + ) + return CsrfRoutePolicyRegistry(tuple(policies)) + + +@lru_cache(maxsize=1) +def csrf_token_service() -> CsrfTokenService: + secret = resolve_session_signing_secret( + os.getenv("SECRET_KEY"), + node_env=os.getenv("NODE_ENV"), + ) + return CsrfTokenService.from_root_secret(secret) + + +def csrf_enforcement_enabled() -> bool: + node_env = (os.getenv("NODE_ENV") or "").strip().lower() + configured_mode = (os.getenv("CSRF_ENFORCEMENT_MODE") or "").strip().lower() + if not configured_mode or configured_mode == "enforce": + return True + if configured_mode == "disabled" and node_env == "test": + return False + raise RuntimeError("CSRF enforcement mode is invalid") + + +def record_csrf_auth_required( + policy: CsrfRoutePolicy, + method: str, + request_id: str, +) -> None: + record_audit( + action=AuditAction.AUTH_PERMISSION_DENIED, + category="action", + actor_type="system", + status="failure", + metadata={ + "reason": "auth.required", + "policy": policy.policy_kind.value, + "method": method, + "request_id": request_id, + }, + ) + + +def record_csrf_denial( + reason: CsrfValidationReason, + policy: CsrfRoutePolicy, + method: str, + request_id: str, +) -> None: + CsrfObservability.record( + reason=reason.value, + policy=policy.policy_kind.value, + method=method, + ) + record_audit( + action=AuditAction.AUTH_PERMISSION_DENIED, + category="action", + actor_type="system", + status="failure", + metadata={ + "reason": f"auth.csrf.{reason.value}", + "policy": policy.policy_kind.value, + "method": method, + "request_id": request_id, + }, + ) diff --git a/apps/gateway/main.py b/apps/gateway/main.py index a5bf31e97..aaa7f9700 100644 --- a/apps/gateway/main.py +++ b/apps/gateway/main.py @@ -50,6 +50,13 @@ from apps.gateway.composition.authentication import ( validate_login_security_configuration, ) +from apps.gateway.composition.csrf import ( + build_csrf_route_policy_registry, + csrf_enforcement_enabled, + csrf_token_service, + record_csrf_auth_required, + record_csrf_denial, +) from apps.gateway.core.http_security import ( parse_credentialed_cors_origins, resolve_session_signing_secret, @@ -61,6 +68,7 @@ from apps.gateway.middleware.public_conversation_cors import ( PublicConversationCorsBoundaryMiddleware, ) +from apps.gateway.middleware.csrf import CsrfProtectionMiddleware from apps.gateway.utils.api_errors import error_response from apps.shared.audit import record_audit from apps.shared.audit.actions import AuditAction @@ -149,7 +157,9 @@ def _strip_validation_input(value): "code": "validation.failed", "message": "Request validation failed.", "request_id": getattr(request.state, "request_id", None), - "details": {"errors": _strip_validation_input(jsonable_encoder(exc.errors()))}, + "details": { + "errors": _strip_validation_input(jsonable_encoder(exc.errors())) + }, } }, ) @@ -174,29 +184,46 @@ async def permission_mutation_persistence_failed( node_env=os.getenv("NODE_ENV"), ) -# CORS 설정 (withCredentials 지원) -app.add_middleware( - CORSMiddleware, - allow_origins=origins, # .env에서 CORS_ORIGINS로 설정 가능 - allow_credentials=True, # 쿠키 전송 허용 - allow_methods=["*"], - allow_headers=["*"], - expose_headers=["Retry-After"], -) +# 정적 파일 서빙 (widget.js) - 옵션 +STATIC_DIR = BASE_DIR / "static" +if STATIC_DIR.exists(): + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + +# API 라우터 등록 +app.include_router(api_router, prefix="/api/v1") -# 세션 미들웨어 추가 (OAuth 상태 저장용) +# Unsafe routes are classified after router inclusion. Startup fails closed if +# a new mutation has no explicit cookie/public/server policy. +csrf_route_policy_registry = build_csrf_route_policy_registry(app) + +# Middleware is registered from inner to outer because Starlette prepends each +# new entry. CORS must wrap CSRF so allowed browser origins can read 401/403, +# while the public conversation boundary must remain outside legacy CORS. app.add_middleware( SessionMiddleware, secret_key=resolve_session_signing_secret( os.getenv("SECRET_KEY"), node_env=os.getenv("NODE_ENV"), ), - https_only=os.getenv("NODE_ENV") == "production", # 배포 환경에서는 Secure 쿠키 + https_only=os.getenv("NODE_ENV") == "production", +) +app.add_middleware( + CsrfProtectionMiddleware, + registry=csrf_route_policy_registry, + token_service=csrf_token_service(), + allowed_origins=tuple(origins), + enforcement_enabled=csrf_enforcement_enabled(), + on_denied=record_csrf_denial, + on_auth_required=record_csrf_auth_required, +) +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["Retry-After"], ) - -# Must be outer than the legacy credentialed CORS middleware. Public -# Conversation lifecycle calls are iframe-document same-origin only; the -# deployment parent allowlist remains a CSP frame-ancestors policy. app.add_middleware(PublicConversationCorsBoundaryMiddleware) # Added last so this transport sanitizer remains outermost and earlier @@ -204,14 +231,6 @@ async def permission_mutation_persistence_failed( # the ASGI server access log. app.add_middleware(WebhookQueryRedactionMiddleware) -# 정적 파일 서빙 (widget.js) - 옵션 -STATIC_DIR = BASE_DIR / "static" -if STATIC_DIR.exists(): - app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") - -# API 라우터 등록 -app.include_router(api_router, prefix="/api/v1") - @app.get("/") def root(): diff --git a/apps/gateway/middleware/csrf.py b/apps/gateway/middleware/csrf.py new file mode 100644 index 000000000..f4ebcfa44 --- /dev/null +++ b/apps/gateway/middleware/csrf.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import inspect +import logging +import re +import uuid +from collections.abc import Callable, Sequence +from typing import Any + +from fastapi import Request +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response + +from apps.gateway.application.csrf.models import ( + CsrfContentKind, + CsrfRoutePolicy, + CsrfRoutePolicyKind, + CsrfRoutePolicyRegistry, +) +from apps.gateway.application.csrf.token import ( + CSRF_ANON_COOKIE_NAME, + CSRF_COOKIE_NAME, + CSRF_HEADER_NAME, + CSRF_ORGANIZATION_HEADER_NAME, + CsrfBindingKind, + CsrfTokenService, + CsrfValidationReason, +) + +logger = logging.getLogger(__name__) + +DenialCallback = Callable[ + [CsrfValidationReason, CsrfRoutePolicy, str, str], + Any, +] +AuthRequiredCallback = Callable[[CsrfRoutePolicy, str, str], Any] + +_ALLOWED_FETCH_SITES = frozenset({"same-origin", "same-site"}) +_JSON_MEDIA_TYPE = "application/json" +_MULTIPART_MEDIA_TYPE = "multipart/form-data" +_MULTIPART_BOUNDARY_PATTERN = re.compile(r"^[0-9A-Za-z._-]{1,70}$") + + +class CsrfProtectionMiddleware(BaseHTTPMiddleware): + def __init__( + self, + app, + *, + registry: CsrfRoutePolicyRegistry, + token_service: CsrfTokenService, + allowed_origins: Sequence[str], + enforcement_enabled: bool, + on_denied: DenialCallback, + on_auth_required: AuthRequiredCallback | None = None, + ): + super().__init__(app) + self._registry = registry + self._token_service = token_service + self._allowed_origins = frozenset(allowed_origins) + self._enforcement_enabled = enforcement_enabled + self._on_denied = on_denied + self._on_auth_required = on_auth_required + + async def dispatch(self, request: Request, call_next) -> Response: + policy = self._registry.match(request.method, request.url.path) + if policy is None or not policy.requires_csrf or not self._enforcement_enabled: + return await call_next(request) + + if ( + policy.policy_kind is CsrfRoutePolicyKind.COOKIE_AUTHENTICATED + and not request.cookies.get("auth_token") + ): + return await self._authentication_required(request, policy) + + reason = self._validate_browser_boundary(request, policy) + if reason is None: + return await call_next(request) + return await self._denied(request, policy, reason) + + def _validate_browser_boundary( + self, + request: Request, + policy: CsrfRoutePolicy, + ) -> CsrfValidationReason | None: + origin = request.headers.get("origin") + if origin is None or origin not in self._allowed_origins: + return CsrfValidationReason.ORIGIN_INVALID + + fetch_site = request.headers.get("sec-fetch-site") + if fetch_site is not None and fetch_site.lower() not in _ALLOWED_FETCH_SITES: + return CsrfValidationReason.FETCH_METADATA_INVALID + + if not self._content_type_allowed(request, policy.content_kind): + return CsrfValidationReason.CONTENT_TYPE_INVALID + + auth_cookie = request.cookies.get("auth_token") + if policy.policy_kind is CsrfRoutePolicyKind.COOKIE_AUTHENTICATED: + binding_kind = CsrfBindingKind.AUTHENTICATED + binding_secret = auth_cookie + elif auth_cookie: + binding_kind = CsrfBindingKind.AUTHENTICATED + binding_secret = auth_cookie + else: + binding_kind = CsrfBindingKind.PRE_AUTH + binding_secret = request.cookies.get(CSRF_ANON_COOKIE_NAME) + + return self._token_service.validate( + header_token=request.headers.get(CSRF_HEADER_NAME), + cookie_token=request.cookies.get(CSRF_COOKIE_NAME), + binding_kind=binding_kind, + binding_secret=binding_secret, + organization_scope=request.headers.get(CSRF_ORGANIZATION_HEADER_NAME), + ) + + @staticmethod + def _content_type_allowed( + request: Request, + content_kind: CsrfContentKind, + ) -> bool: + if content_kind is CsrfContentKind.UNRESTRICTED: + return True + + raw_content_type = request.headers.get("content-type") + if not raw_content_type: + if content_kind is not CsrfContentKind.BODY_OPTIONAL: + return False + content_length = request.headers.get("content-length") + return ( + content_length in {None, "", "0"} + and request.headers.get("transfer-encoding") is None + ) + + media_type, *raw_parameters = raw_content_type.split(";") + normalized_media_type = media_type.strip().lower() + parameters = CsrfProtectionMiddleware._parse_parameters(raw_parameters) + if parameters is None: + return False + + if normalized_media_type == _JSON_MEDIA_TYPE: + return not parameters or parameters == {"charset": "utf-8"} + + if ( + content_kind is CsrfContentKind.MULTIPART + and normalized_media_type == _MULTIPART_MEDIA_TYPE + ): + boundary = parameters.get("boundary") + return ( + set(parameters) == {"boundary"} + and boundary is not None + and _MULTIPART_BOUNDARY_PATTERN.fullmatch(boundary) is not None + ) + return False + + @staticmethod + def _parse_parameters( + raw_parameters: list[str], + ) -> dict[str, str] | None: + parameters: dict[str, str] = {} + for raw_parameter in raw_parameters: + if not raw_parameter.strip() or "=" not in raw_parameter: + return None + raw_name, raw_value = raw_parameter.split("=", 1) + name = raw_name.strip().lower() + value = raw_value.strip().strip('"') + if not name or not value or name in parameters: + return None + parameters[name] = value.lower() if name == "charset" else value + return parameters + + @staticmethod + def _safe_request_id(value: object) -> str: + if ( + isinstance(value, str) + and 1 <= len(value) <= 128 + and all(ord(character) >= 32 for character in value) + ): + return value + return str(uuid.uuid4()) + + async def _authentication_required( + self, + request: Request, + policy: CsrfRoutePolicy, + ) -> Response: + request_id = self._safe_request_id( + request.headers.get("X-Request-ID") + or getattr(request.state, "request_id", None) + ) + request.state.request_id = request_id + if self._on_auth_required is not None: + try: + callback_result = self._on_auth_required( + policy, + request.method.upper(), + request_id, + ) + if inspect.isawaitable(callback_result): + await callback_result + except Exception as exc: + logger.error( + "Authentication denial telemetry failed: error_type=%s", + type(exc).__name__, + ) + response = JSONResponse( + status_code=401, + content={ + "error": { + "code": "auth.required", + "message": "Authentication is required.", + "request_id": request_id, + } + }, + ) + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + response.headers["X-Request-ID"] = request_id + return response + + async def _denied( + self, + request: Request, + policy: CsrfRoutePolicy, + reason: CsrfValidationReason, + ) -> Response: + request_id = self._safe_request_id( + request.headers.get("X-Request-ID") + or getattr(request.state, "request_id", None) + ) + request.state.request_id = request_id + try: + callback_result = self._on_denied( + reason, + policy, + request.method.upper(), + request_id, + ) + if inspect.isawaitable(callback_result): + await callback_result + except Exception as exc: + logger.error( + "CSRF denial telemetry failed: error_type=%s", + type(exc).__name__, + ) + + response = JSONResponse( + status_code=403, + content={ + "error": { + "code": "auth.csrf_validation_failed", + "message": "CSRF validation failed.", + "request_id": request_id, + } + }, + ) + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" + response.headers["X-Request-ID"] = request_id + return response diff --git a/apps/gateway/tests/api/test_auth_csrf.py b/apps/gateway/tests/api/test_auth_csrf.py new file mode 100644 index 000000000..15a7d8da5 --- /dev/null +++ b/apps/gateway/tests/api/test_auth_csrf.py @@ -0,0 +1,106 @@ +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from apps.gateway.api.v1.endpoints import auth as auth_endpoint +from apps.gateway.application.csrf.token import ( + CSRF_ANON_COOKIE_NAME, + CSRF_COOKIE_NAME, + CsrfTokenService, +) +from apps.gateway.services.auth_service import AuthService +from apps.shared.db.session import get_db + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(auth_endpoint.router, prefix="/auth") + app.dependency_overrides[get_db] = lambda: object() + return TestClient(app, base_url="http://localhost") + + +def test_anonymous_csrf_bootstrap_sets_host_only_http_only_cookies(monkeypatch): + monkeypatch.setattr( + auth_endpoint, + "csrf_token_service", + lambda: CsrfTokenService.from_root_secret("csrf-endpoint-test-secret"), + ) + + with _client() as client: + response = client.get("/auth/csrf") + + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload["token"], str) + assert payload["token"] == response.cookies[CSRF_COOKIE_NAME] + assert "anonymous" not in response.text.lower() + assert response.headers["cache-control"] == "no-store" + assert response.headers["pragma"] == "no-cache" + cookies = response.headers.get_list("set-cookie") + assert any( + cookie.startswith(f"{CSRF_COOKIE_NAME}=") + and "HttpOnly" in cookie + and "Path=/api/v1" in cookie + and "Domain=" not in cookie + for cookie in cookies + ) + assert any( + cookie.startswith(f"{CSRF_ANON_COOKIE_NAME}=") + and "HttpOnly" in cookie + and "Domain=" not in cookie + for cookie in cookies + ) + + +def test_authenticated_csrf_bootstrap_validates_cookie_and_clears_anon_seed( + monkeypatch, +): + monkeypatch.setattr(AuthService, "get_user_from_token", lambda db, token: object()) + monkeypatch.setattr( + auth_endpoint, + "csrf_token_service", + lambda: CsrfTokenService.from_root_secret("csrf-endpoint-test-secret"), + ) + + with _client() as client: + client.cookies.set("auth_token", "valid-auth-token") + client.cookies.set(CSRF_ANON_COOKIE_NAME, "stale-anonymous-seed") + response = client.get( + "/auth/csrf", + headers={"X-Organization-Id": "organization-a"}, + ) + + assert response.status_code == 200 + assert response.cookies[CSRF_COOKIE_NAME] == response.json()["token"] + assert any( + cookie.startswith(f"{CSRF_ANON_COOKIE_NAME}=") and "Max-Age=0" in cookie + for cookie in response.headers.get_list("set-cookie") + ) + + +def test_invalid_auth_cookie_cannot_fall_back_to_anonymous_bootstrap(monkeypatch): + def reject_invalid_cookie(_db, _token): + raise HTTPException(status_code=401, detail="invalid") + + monkeypatch.setattr(AuthService, "get_user_from_token", reject_invalid_cookie) + + with _client() as client: + client.cookies.set("auth_token", "invalid-auth-token") + response = client.get("/auth/csrf") + + assert response.status_code == 401 + assert response.json()["error"]["code"] == "auth.invalid" + cookies = response.headers.get_list("set-cookie") + assert any(cookie.startswith("auth_token=") for cookie in cookies) + assert any(cookie.startswith(f"{CSRF_COOKIE_NAME}=") for cookie in cookies) + assert any(cookie.startswith(f"{CSRF_ANON_COOKIE_NAME}=") for cookie in cookies) + + +def test_logout_clears_auth_and_csrf_cookie_families(): + with _client() as client: + response = client.post("/auth/logout") + + assert response.status_code == 200 + cookies = response.headers.get_list("set-cookie") + assert any(cookie.startswith("auth_token=") for cookie in cookies) + assert any(cookie.startswith(f"{CSRF_COOKIE_NAME}=") for cookie in cookies) + assert any(cookie.startswith(f"{CSRF_ANON_COOKIE_NAME}=") for cookie in cookies) diff --git a/apps/gateway/tests/application/csrf/test_token_service.py b/apps/gateway/tests/application/csrf/test_token_service.py new file mode 100644 index 000000000..e084aec1b --- /dev/null +++ b/apps/gateway/tests/application/csrf/test_token_service.py @@ -0,0 +1,180 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from apps.gateway.application.csrf.token import ( + CsrfBindingKind, + CsrfTokenService, + CsrfValidationReason, +) + + +@pytest.fixture +def service() -> CsrfTokenService: + return CsrfTokenService.from_root_secret( + "test-session-secret-with-sufficient-entropy", + ttl_seconds=600, + nonce_factory=lambda size: b"n" * size, + ) + + +def test_signed_token_contains_no_binding_or_organization_plaintext( + service: CsrfTokenService, +): + now = datetime(2026, 7, 29, tzinfo=timezone.utc) + + issued = service.issue( + binding_kind=CsrfBindingKind.AUTHENTICATED, + binding_secret="raw-auth-cookie-sentinel", + organization_scope="raw-organization-sentinel", + now=now, + ) + + assert issued.token.count(".") == 3 + assert "raw-auth-cookie-sentinel" not in issued.token + assert "raw-organization-sentinel" not in issued.token + assert issued.expires_at == now + timedelta(seconds=600) + + +def test_valid_signed_double_submit_token_is_accepted( + service: CsrfTokenService, +): + now = datetime(2026, 7, 29, tzinfo=timezone.utc) + issued = service.issue( + binding_kind=CsrfBindingKind.AUTHENTICATED, + binding_secret="session-a", + organization_scope="organization-a", + now=now, + ) + + reason = service.validate( + header_token=issued.token, + cookie_token=issued.token, + binding_kind=CsrfBindingKind.AUTHENTICATED, + binding_secret="session-a", + organization_scope="organization-a", + now=now + timedelta(seconds=1), + ) + + assert reason is None + + +@pytest.mark.parametrize( + ("header_token", "cookie_token", "expected"), + [ + (None, "cookie-token", CsrfValidationReason.TOKEN_MISSING), + ("header-token", None, CsrfValidationReason.TOKEN_MISSING), + ("header-token", "cookie-token", CsrfValidationReason.TOKEN_MISMATCH), + ("not-a-token", "not-a-token", CsrfValidationReason.TOKEN_INVALID), + ], +) +def test_missing_mismatched_and_malformed_tokens_are_rejected( + service: CsrfTokenService, + header_token: str | None, + cookie_token: str | None, + expected: CsrfValidationReason, +): + reason = service.validate( + header_token=header_token, + cookie_token=cookie_token, + binding_kind=CsrfBindingKind.PRE_AUTH, + binding_secret="anonymous-seed", + organization_scope=None, + now=datetime(2026, 7, 29, tzinfo=timezone.utc), + ) + + assert reason is expected + + +@pytest.mark.parametrize( + ("binding_kind", "binding_secret", "organization_scope"), + [ + (CsrfBindingKind.AUTHENTICATED, "session-b", "organization-a"), + (CsrfBindingKind.AUTHENTICATED, "session-a", "organization-b"), + (CsrfBindingKind.PRE_AUTH, "session-a", "organization-a"), + ], +) +def test_cross_session_scope_and_binding_kind_replay_are_rejected( + service: CsrfTokenService, + binding_kind: CsrfBindingKind, + binding_secret: str, + organization_scope: str | None, +): + now = datetime(2026, 7, 29, tzinfo=timezone.utc) + issued = service.issue( + binding_kind=CsrfBindingKind.AUTHENTICATED, + binding_secret="session-a", + organization_scope="organization-a", + now=now, + ) + + reason = service.validate( + header_token=issued.token, + cookie_token=issued.token, + binding_kind=binding_kind, + binding_secret=binding_secret, + organization_scope=organization_scope, + now=now + timedelta(seconds=1), + ) + + assert reason is CsrfValidationReason.TOKEN_INVALID + + +def test_expired_and_implausibly_future_tokens_are_rejected( + service: CsrfTokenService, +): + now = datetime(2026, 7, 29, tzinfo=timezone.utc) + issued = service.issue( + binding_kind=CsrfBindingKind.PRE_AUTH, + binding_secret="anonymous-seed", + organization_scope=None, + now=now, + ) + + expired = service.validate( + header_token=issued.token, + cookie_token=issued.token, + binding_kind=CsrfBindingKind.PRE_AUTH, + binding_secret="anonymous-seed", + organization_scope=None, + now=now + timedelta(seconds=601), + ) + future = service.validate( + header_token=issued.token, + cookie_token=issued.token, + binding_kind=CsrfBindingKind.PRE_AUTH, + binding_secret="anonymous-seed", + organization_scope=None, + now=now - timedelta(seconds=31), + ) + + assert expired is CsrfValidationReason.TOKEN_EXPIRED + assert future is CsrfValidationReason.TOKEN_INVALID + + +def test_noncanonical_or_invalid_base64_token_segments_are_rejected( + service: CsrfTokenService, +): + now = datetime(2026, 7, 29, tzinfo=timezone.utc) + issued = service.issue( + binding_kind=CsrfBindingKind.PRE_AUTH, + binding_secret="anonymous-seed", + organization_scope=None, + now=now, + ) + version, expiry, nonce, mac = issued.token.split(".") + + for malformed in ( + f"{version}.0{expiry}.{nonce}.{mac}", + f"{version}.{expiry}.{nonce}=.{mac}", + f"{version}.{expiry}.{nonce}.{mac}$", + ): + reason = service.validate( + header_token=malformed, + cookie_token=malformed, + binding_kind=CsrfBindingKind.PRE_AUTH, + binding_secret="anonymous-seed", + organization_scope=None, + now=now, + ) + assert reason is CsrfValidationReason.TOKEN_INVALID diff --git a/apps/gateway/tests/architecture/test_csrf_route_inventory.py b/apps/gateway/tests/architecture/test_csrf_route_inventory.py new file mode 100644 index 000000000..2db047344 --- /dev/null +++ b/apps/gateway/tests/architecture/test_csrf_route_inventory.py @@ -0,0 +1,92 @@ +from collections import Counter + +import pytest +from fastapi import FastAPI + +from apps.gateway.application.csrf.models import ( + CsrfContentKind, + CsrfRoutePolicyKind, +) +from apps.gateway.composition.csrf import ( + CsrfRouteInventoryError, + build_csrf_route_policy_registry, +) +from apps.gateway.main import app + + +def test_every_gateway_unsafe_route_has_exactly_one_csrf_policy(): + registry = build_csrf_route_policy_registry(app) + + assert len(registry.policies) == 138 + assert Counter(policy.policy_kind for policy in registry.policies) == { + CsrfRoutePolicyKind.COOKIE_AUTHENTICATED: 131, + CsrfRoutePolicyKind.PRE_AUTH_SESSION: 3, + CsrfRoutePolicyKind.PUBLIC_ANONYMOUS: 2, + CsrfRoutePolicyKind.SERVER_CREDENTIAL: 2, + } + + +def test_multipart_cookie_routes_are_explicit_and_bounded(): + registry = build_csrf_route_policy_registry(app) + + assert { + (policy.method, policy.path_template) + for policy in registry.policies + if policy.content_kind is CsrfContentKind.MULTIPART + } == { + ("POST", "/api/v1/rag/upload"), + ("POST", "/api/v1/workflows/{workflow_id}/stream"), + } + + +@pytest.mark.parametrize( + ("method", "path", "expected"), + [ + ( + "POST", + "/api/v1/auth/login", + CsrfRoutePolicyKind.PRE_AUTH_SESSION, + ), + ( + "POST", + "/api/v1/teams", + CsrfRoutePolicyKind.COOKIE_AUTHENTICATED, + ), + ( + "PUT", + "/api/v1/permissions/workflows/{workflow_id}/teams/{team_id}", + CsrfRoutePolicyKind.COOKIE_AUTHENTICATED, + ), + ( + "POST", + "/api/v1/run-public/{url_slug}/chat", + CsrfRoutePolicyKind.PUBLIC_ANONYMOUS, + ), + ( + "POST", + "/api/v1/hooks/{url_slug}", + CsrfRoutePolicyKind.SERVER_CREDENTIAL, + ), + ], +) +def test_high_risk_and_exception_routes_keep_their_declared_policy( + method: str, + path: str, + expected: CsrfRoutePolicyKind, +): + registry = build_csrf_route_policy_registry(app) + + policy = registry.by_key(method, path) + + assert policy.policy_kind is expected + + +def test_new_unsafe_route_without_auth_dependency_or_exception_fails_inventory(): + unclassified_app = FastAPI() + + @unclassified_app.post("/api/v1/new-public-mutation") + def new_public_mutation(): + return {"ok": True} + + with pytest.raises(CsrfRouteInventoryError, match="unclassified unsafe route"): + build_csrf_route_policy_registry(unclassified_app) diff --git a/apps/gateway/tests/architecture/test_http_middleware_order.py b/apps/gateway/tests/architecture/test_http_middleware_order.py new file mode 100644 index 000000000..aa0c658bc --- /dev/null +++ b/apps/gateway/tests/architecture/test_http_middleware_order.py @@ -0,0 +1,28 @@ +from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware + +from apps.gateway.main import app +from apps.gateway.middleware.csrf import CsrfProtectionMiddleware +from apps.gateway.middleware.public_conversation_cors import ( + PublicConversationCorsBoundaryMiddleware, +) +from apps.gateway.middleware.webhook_query_redaction import ( + WebhookQueryRedactionMiddleware, +) + + +def test_security_middleware_order_keeps_cors_outside_csrf_denials(): + middleware_classes = [entry.cls for entry in app.user_middleware] + + assert middleware_classes.index(WebhookQueryRedactionMiddleware) < ( + middleware_classes.index(PublicConversationCorsBoundaryMiddleware) + ) + assert middleware_classes.index(PublicConversationCorsBoundaryMiddleware) < ( + middleware_classes.index(CORSMiddleware) + ) + assert middleware_classes.index(CORSMiddleware) < middleware_classes.index( + CsrfProtectionMiddleware + ) + assert middleware_classes.index(CsrfProtectionMiddleware) < ( + middleware_classes.index(SessionMiddleware) + ) diff --git a/apps/gateway/tests/composition/test_csrf_configuration.py b/apps/gateway/tests/composition/test_csrf_configuration.py new file mode 100644 index 000000000..d355e72b7 --- /dev/null +++ b/apps/gateway/tests/composition/test_csrf_configuration.py @@ -0,0 +1,29 @@ +import pytest + +from apps.gateway.composition.csrf import csrf_enforcement_enabled + + +def test_csrf_enforcement_defaults_to_enabled(monkeypatch): + monkeypatch.delenv("CSRF_ENFORCEMENT_MODE", raising=False) + monkeypatch.setenv("NODE_ENV", "production") + + assert csrf_enforcement_enabled() is True + + +def test_csrf_enforcement_can_only_be_disabled_in_test(monkeypatch): + monkeypatch.setenv("CSRF_ENFORCEMENT_MODE", "disabled") + monkeypatch.setenv("NODE_ENV", "test") + + assert csrf_enforcement_enabled() is False + + monkeypatch.setenv("NODE_ENV", "production") + with pytest.raises(RuntimeError, match="CSRF enforcement mode is invalid"): + csrf_enforcement_enabled() + + +def test_unknown_csrf_enforcement_mode_fails_closed(monkeypatch): + monkeypatch.setenv("CSRF_ENFORCEMENT_MODE", "monitor") + monkeypatch.setenv("NODE_ENV", "development") + + with pytest.raises(RuntimeError, match="CSRF enforcement mode is invalid"): + csrf_enforcement_enabled() diff --git a/apps/gateway/tests/conftest.py b/apps/gateway/tests/conftest.py index f85ccfb5f..5487056d8 100644 --- a/apps/gateway/tests/conftest.py +++ b/apps/gateway/tests/conftest.py @@ -3,3 +3,4 @@ # Gateway tests import the application during collection and must use its test profile. os.environ.setdefault("NODE_ENV", "test") +os.environ.setdefault("CSRF_ENFORCEMENT_MODE", "disabled") diff --git a/apps/gateway/tests/middleware/test_csrf_content_types.py b/apps/gateway/tests/middleware/test_csrf_content_types.py new file mode 100644 index 000000000..7da7c13ba --- /dev/null +++ b/apps/gateway/tests/middleware/test_csrf_content_types.py @@ -0,0 +1,133 @@ +import re + +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from apps.gateway.application.csrf.models import ( + CsrfContentKind, + CsrfRoutePolicy, + CsrfRoutePolicyKind, + CsrfRoutePolicyRegistry, +) +from apps.gateway.application.csrf.token import ( + CSRF_COOKIE_NAME, + CSRF_HEADER_NAME, + CsrfBindingKind, + CsrfTokenService, +) +from apps.gateway.middleware.csrf import CsrfProtectionMiddleware + + +ORIGIN = "https://client.example" + + +def _policy(path: str, content_kind: CsrfContentKind) -> CsrfRoutePolicy: + return CsrfRoutePolicy( + method="POST" if path != "/optional" else "DELETE", + path_template=path, + path_pattern=re.compile(f"^{re.escape(path)}$"), + policy_kind=CsrfRoutePolicyKind.COOKIE_AUTHENTICATED, + content_kind=content_kind, + ) + + +def _app_and_headers(): + app = FastAPI() + effects = {"json": 0, "multipart": 0, "optional": 0} + + @app.post("/json") + async def json_endpoint(request: Request): + await request.body() + effects["json"] += 1 + return {"ok": True} + + @app.post("/multipart") + async def multipart_endpoint(request: Request): + await request.body() + effects["multipart"] += 1 + return {"ok": True} + + @app.delete("/optional") + async def optional_endpoint(): + effects["optional"] += 1 + return {"ok": True} + + service = CsrfTokenService.from_root_secret("content-type-middleware-test-secret") + issued = service.issue( + binding_kind=CsrfBindingKind.AUTHENTICATED, + binding_secret="session-a", + organization_scope=None, + ) + registry = CsrfRoutePolicyRegistry( + ( + _policy("/json", CsrfContentKind.JSON), + _policy("/multipart", CsrfContentKind.MULTIPART), + _policy("/optional", CsrfContentKind.BODY_OPTIONAL), + ) + ) + app.add_middleware( + CsrfProtectionMiddleware, + registry=registry, + token_service=service, + allowed_origins=(ORIGIN,), + enforcement_enabled=True, + on_denied=lambda *_args: None, + ) + headers = { + "Origin": ORIGIN, + "Sec-Fetch-Site": "same-origin", + CSRF_HEADER_NAME: issued.token, + } + return app, effects, headers, issued.token + + +def test_json_utf8_multipart_and_empty_body_contracts_are_accepted(): + app, effects, headers, token = _app_and_headers() + + with TestClient(app, base_url=ORIGIN) as client: + client.cookies.set("auth_token", "session-a") + client.cookies.set(CSRF_COOKIE_NAME, token) + json_response = client.post( + "/json", + headers={**headers, "Content-Type": "application/json; charset=UTF-8"}, + content="{}", + ) + multipart_response = client.post( + "/multipart", + headers=headers, + files={"file": ("document.txt", b"content", "text/plain")}, + ) + optional_response = client.delete("/optional", headers=headers) + + assert json_response.status_code == 200 + assert multipart_response.status_code == 200 + assert optional_response.status_code == 200 + assert effects == {"json": 1, "multipart": 1, "optional": 1} + + +def test_unexpected_json_parameters_and_malformed_multipart_fail_before_effects(): + app, effects, headers, token = _app_and_headers() + + with TestClient(app, base_url=ORIGIN) as client: + client.cookies.set("auth_token", "session-a") + client.cookies.set(CSRF_COOKIE_NAME, token) + json_response = client.post( + "/json", + headers={ + **headers, + "Content-Type": "application/json; profile=unexpected", + }, + content="{}", + ) + multipart_response = client.post( + "/multipart", + headers={ + **headers, + "Content-Type": "multipart/form-data; boundary=bad boundary", + }, + content=b"sentinel", + ) + + assert json_response.status_code == 403 + assert multipart_response.status_code == 403 + assert effects == {"json": 0, "multipart": 0, "optional": 0} diff --git a/apps/gateway/tests/middleware/test_csrf_protection.py b/apps/gateway/tests/middleware/test_csrf_protection.py new file mode 100644 index 000000000..f808bfa4a --- /dev/null +++ b/apps/gateway/tests/middleware/test_csrf_protection.py @@ -0,0 +1,275 @@ +import re + +import pytest +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from apps.gateway.application.csrf.models import ( + CsrfContentKind, + CsrfRoutePolicy, + CsrfRoutePolicyKind, + CsrfRoutePolicyRegistry, +) +from apps.gateway.application.csrf.token import ( + CSRF_ANON_COOKIE_NAME, + CSRF_COOKIE_NAME, + CSRF_HEADER_NAME, + CsrfBindingKind, + CsrfTokenService, +) +from apps.gateway.middleware.csrf import CsrfProtectionMiddleware + + +ORIGIN = "https://client.example" +NOW_HEADER = {"X-Request-ID": "csrf-request-id"} + + +def _policy( + method: str, + path: str, + kind: CsrfRoutePolicyKind, + *, + content_kind: CsrfContentKind = CsrfContentKind.JSON, +) -> CsrfRoutePolicy: + return CsrfRoutePolicy( + method=method, + path_template=path, + path_pattern=re.compile(f"^{re.escape(path)}$"), + policy_kind=kind, + content_kind=content_kind, + ) + + +def _build_app( + token_service: CsrfTokenService, + denied: list[tuple[str, str, str, str]], +): + app = FastAPI() + effects = {"protected": 0, "public": 0, "pre_auth": 0} + + @app.post("/protected") + async def protected(request: Request): + await request.body() + effects["protected"] += 1 + return {"ok": True} + + @app.post("/public") + async def public(): + effects["public"] += 1 + return {"ok": True} + + @app.post("/pre-auth") + async def pre_auth(): + effects["pre_auth"] += 1 + return {"ok": True} + + registry = CsrfRoutePolicyRegistry( + ( + _policy( + "POST", + "/protected", + CsrfRoutePolicyKind.COOKIE_AUTHENTICATED, + ), + _policy( + "POST", + "/public", + CsrfRoutePolicyKind.PUBLIC_ANONYMOUS, + content_kind=CsrfContentKind.UNRESTRICTED, + ), + _policy("POST", "/pre-auth", CsrfRoutePolicyKind.PRE_AUTH_SESSION), + ) + ) + + def on_denied(reason, policy, method, request_id): + denied.append((reason.value, policy.policy_kind.value, method, request_id)) + + app.add_middleware( + CsrfProtectionMiddleware, + registry=registry, + token_service=token_service, + allowed_origins=(ORIGIN,), + enforcement_enabled=True, + on_denied=on_denied, + ) + return app, effects + + +@pytest.fixture +def token_service() -> CsrfTokenService: + return CsrfTokenService.from_root_secret( + "middleware-test-session-secret", + ttl_seconds=600, + nonce_factory=lambda size: b"m" * size, + ) + + +def _authenticated_headers( + token_service: CsrfTokenService, + *, + session: str = "session-a", + organization: str | None = "organization-a", +) -> tuple[dict[str, str], str]: + issued = token_service.issue( + binding_kind=CsrfBindingKind.AUTHENTICATED, + binding_secret=session, + organization_scope=organization, + ) + headers = { + **NOW_HEADER, + "Origin": ORIGIN, + "Sec-Fetch-Site": "same-site", + "Content-Type": "application/json", + CSRF_HEADER_NAME: issued.token, + } + if organization is not None: + headers["X-Organization-Id"] = organization + return headers, issued.token + + +def test_valid_cookie_authenticated_request_reaches_endpoint( + token_service: CsrfTokenService, +): + denied: list[tuple[str, str, str, str]] = [] + app, effects = _build_app(token_service, denied) + headers, token = _authenticated_headers(token_service) + + with TestClient(app, base_url=ORIGIN) as client: + client.cookies.set("auth_token", "session-a") + client.cookies.set(CSRF_COOKIE_NAME, token) + response = client.post("/protected", headers=headers, json={"value": 1}) + + assert response.status_code == 200 + assert effects["protected"] == 1 + assert denied == [] + + +@pytest.mark.parametrize( + ("header_changes", "cookie_token", "expected_reason"), + [ + ({"Origin": None}, None, "origin_invalid"), + ({"Origin": "null"}, None, "origin_invalid"), + ({"Origin": "https://attacker.example"}, None, "origin_invalid"), + ({"Sec-Fetch-Site": "cross-site"}, None, "fetch_metadata_invalid"), + ({"Content-Type": "text/plain"}, None, "content_type_invalid"), + ({CSRF_HEADER_NAME: None}, None, "token_missing"), + ({CSRF_HEADER_NAME: "forged"}, "forged", "token_invalid"), + ({"X-Organization-Id": "organization-b"}, None, "token_invalid"), + ], +) +def test_invalid_browser_boundary_is_rejected_before_body_or_side_effect( + token_service: CsrfTokenService, + header_changes: dict[str, str | None], + cookie_token: str | None, + expected_reason: str, +): + denied: list[tuple[str, str, str, str]] = [] + app, effects = _build_app(token_service, denied) + headers, issued_token = _authenticated_headers(token_service) + for name, value in header_changes.items(): + if value is None: + headers.pop(name, None) + else: + headers[name] = value + + with TestClient(app, base_url=ORIGIN) as client: + client.cookies.set("auth_token", "session-a") + client.cookies.set(CSRF_COOKIE_NAME, cookie_token or issued_token) + response = client.post( + "/protected", + headers=headers, + content='{"sentinel":"request-body-must-not-be-read"}', + ) + + assert response.status_code == 403 + assert response.json() == { + "error": { + "code": "auth.csrf_validation_failed", + "message": "CSRF validation failed.", + "request_id": "csrf-request-id", + } + } + assert effects["protected"] == 0 + assert denied == [ + ( + expected_reason, + "cookie_authenticated", + "POST", + "csrf-request-id", + ) + ] + + +def test_cookie_authenticated_route_without_auth_cookie_returns_401_before_effect( + token_service: CsrfTokenService, +): + denied: list[tuple[str, str, str, str]] = [] + app, effects = _build_app(token_service, denied) + + with TestClient(app, base_url=ORIGIN) as client: + response = client.post( + "/protected", + headers={**NOW_HEADER, "Origin": ORIGIN}, + json={"value": 1}, + ) + + assert response.status_code == 401 + assert response.json() == { + "error": { + "code": "auth.required", + "message": "Authentication is required.", + "request_id": "csrf-request-id", + } + } + assert effects["protected"] == 0 + assert denied == [] + + +def test_pre_auth_token_uses_anonymous_seed_without_auth_cookie( + token_service: CsrfTokenService, +): + denied: list[tuple[str, str, str, str]] = [] + app, effects = _build_app(token_service, denied) + issued = token_service.issue( + binding_kind=CsrfBindingKind.PRE_AUTH, + binding_secret="anonymous-seed", + organization_scope=None, + ) + + with TestClient(app, base_url=ORIGIN) as client: + client.cookies.set(CSRF_ANON_COOKIE_NAME, "anonymous-seed") + client.cookies.set(CSRF_COOKIE_NAME, issued.token) + response = client.post( + "/pre-auth", + headers={ + **NOW_HEADER, + "Origin": ORIGIN, + "Sec-Fetch-Site": "same-origin", + "Content-Type": "application/json", + CSRF_HEADER_NAME: issued.token, + }, + json={"email": "user@example.com"}, + ) + + assert response.status_code == 200 + assert effects["pre_auth"] == 1 + assert denied == [] + + +def test_public_route_ignores_login_and_csrf_cookies( + token_service: CsrfTokenService, +): + denied: list[tuple[str, str, str, str]] = [] + app, effects = _build_app(token_service, denied) + + with TestClient(app, base_url=ORIGIN) as client: + client.cookies.set("auth_token", "session-sentinel") + client.cookies.set(CSRF_COOKIE_NAME, "csrf-sentinel") + response = client.post( + "/public", + headers={"Origin": "https://external.example"}, + content="public payload", + ) + + assert response.status_code == 200 + assert effects["public"] == 1 + assert denied == [] diff --git a/apps/shared/schemas/csrf.py b/apps/shared/schemas/csrf.py new file mode 100644 index 000000000..23d227bda --- /dev/null +++ b/apps/shared/schemas/csrf.py @@ -0,0 +1,10 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class CsrfTokenResponse(BaseModel): + """브라우저 쿠키 인증 변경 요청용 단기 CSRF 토큰 응답.""" + + token: str + expires_at: datetime diff --git a/docs/architecture.md b/docs/architecture.md index 035ed6be3..6c9c2c440 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -672,6 +672,10 @@ Canonical content revision/input hash ### 사용자 인증 - 사용자 세션은 `auth_token` HttpOnly cookie 기준이다. user session용 Bearer token dependency는 없다. +- Cookie-authenticated/pre-auth unsafe Gateway API는 [ADR-0073](decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md)의 signed double-submit 경계를 사용한다. Safe `GET /api/v1/auth/csrf`가 10분 token을 body와 host-only HttpOnly cookie로 발급하며 token MAC은 auth cookie 또는 anonymous seed와 active organization/account scope에 결박된다. +- Gateway는 모든 unsafe route를 cookie, pre-auth, public anonymous 또는 server credential audience로 startup 시 분류한다. Cookie/pre-auth route는 body parsing과 DB·queue·storage·provider 이전에 exact configured Origin, Fetch Metadata, JSON/명시 multipart와 token equality/signature/session/scope를 검증한다. Public/server route는 login cookie를 principal로 해석하지 않는다. +- Browser Client는 CSRF token을 module memory에만 보관하고 active organization/auth lifecycle에서 폐기한다. 안전하게 replay 가능한 request만 고정 CSRF 오류 뒤 최대 한 번 갱신·재시도한다. Workflow SSE Next proxy는 strict header token만 outbound host-only cookie로 복제하고 original Origin/Fetch Metadata를 전달하며 Gateway가 최종 검증한다. +- Middleware 외곽 순서는 webhook query redaction, Public Conversation CORS boundary, credentialed CORS, CSRF, Session 순이다. 따라서 Public iframe 경계를 유지하면서 configured Client가 CSRF `401/403`을 읽을 수 있다. - Google OAuth 로그인을 지원한다 (`/api/v1/auth/google/login` → callback). - 인증 내부 실행의 safe same-origin `next` 복귀는 현재 이메일/비밀번호 로그인에만 적용하며, unsafe URL은 `/dashboard`로 닫는다. Google OAuth callback은 기존 `/dashboard` 복귀를 유지한다. - Bearer secret은 public run/webhook endpoint의 app secret 인증에만 사용한다. Public webhook은 [ADR-0041](decisions/ADR-0041-public-webhook-ingress-security-boundary.md)에 따라 query `token`을 거부하고 정확히 하나의 Bearer 또는 `X-Webhook-Secret` header만 허용한다. [ADR-0056](decisions/ADR-0056-app-auth-secret-issuance-and-rotation.md)에 따라 일반 App·Deployment 응답은 원문을 반환하지 않고, 명시적 one-time rotation API만 신규 원문을 반환한다. Gateway는 App current/previous 비가역 verifier를 권위로 사용하며 row lock·version CAS·최대 5분 grace·즉시 폐기를 집행한다. diff --git a/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md b/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md new file mode 100644 index 000000000..174fce8d5 --- /dev/null +++ b/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md @@ -0,0 +1,99 @@ +# ADR-0073: Cookie-authenticated API CSRF boundary + +Status: Accepted + +## Context + +Gateway 사용자 인증은 `auth_token` HttpOnly JWT cookie를 사용한다. Non-local 환경에서는 credentialed cross-origin Client를 지원하기 위해 `Secure`, `SameSite=None`을 사용할 수 있다. CORS는 응답을 읽을 수 있는 origin을 제한하지만 요청의 의도나 mutation 권한을 증명하지 않으며, SameSite도 이 배포 형태의 독립된 CSRF 방어가 아니다. + +Gateway에는 공통 `get_current_user` dependency를 사용하는 route 외에도 자체 cookie 인증 helper, 로그인 전 mutation, 익명 Public 실행, app secret 기반 실행과 webhook이 함께 존재한다. Cookie 존재 여부만으로 정책을 추론하면 Public audience를 사용자 권한으로 승격하거나 보호 mutation을 누락할 수 있다. + +## Options Considered + +### Option A: CORS와 SameSite만 유지 + +- 장점: Client 변경이 없다. +- 단점: 서버 mutation 승인 증거가 없고 CORS 구성 오류와 cross-site cookie 전송에 취약하다. + +### Option B: Server-side synchronizer token 저장소 도입 + +- 장점: 중앙 revoke와 단일 사용 token을 구현할 수 있다. +- 단점: 현재 stateless JWT 인증에 별도 session 저장소와 가용성·정리 정책이 추가된다. + +### Option C: Session·organization에 결박한 signed double-submit token + +- 장점: 별도 durable session row 없이 cookie injection, token 변조와 다른 session·organization replay를 막을 수 있다. +- 단점: Client bootstrap/lifecycle, route inventory와 배포 순서를 함께 관리해야 한다. + +## Decision + +Option C를 채택한다. + +### Token과 bootstrap + +1. `GET /api/v1/auth/csrf`는 `v1.expiry.nonce.mac` 형식의 10분 HMAC token을 응답 body와 host-only HttpOnly `csrf_token` cookie에 함께 발급한다. +2. Token payload에는 auth cookie, 사용자, organization 또는 그 fingerprint 원문을 넣지 않는다. MAC은 domain-separated key, binding 종류, auth cookie 또는 anonymous seed의 HMAC, active `X-Organization-Id` 또는 account sentinel을 포함한다. +3. 인증 cookie가 없으면 host-only HttpOnly random `csrf_anon_seed`에 결박한 pre-auth token을 발급한다. 유효하지 않은 `auth_token`이 있으면 anonymous로 조용히 전환하지 않고 `401 auth.invalid`로 닫고 invalid auth/CSRF cookie를 삭제한다. Client는 cookie 삭제가 반영된 뒤 bootstrap을 한 번만 다시 시도할 수 있다. +4. Bootstrap 응답은 `Cache-Control: no-store`, `Pragma: no-cache`를 사용한다. Token과 seed cookie는 `/api/v1`, 600초, HttpOnly, host-only이며 non-local에서는 Secure와 SameSite=None, loopback에서는 SameSite=Lax를 사용한다. +5. Signup, password login, Google OAuth 성공과 logout은 이전 CSRF/anonymous cookie를 삭제한다. Client는 인증 전환과 active organization 변경 시 memory token을 폐기한다. + +### 중앙 route policy와 검증 순서 + +1. 모든 unsafe Gateway route는 `cookie_authenticated`, `pre_auth_session`, `public_anonymous`, `server_credential` 중 정확히 하나로 분류한다. OAuth GET 진입/콜백은 기존 signed one-time state를 사용하는 `oauth_state` 예외로 별도 확인한다. +2. 신규 unsafe route가 미분류되거나 명시 예외가 실제 route와 어긋나면 Gateway startup과 architecture test를 실패시킨다. +3. Cookie/pre-auth mutation은 body parsing, DB, queue, storage와 provider 호출 전에 다음 순서로 검증한다. + - `CORS_ORIGINS`와 정확히 일치하는 `Origin` + - 존재하는 경우 `Sec-Fetch-Site`가 `same-origin` 또는 `same-site` + - JSON 또는 route inventory에 등록한 multipart/bodyless 계약 + - header/cookie equality와 token signature, binding kind, session/anonymous seed, organization scope, expiry +4. 인증 cookie가 없는 `cookie_authenticated` 요청은 CSRF token으로 사용자 identity를 만들지 않고 side effect 전에 `401 auth.required`로 닫는다. +5. `public_anonymous`와 `server_credential` route는 login cookie가 우연히 포함돼도 CSRF cookie 또는 사용자 principal을 사용하지 않는다. Public Chatbot, Public run과 app-secret webhook/run의 기존 audience를 유지한다. +6. CORS middleware는 CSRF middleware 바깥에서 허용된 Client origin이 안전한 `401/403` body를 읽게 한다. Public Conversation CORS boundary는 그 바깥에서 Public iframe 정책을 계속 소유하고, webhook query redaction은 최외곽 transport sanitizer를 유지한다. + +### 오류, 관측과 Client + +1. CSRF 실패는 항상 `403 auth.csrf_validation_failed`와 고정 message를 반환한다. 내부에서는 bounded reason, policy, method와 검증된 request ID만 metric/audit에 기록하며 token, cookie, Origin, session, organization과 path parameter 원문을 기록하지 않는다. +2. Client token은 module memory에만 저장하고 localStorage, sessionStorage, URL과 log에 남기지 않는다. 같은 scope의 동시 bootstrap은 하나로 합친다. +3. 공통 Axios client와 보호된 직접 fetch는 unsafe method에 token을 자동 첨부한다. CSRF 실패 시 PUT/DELETE 또는 idempotency key가 있는 요청만 새 token으로 최대 한 번 재시도한다. 일반 POST/PATCH는 자동 replay하지 않는다. +4. Workflow SSE의 same-origin Next proxy는 API host-only CSRF cookie를 직접 받을 수 없다. 이 단일 proxy는 엄격한 token 문자·길이 검사를 거친 `X-CSRF-Token`을 outbound `csrf_token` cookie로 복제하고, 원래 Origin, Fetch Metadata, organization과 request context를 Gateway에 전달한다. Gateway는 동일한 HMAC/session/scope 검증을 수행한다. + +### Enforcement와 배포 + +1. Development와 production은 별도 opt-in 없이 enforcement가 기본이다. `CSRF_ENFORCEMENT_MODE=disabled`는 `NODE_ENV=test`에서 기존 비-CSRF 단위 테스트를 격리하는 용도로만 허용하며 다른 환경의 disabled/unknown 값은 startup을 실패시킨다. +2. 구 Client와 신 Gateway, 신 Client와 구 Gateway의 혼합 revision은 지원 계약이 아니다. Frontend와 Gateway를 같은 maintenance release로 전환하고 readiness 뒤 트래픽을 열어야 한다. Rollback도 두 component를 같은 계약 revision으로 되돌린다. +3. Production observation/fail-open mode를 두지 않는다. 혼합 revision 무중단 전환이 필요해지면 별도의 versioned protocol ADR과 제거 기한을 먼저 정의한다. + +## Rationale + +- Signed token을 기존 검증된 `SECRET_KEY`에서 domain separation해 파생하면 secret 원문이나 신규 durable session 저장소 없이 현재 인증 구조에 맞출 수 있다. +- Route audience를 먼저 분류하면 login cookie의 우연한 포함이 Public 또는 server credential route의 principal을 바꾸지 않는다. +- Exact Origin, Fetch Metadata, content type와 token을 독립적으로 검증하면 어느 한 방어 계층의 오구성이 곧바로 mutation 허용으로 이어지지 않는다. +- Non-idempotent 자동 replay를 금지하면 token expiry 복구가 중복 side effect로 바뀌지 않는다. + +## Affected Files + +- `apps/gateway/application/csrf/*` +- `apps/gateway/adapters/csrf/*` +- `apps/gateway/composition/csrf.py` +- `apps/gateway/middleware/csrf.py` +- `apps/gateway/api/v1/endpoints/auth.py` +- `apps/gateway/main.py` +- `apps/shared/schemas/csrf.py` +- `apps/client/lib/csrfToken.ts`, `apps/client/lib/apiClient.ts` +- 보호 Axios/direct-fetch consumer와 Workflow stream proxy +- Auth·Architecture·Chatbot/Memory 문서 및 관련 테스트 + +## Consequences + +- 첫 unsafe browser mutation 전에 safe CSRF bootstrap 요청이 하나 추가될 수 있다. +- Organization 변경, 인증 전환과 10분 만료 뒤 새 token이 필요하다. +- 잘못된 Origin, content type, stale scope 또는 누락 token은 endpoint와 side effect에 도달하지 않는다. +- 테스트 프로필은 중앙 middleware 자체 테스트와 route inventory test를 제외한 기존 API 테스트에서 enforcement를 비활성화할 수 있다. +- Public와 server credential 호출에는 CSRF header를 추가하지 않으며 해당 route가 cookie principal을 사용하지 않는 별도 계약이 계속 필요하다. + +## Follow-up Review + +- 새 unsafe route와 multipart route는 route inventory, audience와 protected-resource 완결성 증거를 함께 추가한다. +- 인증형 내부 Chatbot과 durable Conversation Memory route는 동일한 cookie policy를 상속하되 별도 access permission, storage namespace와 retention 계약을 구현한다. +- 실제 배포 절차에서 Frontend/Gateway maintenance cutover와 rollback이 같은 contract revision을 유지하는지 검증한다. +- 향후 server-side user session을 도입하면 외부 header/error 계약을 유지하면서 token binding validator 교체를 검토한다. diff --git a/docs/features/auth/api_spec.md b/docs/features/auth/api_spec.md index f35f1d7f2..e6a2fd8f2 100644 --- a/docs/features/auth/api_spec.md +++ b/docs/features/auth/api_spec.md @@ -1,7 +1,6 @@ # Auth API Spec Status: Draft -Verified Against: feature/mba-234 @ 647913b9 기본 경로: `/api/v1` @@ -9,21 +8,45 @@ Verified Against: feature/mba-234 @ 647913b9 | 메서드 | 경로 | 설명 | 인증 | | --- | --- | --- | --- | -| POST | `/auth/signup` | 이메일/비밀번호 사용자를 생성하고, 6시간짜리 JWT 세션을 만들며, `auth_token` 쿠키를 설정한 뒤 사용자/세션 데이터를 반환한다. | 공개 | -| POST | `/auth/login` | 분산 account/network admission 뒤 이메일/비밀번호 사용자를 인증하고, 6시간짜리 JWT 세션을 만들며, `auth_token` 쿠키를 설정한 뒤 사용자/세션 데이터를 반환한다. | 공개 | -| POST | `/auth/logout` | `auth_token` 쿠키를 삭제하고 로그아웃 확인 응답을 반환한다. | 공개 | +| GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. | Safe bootstrap; resource permission 없음 | +| POST | `/auth/signup` | 이메일/비밀번호 사용자를 생성하고, 6시간짜리 JWT 세션을 만들며, `auth_token` 쿠키를 설정한 뒤 사용자/세션 데이터를 반환한다. | Pre-auth CSRF; resource permission 없음 | +| POST | `/auth/login` | 분산 account/network admission 뒤 이메일/비밀번호 사용자를 인증하고, 6시간짜리 JWT 세션을 만들며, `auth_token` 쿠키를 설정한 뒤 사용자/세션 데이터를 반환한다. | Pre-auth CSRF; resource permission 없음 | +| POST | `/auth/logout` | auth/CSRF cookie를 삭제하고 로그아웃 확인 응답을 반환한다. | Pre-auth 또는 auth-bound CSRF; resource permission 없음 | | GET | `/auth/me` | 쿠키에서 `auth_token`을 읽어 검증하고 현재 사용자/세션 데이터를 반환한다. | `auth_token` 쿠키 필요 | | GET | `/auth/google/login` | 선택적 safe `next`를 서명 세션에 저장하고 Google 인증 화면으로 리디렉션한다. | 공개 | | GET | `/auth/google/callback` | Google OAuth를 완료하고, 소셜 사용자를 생성하거나 갱신하며, `auth_token` 쿠키를 설정한 뒤 1회용 safe 복귀 경로로 리디렉션한다. | Google OAuth 콜백 | ## Request And Response Models +### `GET /auth/csrf` + +요청 본문: 없음. + +선택 입력: + +| 입력 | 의미 | +| --- | --- | +| `auth_token` cookie | 존재하면 유효한 사용자 session인지 검증하고 token을 그 cookie에 결박한다. Invalid cookie는 `401 auth.invalid`과 삭제 Set-Cookie를 반환하며 anonymous로 같은 응답에서 전환하지 않는다. | +| `X-Organization-Id` | 존재하면 token MAC의 active organization scope에 포함한다. 없으면 account scope를 사용한다. | +| `csrf_anon_seed` cookie | auth cookie가 없을 때 유효한 random seed를 재사용하며, 없거나 malformed이면 새 seed를 발급한다. | + +성공 응답: `200 OK`. + +```json +{ + "token": "", + "expires_at": "2026-07-29T00:10:00Z" +} +``` + +응답은 같은 token을 host-only HttpOnly `csrf_token` cookie로 설정한다. Anonymous bootstrap은 host-only HttpOnly `csrf_anon_seed`도 설정한다. `Cache-Control: no-store`, `Pragma: no-cache`가 필수다. Token은 `v1.expiry.nonce.mac` 형식이며 auth cookie, user와 organization 원문을 포함하지 않는다. ### `POST /auth/signup` 요청 본문: | 필드 | 타입 | 필수 | 비고 | | --- | --- | --- | --- | +| GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. | Safe bootstrap; resource permission 없음 | | `email` | `EmailStr` | 예 | Pydantic 이메일 검증을 통과해야 한다. | | `password` | `string` | 예 | salt가 포함된 SHA-256 비밀번호 해시로 저장된다. | | `name` | `string` | 예 | 사용자 표시 이름이다. | @@ -36,6 +59,7 @@ Verified Against: feature/mba-234 @ 647913b9 | 필드 | 타입 | 필수 | 비고 | | --- | --- | --- | --- | +| GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. | Safe bootstrap; resource permission 없음 | | `email` | `EmailStr` | 예 | Pydantic 이메일 검증을 통과해야 한다. | | `password` | `string` | 예 | 저장된 비밀번호 해시와 비교된다. | @@ -99,6 +123,7 @@ Query: | 필드 | 타입 | 필수 | 제약 | | --- | --- | --- | --- | +| GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. | Safe bootstrap; resource permission 없음 | | `next` | `string` | 아니요 | 최대 2,048자. Gateway가 상대 same-origin 경로로 다시 검증하며 안전하지 않으면 `/dashboard`를 저장한다. | 성공 응답: Google OAuth 인증 화면으로 이동하는 리디렉션 응답. @@ -113,6 +138,32 @@ OAuth 입력: Google OAuth 콜백 요청과 세션 상태. 성공 응답: `302 Found`, `auth_token` 쿠키 설정, 소비된 safe `next`로 리디렉션. 복귀 컨텍스트가 없거나 만료·재사용·형식 오류이면 `/dashboard`를 사용한다. Gateway 호스트가 정확히 `localhost:8000` 또는 `127.0.0.1:8000`이면 각각 대응하는 client origin의 3000 포트로 이동한다. `AUTH_FRONTEND_ORIGIN`이 설정되어 있으면 Gateway가 검증한 해당 HTTP(S) origin을 사용한다. +### CSRF mutation 요청 계약 + +`cookie_authenticated`와 `pre_auth_session`으로 분류된 `POST`, `PUT`, `PATCH`, `DELETE`는 다음 값을 함께 보내야 한다. + +| 입력 | 계약 | +| --- | --- | +| `Origin` | `CORS_ORIGINS`의 canonical origin 중 하나와 exact match | +| `Sec-Fetch-Site` | Header가 있으면 `same-origin` 또는 `same-site` | +| `X-CSRF-Token` | `/auth/csrf` body에서 받은 token | +| `csrf_token` cookie | Header token과 같은 host-only HttpOnly cookie | +| `X-Organization-Id` | Organization-scoped token을 발급받은 요청은 같은 값 | +| `Content-Type` | 기본 `application/json`(선택적 `charset=utf-8`), inventory에 등록된 multipart 또는 bodyless route만 예외 | + +현재 명시 예외는 Public Chatbot/Public run의 `public_anonymous`, app secret run/webhook의 `server_credential`, signed one-time state를 사용하는 OAuth GET route다. Login cookie가 예외 route에 포함돼도 cookie user principal이나 private permission으로 승격하지 않는다. + +CSRF 실패는 endpoint body parsing보다 먼저 아래 고정 응답으로 종료된다. + +```json +{ + "error": { + "code": "auth.csrf_validation_failed", + "message": "CSRF validation failed.", + "request_id": "..." + } +} +``` ### 공통 응답 모델 `LoginResponse`: @@ -157,12 +208,21 @@ OAuth 입력: Google OAuth 콜백 요청과 세션 상태. ### 쿠키 동작 +CSRF cookie 계약: + +| cookie | path | max-age | JS 접근 | domain | 환경 속성 | +| --- | --- | ---: | --- | --- | --- | +| `csrf_token` | `/api/v1` | 600초 | HttpOnly | 미설정(host-only) | loopback: SameSite=Lax, non-local: Secure/SameSite=None | +| `csrf_anon_seed` | `/api/v1` | 600초 | HttpOnly | 미설정(host-only) | loopback: SameSite=Lax, non-local: Secure/SameSite=None | + +Signup, login, OAuth 성공과 logout은 두 CSRF cookie를 삭제한다. Invalid auth cookie가 있는 bootstrap은 auth/CSRF cookie를 삭제하고 `401`을 반환한다. Client는 삭제 반영 뒤 anonymous bootstrap을 최대 한 번 재시도한다. 회원가입, 로그인, Google 콜백은 `auth_token`을 `max_age` 6시간의 HTTP-only 쿠키로 설정한다. 이메일/비밀번호 signup 및 login의 경우: | 환경 | `path` | `secure` | `samesite` | `domain` | | --- | --- | --- | --- | --- | +| GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. | Safe bootstrap; resource permission 없음 | | Localhost 또는 `127.0.0.1` 호스트 | `/` | `false` | `lax` | 설정하지 않음 | | Non-local 호스트 | `/` | `true` | `none` | `COOKIE_DOMAIN`, 또는 마지막 두 호스트 라벨 앞에 `.`를 붙인 값 | @@ -200,6 +260,9 @@ HTTP 예외는 다음 형식으로 반환된다. | 상태 | 엔드포인트 | 상세 / 본문 | 조건 | | --- | --- | --- | --- | +| 401 | `GET /auth/csrf` | `auth.invalid` envelope과 auth/CSRF cookie 삭제 | 존재하는 `auth_token`이 유효하지 않다. Anonymous fallback은 같은 응답에서 수행하지 않는다. | +| 403 | 모든 cookie/pre-auth unsafe route | `auth.csrf_validation_failed` 고정 envelope | Origin, Fetch Metadata, content type, token equality/signature/binding/scope/expiry 중 하나가 실패한다. | +| GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. | Safe bootstrap; resource permission 없음 | | 400 | `POST /auth/signup` | `이미 등록된 이메일입니다` | 이메일이 이미 존재한다. | | 400 | `GET /auth/google/callback` | `OAuth authentication failed` | token 교환, token/user info 타입, user info 조회 또는 email 검증에 실패한다. Provider exception 원문은 반환하지 않는다. | | 503 | `GET /auth/google/login` | `OAuth login is unavailable` | provider authorization 시작에 실패한다. Exception 원문은 반환하지 않는다. | @@ -219,7 +282,7 @@ HTTP 예외는 다음 형식으로 반환된다. `GET /auth/me`는 `AuthService.get_user_from_token`을 통해 `auth_token` 쿠키를 검증해서 인증한다. -회원가입, 로그인, 로그아웃, Google OAuth 진입/콜백은 공개 인증 생명주기 엔드포인트이다. 공개라는 의미는 resource permission이 필요 없다는 뜻이며 password login admission을 우회한다는 뜻이 아니다. +회원가입, 로그인, 로그아웃, Google OAuth 진입/콜백은 resource permission이 없는 인증 생명주기 엔드포인트이다. 공개라는 의미는 CSRF나 password login admission을 우회한다는 뜻이 아니다. Unsafe signup/login/logout은 pre-auth 또는 auth-bound CSRF 검증을 먼저 통과한다. OAuth GET은 signed one-time state 계약을 사용한다. 회원가입, 로그인, 로그아웃, Google 로그인 성공, 인증 실패는 Gateway에 감사 이벤트를 기록한다. Password login은 성공에 `user.login`, invalid/inactive/limited/limiter-unavailable/internal-error에 `user.login_failed`를 사용하고 safe reason code로 구분한다. Password login이 전용 감사를 기록한 `401/403`은 전역 `auth.permission_denied` 감사를 중복 생성하지 않는다. Login audit의 성공 actor snapshot은 opaque user ID와 표시 이름만 포함하며 raw email, IP/forwarded header, HMAC fingerprint, Redis key와 exception message를 저장하지 않는다. @@ -232,13 +295,15 @@ HTTP 예외는 다음 형식으로 반환된다. - Login limiter는 기존 Redis host/port/password와 전용 logical DB를 사용한다. Admission dependency가 실패하면 password login만 `503`으로 닫고 기존 JWT session 검증은 유지한다. - Non-secret 설정은 `AUTH_LOGIN_LIMITER_REDIS_DB`(기본 `2`), `AUTH_LOGIN_LIMITER_POLICY_VERSION`, `AUTH_LOGIN_FINGERPRINT_PRIMARY_VERSION`, `AUTH_LOGIN_TRUSTED_PROXY_CIDRS`를 사용한다. Production HMAC keyring JSON은 `AUTH_LOGIN_FINGERPRINT_KEYS` Secret으로 주입하고 실제 값은 manifest, log와 진단 응답에 출력하지 않는다. - 초기 capacity/full-refill 값은 ADR의 versioned policy로 고정한다. 값을 변경하면 policy version, requirements와 Redis integration test를 함께 갱신한다. -- 이 CORS allowlist는 브라우저가 credentialed JSON 요청을 보내는 현행 제품 경계다. 별도 CSRF token과 exact-Origin 검사는 Target이며 현재 구현으로 표현하지 않는다. +- CORS allowlist와 CSRF exact-Origin 검사는 같은 canonical origin parser를 사용하지만 독립적으로 판정한다. CORS middleware는 CSRF middleware 바깥에서 허용된 Client가 고정 `401/403` 응답을 읽게 한다. +- CSRF enforcement는 development/production에서 기본 활성화된다. `CSRF_ENFORCEMENT_MODE=disabled`는 `NODE_ENV=test`에서만 허용하며 다른 환경의 disabled/unknown 값은 startup 오류다. +- 신·구 Frontend/Gateway 혼합 revision은 지원하지 않으며 같은 maintenance release로 전환·rollback한다. ## Target Runtime Principal Boundary - `auth_token` cookie/JWT가 검증한 current user만 authenticated identity를 제공한다. Organization membership, resource permission, LLM credential과 billing scope는 각 소유 도메인이 별도로 평가한다. - `Authorization: Conversation `과 `Authorization: Purge `는 public Conversation Memory capability이며 `get_current_user`, `/auth/me`와 authenticated route의 user identity로 수용하지 않는다. -- Public Chatbot route는 login cookie가 함께 있어도 anonymous public audience를 유지한다. Current authenticated internal Chatbot은 별도 route, cookie auth와 configured credentialed CORS/JSON-only mutation 경계를 사용한다. 별도 access grant, CSRF token과 exact-Origin 검사는 Target이며 아직 구현되지 않았다. +- Public Chatbot route는 login cookie가 함께 있어도 anonymous public audience를 유지한다. Cookie-authenticated Gateway mutation은 중앙 CSRF token/exact-Origin 경계를 사용한다. Authenticated internal Chatbot은 이 공통 경계를 상속해야 하지만 별도 access grant와 durable Conversation namespace는 후속 범위다. - Public create/close/reset/delete request와 capability lifecycle AuditLog는 `actor_id=null`, `actor_type='public'`을 사용한다. 비동기 purge completion은 `system` actor를 사용한다. App/deployment owner, credential/billing principal과 capability reference를 user actor로 합성하지 않는다. 이 section은 ADR-0030 target integration contract이며 현재 auth endpoint 구현이 Conversation Memory capability를 이미 제공한다는 뜻이 아니다. diff --git a/docs/features/auth/component_spec.md b/docs/features/auth/component_spec.md index 6bc7d936c..ed918a5f2 100644 --- a/docs/features/auth/component_spec.md +++ b/docs/features/auth/component_spec.md @@ -219,6 +219,36 @@ Status: Draft - 성공 시 로컬 저장소에서 `moduly_session_token`과 `moduly_user`를 제거하고 `/auth/login`으로 이동한다. - 실패 시 `로그아웃 실패:`를 기록하고 이동하지 않는다. +## CSRF Browser Boundary + +### Gateway Route Policy Registry + +- Gateway composition은 모든 unsafe route를 `cookie_authenticated`, `pre_auth_session`, `public_anonymous`, `server_credential` 중 하나로 분류한다. +- `get_current_user` dependency route는 자동으로 cookie policy가 되며, 자체 cookie helper를 사용하는 Team/Permission route와 Public/server 예외는 exact method/path inventory로 관리한다. +- 미분류 route, stale 예외, duplicate unsafe route와 승인되지 않은 protected media type은 application startup과 architecture test를 실패시킨다. +- OAuth login/callback GET은 custom header 대신 signed one-time state를 사용하는 별도 safe-method 예외다. + +### Gateway CSRF Guard + +- Guard는 endpoint보다 먼저 Origin, Fetch Metadata, content type, double-submit equality와 HMAC/session/scope를 검증한다. +- 실패 body는 고정 `auth.csrf_validation_failed`만 노출한다. Bounded reason은 metric/audit adapter 내부에서만 사용한다. +- 인증 cookie가 없는 protected mutation은 token을 identity로 사용하지 않고 `401 auth.required`로 종료한다. +- CORS는 guard 바깥에서 허용 origin이 오류 응답을 읽게 하고, Public Conversation CORS와 webhook query redaction의 더 바깥 경계를 유지한다. + +### Client CSRF Token Manager + +- `csrfToken.ts`는 `/auth/csrf` 응답을 runtime 검증하고 token, expiry와 organization/account scope를 module memory에만 저장한다. +- 같은 scope의 동시 요청은 하나의 bootstrap Promise를 공유한다. Reload와 tab은 token을 공유하지 않는다. +- Axios request interceptor는 active organization header가 결정된 뒤 unsafe request에 `X-CSRF-Token`을 추가한다. Response interceptor는 고정 CSRF error에서 cache를 지우며 PUT/DELETE 또는 idempotency key 요청만 최대 한 번 재시도한다. +- `csrfFetch`는 Settings, Wizard, RAG stream과 Workflow stream처럼 Axios를 통하지 않는 protected mutation에 같은 계약을 제공한다. Public Chatbot/Public run, app-secret 실행과 presigned object upload에는 적용하지 않는다. +- Signup/login/logout 성공, OAuth navigation과 `nodease-active-organization-changed` event는 cached token을 폐기한다. Invalid HttpOnly auth cookie bootstrap `401`은 cookie 삭제 반영을 위해 최대 한 번만 재시도한다. + +### Workflow Stream Proxy + +- Browser는 same-origin `/stream-api/workflows/{workflowId}`에 CSRF header를 보낸다. +- Next route는 original Origin, `Sec-Fetch-Site`, CSRF token, organization과 bounded request context만 전달한다. +- API host-only CSRF cookie가 Next host에 전달되지 않는 경우를 위해 strict 문자·길이 검사를 통과한 header token만 outbound `csrf_token` cookie로 복제한다. 기존 `csrf_token` cookie는 제거한 뒤 하나만 전달하며 Authorization은 전달하지 않는다. +- Gateway가 최종 signature/session/organization 검증을 수행하므로 proxy 복제는 인증이나 권한 판단을 대체하지 않는다. ## Accessibility ### LoginPage diff --git a/docs/features/auth/requirements.md b/docs/features/auth/requirements.md index 8f9451c1d..3cf5731f4 100644 --- a/docs/features/auth/requirements.md +++ b/docs/features/auth/requirements.md @@ -5,7 +5,7 @@ Related Features: organization, audit-tracing, workflow, deployment, chatbot-dep ## Purpose -Auth 기능은 사용자의 인증 생명주기를 담당한다. 현재 구현 범위는 이메일/비밀번호 회원가입, 분산 abuse prevention이 적용된 이메일/비밀번호 로그인, Google OAuth 로그인, JWT 세션 쿠키 발급/검증/삭제, 현재 사용자 조회, 클라이언트 인증 리다이렉트 처리이다. +Auth 기능은 사용자의 인증 생명주기를 담당한다. 현재 구현 범위는 이메일/비밀번호 회원가입, 분산 abuse prevention이 적용된 이메일/비밀번호 로그인, Google OAuth 로그인, JWT 세션 쿠키 발급/검증/삭제, 현재 사용자 조회, 클라이언트 인증 리다이렉트 처리와 cookie-authenticated unsafe API의 중앙 CSRF 방어이다. Auth는 보호된 Gateway API가 `auth_token` 쿠키에서 현재 사용자를 식별할 수 있는 공통 인증 경계를 제공한다. 신규 사용자 생성 시 기본 organization 컨텍스트를 준비하고, 주요 인증 성공/실패 이벤트를 audit로 기록한다. Resource permission 판정, organization 관리, audit 조회/정책 처리는 auth 자체의 책임 범위가 아니다. @@ -93,8 +93,31 @@ Auth는 보호된 Gateway API가 `auth_token` 쿠키에서 현재 사용자를 - AUTH-REQ-068: Production Helm 배포에서 Ingress가 활성화되면 실제 peer topology에 맞는 trusted proxy CIDR이 필수여야 한다. Direct Gateway 배포는 빈 목록으로 forwarded address를 무시할 수 있으며, 광역 CIDR을 추측해 기본값으로 제공하지 않아야 한다. - AUTH-REQ-069: Bundled Docker Compose는 development mode를 명시적으로 기본 적용해야 하며, 운영 사용 시 `NODE_ENV=production`과 dedicated login fingerprint keyring을 설정해 production startup 검증을 활성화해야 한다. +- AUTH-REQ-070: 시스템은 `GET /auth/csrf`에서 10분 만료 signed double-submit token을 응답 body와 host-only HttpOnly `csrf_token` cookie로 발급해야 한다. +- AUTH-REQ-071: CSRF token은 auth cookie 또는 random anonymous seed, binding 종류와 normalized active organization/account scope에 domain-separated HMAC으로 결박해야 하며 이 값들의 원문을 token payload에 포함하지 않아야 한다. +- AUTH-REQ-072: 인증 cookie가 없는 bootstrap은 host-only HttpOnly `csrf_anon_seed`와 pre-auth token을 발급해야 한다. 유효하지 않은 auth cookie는 anonymous로 조용히 전환하지 않고 `401 auth.invalid`로 닫고 invalid auth/CSRF cookie를 삭제해야 한다. +- AUTH-REQ-073: `csrf_token`과 `csrf_anon_seed`는 `path=/api/v1`, 600초, HttpOnly, host-only여야 한다. Non-local에서는 Secure/SameSite=None, loopback에서는 SameSite=Lax를 사용해야 한다. +- AUTH-REQ-074: Signup, password login, Google OAuth 성공과 logout은 stale CSRF/anonymous cookie를 삭제해야 한다. +- AUTH-REQ-075: 모든 unsafe Gateway route는 `cookie_authenticated`, `pre_auth_session`, `public_anonymous`, `server_credential` 중 정확히 하나로 분류되어야 하며 미분류, 중복 또는 존재하지 않는 명시 예외는 startup과 architecture test를 실패시켜야 한다. +- AUTH-REQ-076: Cookie/pre-auth mutation은 body parsing과 side effect 전에 configured exact Origin을 요구해야 한다. `Sec-Fetch-Site`가 있으면 `same-origin` 또는 `same-site`만 허용해야 한다. +- AUTH-REQ-077: Cookie/pre-auth mutation은 기본적으로 canonical JSON만 허용하고 route inventory에 등록된 multipart와 bodyless 요청만 예외로 허용해야 한다. +- AUTH-REQ-078: Cookie/pre-auth mutation은 `X-CSRF-Token`과 CSRF cookie의 equality, signature, version, expiry, binding kind, auth/anonymous binding과 organization/account scope를 검증해야 한다. +- AUTH-REQ-079: CSRF 검증은 controller, credential verifier, DB, queue, storage, retrieval과 provider 호출보다 먼저 수행되어야 한다. 인증 cookie가 없는 cookie-authenticated mutation은 side effect 전에 `401 auth.required`로 닫아야 한다. +- AUTH-REQ-080: Public anonymous와 server credential route는 login cookie 존재 여부로 audience나 principal을 바꾸지 않고 CSRF token을 private 권한 근거로 사용하지 않아야 한다. +- AUTH-REQ-081: 모든 CSRF 거부는 `403 auth.csrf_validation_failed`와 고정 message를 반환하고, 내부 audit/metric에는 bounded reason, policy, method와 safe request ID만 기록해야 한다. Token, cookie, Origin, session, organization과 path parameter 원문은 기록하지 않아야 한다. +- AUTH-REQ-082: Client는 CSRF token을 module memory에만 보관하고 같은 scope bootstrap을 single-flight해야 한다. LocalStorage, sessionStorage, URL과 log에 token을 남기지 않아야 한다. +- AUTH-REQ-083: Client는 login/signup/logout/OAuth와 active organization 변경 때 cached token을 폐기해야 한다. Invalid auth cookie를 삭제한 bootstrap `401`은 한 번만 재시도할 수 있다. +- AUTH-REQ-084: Client는 CSRF 실패 뒤 PUT/DELETE 또는 idempotency key가 있는 요청만 token refresh 후 최대 한 번 재시도하고, 일반 POST/PATCH를 자동 replay하지 않아야 한다. +- AUTH-REQ-085: OAuth GET navigation/callback은 custom CSRF header 대신 기존 signed, expiring, one-time state를 유지해야 한다. +- AUTH-REQ-086: `CORS_ORIGINS`는 CSRF exact-Origin allowlist에 재사용하되 CORS 허용을 CSRF 성공으로 간주하지 않아야 한다. CORS middleware는 허용된 Client가 CSRF `401/403`을 읽을 수 있도록 CSRF middleware 바깥에 있어야 한다. +- AUTH-REQ-087: Workflow stream proxy는 original Origin, Fetch Metadata, CSRF token과 organization context를 전달하고, 엄격히 검증한 header token만 outbound host-only CSRF cookie로 복제해야 한다. 다른 proxy/public adapter는 이 예외를 일반화하지 않아야 한다. +- AUTH-REQ-088: CSRF enforcement는 development와 production에서 기본 활성화되어야 한다. Disabled mode는 `NODE_ENV=test`에서만 허용하고 production disabled/unknown mode는 startup을 실패시켜야 한다. ## Policies And Edge Cases +- CORS, SameSite와 CSRF token은 서로 대체하지 않는 독립 방어 계층이다. +- Public iframe parent allowlist와 CSRF Origin allowlist는 목적이 다르며 서로 재사용하지 않는다. +- 신·구 Client/Gateway 혼합 revision은 지원하지 않는다. Frontend와 Gateway는 같은 maintenance release로 전환하고 함께 rollback한다. +- Production observation/fail-open mode는 제공하지 않는다. - Auth 엔드포인트 자체에는 resource permission 검사가 구현되어 있지 않다. - 사용자 테이블의 email은 unique이며, social id도 값이 있으면 unique이다. - 비밀번호는 서버에서 salt가 포함된 SHA-256 해시로 저장된다. diff --git a/docs/features/auth/test_cases.md b/docs/features/auth/test_cases.md index 71c43b258..8529650ad 100644 --- a/docs/features/auth/test_cases.md +++ b/docs/features/auth/test_cases.md @@ -117,6 +117,34 @@ Status: Draft | AUTH-TC-I007 | HMAC rotation overlap은 old limit을 우회하지 않아야 한다. | Old version bucket을 소진한 뒤 new primary/old previous keyring으로 admission한다. | New bucket이 비어 있어도 blocked; all-or-nothing 계약에 따라 어떤 version/dimension state도 추가 소비하지 않음. | | AUTH-TC-I008 | Success reset은 active version account/pair key만 제거해야 한다. | Current+previous keyring에서 성공 reset한다. | 두 version account/pair 삭제, network key 유지. | +## CSRF Boundary Tests + +| ID | 검증 조건 | 최소 실패 조건 | 기대 결과 | +| --- | --- | --- | --- | +| AUTH-TC-CS001 | Signed token은 session/organization 원문을 포함하지 않고 canonical version/expiry/nonce/MAC만 사용해야 한다. | Token에서 auth cookie 또는 organization sentinel을 검색하거나 비정규 expiry/Base64를 검증한다. | 원문 없음, 비정규 encoding은 `token_invalid`. | +| AUTH-TC-CS002 | Header/cookie double-submit 값은 constant-time equality와 signature를 모두 통과해야 한다. | Header/cookie 누락·불일치·MAC 변조 중 하나를 보낸다. | Fixed `403 auth.csrf_validation_failed`, endpoint 미진입. | +| AUTH-TC-CS003 | Token은 다른 auth cookie, anonymous seed, binding kind와 organization/account scope에서 replay되지 않아야 한다. | 한 binding에서 발급한 token을 다른 binding/scope에 사용한다. | `token_invalid`. | +| AUTH-TC-CS004 | Expired token과 현재 시각보다 TTL+skew를 초과해 미래인 token을 거부해야 한다. | 만료 뒤 또는 발급 시각보다 31초 이전 시각에서 검증한다. | `token_expired` 또는 `token_invalid`. | +| AUTH-TC-CS005 | Anonymous `/auth/csrf` bootstrap은 no-store body token과 host-only HttpOnly token/seed cookie를 발급해야 한다. | Auth cookie 없이 GET한다. | `200`, body/cookie token 일치, Domain 미설정, Path `/api/v1`. | +| AUTH-TC-CS006 | Authenticated bootstrap은 auth cookie를 검증하고 stale anonymous seed를 삭제해야 한다. | Valid auth cookie와 기존 seed를 함께 보낸다. | Auth-bound token, seed `Max-Age=0`. | +| AUTH-TC-CS007 | Invalid auth cookie bootstrap은 anonymous로 같은 응답에서 전환하지 않아야 한다. | Invalid auth cookie로 GET한다. | `401 auth.invalid`, auth/CSRF cookie 삭제; Client는 한 번만 재bootstrap. | +| AUTH-TC-CS008 | Signup/login/logout은 pre-auth 또는 auth-bound token 없이 credential/DB lifecycle에 진입하지 않아야 한다. | Exact Origin은 있지만 token이 없거나 forged token이다. | Fixed 403, endpoint effect 0. | +| AUTH-TC-CS009 | Login/signup/OAuth success와 logout은 stale CSRF cookie family를 삭제해야 한다. | 각 성공 응답의 Set-Cookie를 검사한다. | `csrf_token`, `csrf_anon_seed` 삭제. | +| AUTH-TC-CS010 | 모든 unsafe route는 정확히 하나의 route policy를 가져야 한다. | 신규 unauthenticated POST를 registry 없이 추가하거나 명시 예외를 삭제한다. | Startup/architecture test 실패. | +| AUTH-TC-CS011 | Public run/Chatbot과 app-secret run/webhook은 login cookie가 있어도 cookie policy로 바뀌지 않아야 한다. | Login/CSRF cookie를 예외 route에 함께 보낸다. | 기존 anonymous/server credential audience 유지. | +| AUTH-TC-CS012 | Missing/null/unlisted Origin은 body parsing과 side effect 전에 거부해야 한다. | Valid token에 Origin을 누락하거나 attacker Origin을 보낸다. | Fixed 403, body/endpoint effect 0. | +| AUTH-TC-CS013 | Fetch Metadata가 있으면 same-origin/same-site만 허용해야 한다. | `Sec-Fetch-Site: cross-site`를 보낸다. | Fixed 403; header가 없고 다른 증거가 valid이면 호환 허용. | +| AUTH-TC-CS014 | JSON route는 UTF-8 JSON만 허용해야 한다. | text/plain, form, JSON profile/latin1 parameter를 보낸다. | `content_type_invalid`, body/endpoint effect 0. | +| AUTH-TC-CS015 | 승인 multipart와 bodyless route만 해당 content 예외를 사용해야 한다. | Valid multipart upload, malformed boundary, 빈 DELETE를 각각 보낸다. | Valid multipart/DELETE 성공, malformed boundary 403. | +| AUTH-TC-CS016 | 인증 cookie 없는 cookie-authenticated mutation은 token으로 identity를 만들지 않아야 한다. | Origin과 payload만 보호 route에 보낸다. | Side effect 전 `401 auth.required`. | +| AUTH-TC-CS017 | CSRF denial 관측값은 bounded label만 포함해야 한다. | Token/Origin/session/org/path sentinel을 실패 요청에 주입한다. | Response/log/audit/metric에 sentinel 없음; reason/policy/method/request ID만 기록. | +| AUTH-TC-CS018 | Production/development enforcement는 기본 활성화되고 test만 disable할 수 있어야 한다. | Production disabled 또는 unknown mode로 구성한다. | Startup 오류; `NODE_ENV=test` disabled만 허용. | +| AUTH-TC-CS019 | Client token manager는 same-scope single-flight와 memory-only storage를 유지해야 한다. | 동시 unsafe 요청과 storage spy를 사용한다. | Bootstrap 1회, local/session storage write 0회. | +| AUTH-TC-CS020 | Organization/auth lifecycle은 cached token을 폐기해야 한다. | Organization 변경, signup/login/logout/OAuth 전환 뒤 다음 mutation을 보낸다. | 새 scope/session bootstrap; old token replay 실패. | +| AUTH-TC-CS021 | Fixed CSRF 오류의 자동 replay는 안전한 요청 한 번으로 제한해야 한다. | PUT과 idempotency 없는 POST에서 첫 요청을 403으로 만든다. | PUT 최대 1회 재시도, POST 재시도 0회, 무한 loop 없음. | +| AUTH-TC-CS022 | 보호 direct fetch는 token과 active organization header를 함께 보내야 한다. | Settings/Wizard/RAG stream mutation을 호출한다. | `X-CSRF-Token`, credential, 동일 organization scope 포함. | +| AUTH-TC-CS023 | Workflow stream proxy는 browser security context와 host-only token cookie를 안전하게 중계해야 한다. | Origin, Fetch Metadata, header token, stale cookie와 Authorization을 함께 보낸다. | Context/token 전달, stale CSRF cookie 교체, Authorization 미전달; Gateway가 최종 검증. | +| AUTH-TC-CS024 | Middleware 순서는 허용된 Client가 안전한 CSRF 오류를 읽고 Public/webhook 외곽 경계를 유지해야 한다. | `app.user_middleware` 순서를 검사한다. | Webhook redaction → Public CORS → credentialed CORS → CSRF → Session 순서. | ## Component And Hook Tests | ID | 검증 조건 | 최소 실패 조건 | 기대 결과 | diff --git a/docs/features/chatbot-deployment/api_spec.md b/docs/features/chatbot-deployment/api_spec.md index 1186c1be1..42891610d 100644 --- a/docs/features/chatbot-deployment/api_spec.md +++ b/docs/features/chatbot-deployment/api_spec.md @@ -31,7 +31,7 @@ Status: Draft ## Target Runtime Surface Separation - `public_chatbot`: Conversation Access Grant나 server session 없이 client-held history만 사용하고 login cookie가 있어도 anonymous public-only RAG로 평가한다. -- `authenticated_internal_chatbot`: 현재는 cookie authentication, configured credentialed JSON/CORS 경계, active membership, workflow `execute`, current user KB permission으로 실행한다. 현재 구현을 CSRF token/exact-Origin 완료로 표현하지 않는다. Target에서는 별도 내부 Chatbot 이용 권한, CSRF token, exact Origin과 독립 Conversation Session namespace를 추가한다. +- `authenticated_internal_chatbot`: 현재 cookie-authenticated mutation은 ADR-0073의 signed CSRF token, exact Origin, Fetch Metadata와 route-owned content-type 경계를 통과한다. 이는 공통 browser admission 완결이며 별도 내부 Chatbot 이용 권한과 독립 Conversation Session namespace를 대신하지 않는다. - 두 surface는 시각 Chatbot component만 재사용한다. Public route의 authentication/audience를 조건부 완화하거나 public grant를 execution subject로 승격하지 않는다. - Public iframe parent는 [ADR-0043](../../decisions/ADR-0043-deployment-browser-origin-and-embedding-boundary.md)의 deployment-owned versioned `browser_access_policy`와 CSP `frame-ancestors`가 소유한다. Iframe first-party API와 external direct JavaScript CORS는 별도 경계이며 client/environment fallback으로 parent를 허용하지 않는다. @@ -175,7 +175,7 @@ Request body: - `inputs`에 workflow schema가 선언한 `conversation_id` 또는 `memory_mode`가 있으면 업무 입력으로 보존한다. typed control과 선언되지 않은 legacy `inputs.conversation_id`를 동시에 보내는 모호한 요청은 `400`으로 거부한다. - 기존 인증 caller의 legacy reserved input은 schema collision이 없는 범위에서만 임시 호환하며 string, 최대 255자, control character 금지 조건을 적용한다. Public 실행에는 이 호환 계약을 적용하지 않는다. - 요청 `Content-Type`의 media type은 정확히 `application/json`이어야 한다(`charset` parameter 허용). 누락, `text/plain`, `application/x-www-form-urlencoded`, `multipart/form-data`는 body/schema 처리나 workflow dispatch 전에 `415`로 거부한다. -- Browser credentialed JSON 호출은 configured `CORS_ORIGINS`의 명시적 HTTP(S) origin만 preflight를 통과한다. Wildcard credentialed origin은 Gateway 구성 시 거부한다. 이 현행 경계를 별도 CSRF token/exact-Origin 구현 완료로 표현하지 않는다. +- Browser cookie mutation은 configured `CORS_ORIGINS`의 명시적 HTTP(S) origin만 preflight를 통과하고 ADR-0073의 exact Origin, Fetch Metadata, content type와 signed CSRF token을 별도로 검증한다. Wildcard credentialed origin은 Gateway 구성 시 거부한다. Response: `{"status": "success", "results": { ... }}`. diff --git a/docs/features/chatbot-deployment/requirements.md b/docs/features/chatbot-deployment/requirements.md index 6cf3ea9a5..e5461bc17 100644 --- a/docs/features/chatbot-deployment/requirements.md +++ b/docs/features/chatbot-deployment/requirements.md @@ -31,7 +31,7 @@ Public Chatbot의 현재 계약은 [ADR-0074](../../decisions/ADR-0074-public-ch - CBOT-REQ-003c: 비로그인 사용자가 내부 챗봇 실행 링크를 열어 run-info에서 `401`을 받으면 클라이언트는 `/auth/login?next=<원래 path+query+hash>`로 이동한다. 이메일/비밀번호와 Google OAuth 로그인 모두 같은 origin의 안전한 `next`로 복귀하고, 외부·프로토콜 상대·malformed/중첩-encoded URL은 `/dashboard`로 fallback한다. Google OAuth 복귀 컨텍스트는 서명 session에서 10분 안에 한 번만 소비한다. - CBOT-REQ-003d: 인증 실행 요청은 업무 `inputs`와 별도의 top-level `conversation.client_id`에 canonical UUID를 전달한다. Gateway는 이 값을 deployment와 current user에 결박한 versioned internal namespace로 바꾸며, raw client id를 workflow input, response, audit 또는 log에 노출하지 않는다. - CBOT-REQ-003e: 인증 실행의 `conversation.client_id`는 Chatbot deployment에서만 허용한다. `inputs`에 선언된 `conversation_id` 또는 `memory_mode` workflow 변수는 업무 입력으로 보존하며, typed control과 선언되지 않은 legacy reserved control이 동시에 오면 모호한 요청으로 거부한다. -- CBOT-REQ-003f: 현재 인증 실행 mutation은 `Content-Type: application/json`만 허용하고, 누락·`text/plain`·form-urlencoded·multipart는 workflow dispatch 전에 `415`로 거부한다. Credentialed CORS는 명시적 HTTP(S) allowlist를 사용하고 wildcard 구성을 거부한다. 이는 현행 browser 경계이며 Target의 별도 CSRF token/exact-Origin/access grant를 대체하지 않는다. +- CBOT-REQ-003f: 인증 실행 mutation은 route inventory의 JSON 계약, 명시적 credentialed CORS allowlist와 ADR-0073의 exact Origin, Fetch Metadata, signed CSRF token을 workflow dispatch 전에 검증해야 한다. 이 공통 browser 경계는 별도 내부 Chatbot access grant를 대체하지 않는다. - CBOT-REQ-003g: 인증 내부 챗봇의 text 응답은 raw HTML 실행 없이 GitHub Flavored Markdown의 제목, 강조, 목록, 인용, inline code, link와 table을 렌더링한다. Markdown 이미지는 렌더링하지 않아 응답 열람이 외부 이미지 요청을 발생시키지 않는다. 질문 composer는 IME 조합 중이 아닐 때 `Enter`로 전송하고 `Shift+Enter`로 줄바꿈한다. 내부 실행 화면의 제목, 메시지, 상태, 입력과 전송 control은 일반 workflow 실행 화면과 같은 기본 크기를 사용한다. - CBOT-REQ-004: 공개 챗봇 Client는 현재 React state의 완료된 `user`/`assistant` pair만 `conversation.history`에 담아 전용 `/run-public/{url_slug}/chat` 경로로 보낸다. 첫 요청은 빈 history다. - CBOT-REQ-005: Gateway는 Public history의 exact shape, role 교대, 20 turn, message 크기, UTF-8과 현재 inputs를 포함한 4,096-token 상한을 provider dispatch 전에 검증한다. 오래된 맥락 제거는 완료 turn 단위로만 수행한다. @@ -60,7 +60,7 @@ Public Chatbot의 현재 계약은 [ADR-0074](../../decisions/ADR-0074-public-ch - Public `conversation.history`는 prompt injection/secret-like marker를 정제하되 허용된 message 크기를 별도의 더 작은 런타임 상한으로 다시 자르지 않는다. - Public WorkflowRun/NodeRun/Trace는 운영 상태, token, latency와 routing 결과 같은 content-free metadata만 유지하고 input/history/prompt/completion 원문은 저장하지 않는다. - 현재 인증 실행은 typed conversation control을 사용해 신규 내부 호출의 reserved-input collision을 제거한다. Legacy authenticated compatibility는 Public client-held contract와 분리한다. -- 현재 내부 실행 UI는 first-party configured CORS origin에서 JSON 요청만 보낸다. 브라우저의 unlisted-origin JSON 요청은 preflight에서 차단되고 simple cross-site content type은 `415`로 dispatch 전에 차단되지만, 별도 CSRF token과 exact-Origin 검사는 아직 Target이다. +- 현재 내부 실행 UI의 cookie mutation은 first-party configured CORS, exact Origin, Fetch Metadata, route-owned content type과 signed CSRF token을 모두 통과한다. 별도 내부 Chatbot access grant와 durable session namespace는 후속 범위다. ## User-visible Citations diff --git a/docs/features/conversation-memory/api_spec.md b/docs/features/conversation-memory/api_spec.md index dcda84448..9ea5219b4 100644 --- a/docs/features/conversation-memory/api_spec.md +++ b/docs/features/conversation-memory/api_spec.md @@ -21,7 +21,7 @@ Public history는 인증·인가·resource provenance·credential·billing princ Public iframe document는 relative same-origin으로 `POST /api/v1/run-public/{url_slug}/chat`을 호출한다. 응답과 endpoint 진입 전 validation error는 `Cache-Control: no-store`, `Referrer-Policy: no-referrer`이며 CORS grant를 제공하지 않는다. Client는 대화 원문을 React memory에만 두고 URL, localStorage, sessionStorage, audit, trace와 metric label에 남기지 않는다. -Authenticated internal Chatbot은 Public route에 optional login을 붙이지 않고 별도 authentication/authorization, CSRF/Origin, storage namespace와 retention 계약으로 구현한다. +Authenticated internal Chatbot은 Public route에 optional login을 붙이지 않고 별도 authentication/authorization, storage namespace와 retention 계약으로 구현한다. Cookie mutation은 ADR-0073의 공통 CSRF/exact-Origin 경계를 상속한다. ## HTTP Surface diff --git a/docs/features/conversation-memory/requirements.md b/docs/features/conversation-memory/requirements.md index 6bb673dbc..b07fc284b 100644 --- a/docs/features/conversation-memory/requirements.md +++ b/docs/features/conversation-memory/requirements.md @@ -112,7 +112,7 @@ Public Chatbot은 서버에 익명 transcript를 영구 저장하지 않고 Clie - MEM-REQ-046: Public history는 browser memory에만 유지하고 localStorage/sessionStorage에 자동 복구용 원문을 저장하지 않아야 한다. - MEM-REQ-047: Public request/response와 validation error는 'Cache-Control: no-store', 'Referrer-Policy: no-referrer'를 유지하고 CORS grant를 제공하지 않아야 한다. - MEM-REQ-048: Workflow task args representation, application log, audit, trace와 metric label에 Public history 원문을 남기지 않아야 한다. -- MEM-REQ-049: 인증형 내부 Chatbot durable Memory는 RBAC, CSRF/Origin, retention/legal policy와 operator transcript authorization을 별도 후속 이슈에서 완결해야 한다. +- MEM-REQ-049: 인증형 내부 Chatbot durable Memory는 ADR-0073의 공통 CSRF/Origin admission을 상속하고, 별도 RBAC, retention/legal policy와 operator transcript authorization을 후속 이슈에서 완결해야 한다. ### Context, Summary And Cost (Authenticated Internal Target) @@ -200,7 +200,7 @@ Public 값을 완화하려면 별도 보안·비용 검토가 필요하다. Auth MBA-318은 Public client-held history, legacy Public memory control 차단, content-free Workflow logging과 Embed Chat 전달을 구현한다. MBA-316/317의 durable persistence 및 public lifecycle foundation은 active API에 등록하지 않고 보존한다. -남은 범위는 authenticated internal Chatbot의 RBAC/CSRF/Origin, durable session/turn/entry, transcript lifecycle, retention/legal hold, provider admission/lease/fencing과 운영 UI다. 이 후속 구현은 active durable Memory domain contract를 최신 `dev`와 ADR-0074 경계에 맞게 선별 적용한다. +남은 범위는 authenticated internal Chatbot의 별도 RBAC, durable session/turn/entry, transcript lifecycle, retention/legal hold, provider admission/lease/fencing과 운영 UI다. Cookie mutation은 ADR-0073의 공통 CSRF/Origin admission을 상속하며, 후속 구현은 active durable Memory domain contract를 최신 `dev`와 ADR-0074 경계에 맞게 선별 적용한다. ## MBA-318 Boundary Completion Requirements From 894c82ea72d84f10e1576a06a45dbc33db4aa378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Wed, 29 Jul 2026 16:16:10 +0900 Subject: [PATCH 2/8] =?UTF-8?q?docs:=20ADR-0073=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8A=A4=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/decisions/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index e6f276d84..322b75bba 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -94,6 +94,7 @@ ADR 본문은 작성 시점의 결정 과정을 보존하는 기록 문서다. ` | [ADR-0070](ADR-0070-organization-detector-provider-and-pre-embedding-local-masking-boundary.md) | Accepted | 조직 Detector Provider와 embedding 전 로컬 마스킹 경계 | Local hard baseline, closed mode/provider/action matrix, exact provider·egress approval revision, UTF-8 byte span/fingerprint, platform+Organization validity epoch와 30-day legacy hard max를 redacted canonical/retrieval 경계에 강제한다. 현재 runtime/provider/persistence는 미구현이며 MBA-362와 각 관리 readiness 전에는 non-null provider 및 review-capable path를 비활성화한다. | | [ADR-0071](ADR-0071-rag-query-embedding-provider-capability.md) | Accepted | RAG query embedding provider capability 경계 | Deployment version과 canonical LLM location, exact embedding model별 명시 policy를 사용한다. Authorized 후보가 있을 때만 model별 capability와 durable usage operation을 만들고 query/vector를 invocation-local로 유지하며 ADR-0067 guarded transport를 사용한다. 일반 runtime activation과 legacy 제거는 MBA-320이 소유한다. | | [ADR-0072](ADR-0072-outbound-proxy-only-network-enforcement.md) | Accepted | Outbound proxy-only 네트워크 강제 경계 | Application guard를 의미 정책 권위로 유지하면서 explicit Squid transport, Compose internal network와 provider-neutral Helm NetworkPolicy를 결합한다. PR CI는 pinned kind+Calico IPv4에서 direct HTTPS 차단을 검증하고 dual-stack·운영 CNI는 release gate로 둔다. | +| [ADR-0073](ADR-0073-cookie-authenticated-api-csrf-boundary.md) | Accepted | Cookie 인증 API CSRF 경계 | Cookie/pre-auth unsafe route에 signed double-submit, exact Origin·Fetch Metadata, 명시적 Public/server 예외와 Client memory token을 적용한다. Development와 production은 기본 enforcement를 사용하고 Client/Gateway를 같은 maintenance release와 rollback 단위로 배포한다. | | [ADR-0074](ADR-0074-public-chatbot-client-held-history.md) | Accepted | Public Chatbot client-held conversation history | Public은 완료된 user/assistant history를 요청마다 보내고 서버는 20 turn/4,096 token을 검증한다. Public Session/Transcript/Access Grant와 content-bearing run/node/trace를 저장하지 않는다. ADR-0030/0033 durable Memory는 authenticated internal Chatbot 후속 target으로 유지한다. | ## 참고 보고서 From 2af5bb0398ecb5abee066c88921029832c2138b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Wed, 29 Jul 2026 16:24:44 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=EB=B6=80=EB=B6=84=20mock=EC=97=90?= =?UTF-8?q?=EC=84=9C=20CSRF=20=EB=AA=A8=EB=93=88=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fr13-recommendation-verification-api-client.test.ts | 1 + .../tests/costOptimizer/fr2-baseline-api-client.test.ts | 1 + .../tests/costOptimizer/fr4-fr5-compare-api-client.test.ts | 1 + .../tests/costOptimizer/fr8-apply-api-client.test.ts | 1 + .../costOptimizer/fr9-experiment-history-api-client.test.ts | 1 + apps/client/lib/activeOrganization.ts | 6 ++++-- apps/client/lib/activeOrganizationEvent.ts | 2 ++ apps/client/lib/csrfToken.ts | 6 ++---- 8 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 apps/client/lib/activeOrganizationEvent.ts diff --git a/apps/client/app/features/workflow/tests/costOptimizer/fr13-recommendation-verification-api-client.test.ts b/apps/client/app/features/workflow/tests/costOptimizer/fr13-recommendation-verification-api-client.test.ts index 7acce80d9..cb00f3f37 100644 --- a/apps/client/app/features/workflow/tests/costOptimizer/fr13-recommendation-verification-api-client.test.ts +++ b/apps/client/app/features/workflow/tests/costOptimizer/fr13-recommendation-verification-api-client.test.ts @@ -10,6 +10,7 @@ vi.mock('axios', () => ({ post: axiosPostMock, patch: vi.fn(), interceptors: { + request: { use: vi.fn() }, response: { use: vi.fn() }, }, })), diff --git a/apps/client/app/features/workflow/tests/costOptimizer/fr2-baseline-api-client.test.ts b/apps/client/app/features/workflow/tests/costOptimizer/fr2-baseline-api-client.test.ts index 8ea708156..513b584ae 100644 --- a/apps/client/app/features/workflow/tests/costOptimizer/fr2-baseline-api-client.test.ts +++ b/apps/client/app/features/workflow/tests/costOptimizer/fr2-baseline-api-client.test.ts @@ -9,6 +9,7 @@ vi.mock('axios', () => ({ get: axiosGetMock, post: vi.fn(), interceptors: { + request: { use: vi.fn() }, response: { use: vi.fn() }, }, })), diff --git a/apps/client/app/features/workflow/tests/costOptimizer/fr4-fr5-compare-api-client.test.ts b/apps/client/app/features/workflow/tests/costOptimizer/fr4-fr5-compare-api-client.test.ts index dbbcc871f..8787735e4 100644 --- a/apps/client/app/features/workflow/tests/costOptimizer/fr4-fr5-compare-api-client.test.ts +++ b/apps/client/app/features/workflow/tests/costOptimizer/fr4-fr5-compare-api-client.test.ts @@ -9,6 +9,7 @@ vi.mock('axios', () => ({ get: vi.fn(), post: axiosPostMock, interceptors: { + request: { use: vi.fn() }, response: { use: vi.fn() }, }, })), diff --git a/apps/client/app/features/workflow/tests/costOptimizer/fr8-apply-api-client.test.ts b/apps/client/app/features/workflow/tests/costOptimizer/fr8-apply-api-client.test.ts index 2f6821b4a..b2f36f93b 100644 --- a/apps/client/app/features/workflow/tests/costOptimizer/fr8-apply-api-client.test.ts +++ b/apps/client/app/features/workflow/tests/costOptimizer/fr8-apply-api-client.test.ts @@ -10,6 +10,7 @@ vi.mock('axios', () => ({ post: vi.fn(), patch: axiosPatchMock, interceptors: { + request: { use: vi.fn() }, response: { use: vi.fn() }, }, })), diff --git a/apps/client/app/features/workflow/tests/costOptimizer/fr9-experiment-history-api-client.test.ts b/apps/client/app/features/workflow/tests/costOptimizer/fr9-experiment-history-api-client.test.ts index ca07c2bf3..de3203887 100644 --- a/apps/client/app/features/workflow/tests/costOptimizer/fr9-experiment-history-api-client.test.ts +++ b/apps/client/app/features/workflow/tests/costOptimizer/fr9-experiment-history-api-client.test.ts @@ -10,6 +10,7 @@ vi.mock('axios', () => ({ post: vi.fn(), patch: vi.fn(), interceptors: { + request: { use: vi.fn() }, response: { use: vi.fn() }, }, })), diff --git a/apps/client/lib/activeOrganization.ts b/apps/client/lib/activeOrganization.ts index 88431d57a..26024d8d8 100644 --- a/apps/client/lib/activeOrganization.ts +++ b/apps/client/lib/activeOrganization.ts @@ -1,8 +1,10 @@ import { AxiosHeaders, type AxiosInstance } from 'axios'; +import { ACTIVE_ORGANIZATION_CHANGED_EVENT } from './activeOrganizationEvent'; + +export { ACTIVE_ORGANIZATION_CHANGED_EVENT } from './activeOrganizationEvent'; + const ACTIVE_ORGANIZATION_ID_STORAGE_KEY = 'moduly_active_organization_id'; -export const ACTIVE_ORGANIZATION_CHANGED_EVENT = - 'nodease-active-organization-changed'; type OrganizationLike = { id: string; diff --git a/apps/client/lib/activeOrganizationEvent.ts b/apps/client/lib/activeOrganizationEvent.ts new file mode 100644 index 000000000..8755628c8 --- /dev/null +++ b/apps/client/lib/activeOrganizationEvent.ts @@ -0,0 +1,2 @@ +export const ACTIVE_ORGANIZATION_CHANGED_EVENT = + 'nodease-active-organization-changed'; diff --git a/apps/client/lib/csrfToken.ts b/apps/client/lib/csrfToken.ts index dc853c59d..ba35f48bb 100644 --- a/apps/client/lib/csrfToken.ts +++ b/apps/client/lib/csrfToken.ts @@ -5,10 +5,8 @@ import { type InternalAxiosRequestConfig, } from 'axios'; -import { - ACTIVE_ORGANIZATION_CHANGED_EVENT, - getStoredActiveOrganizationId, -} from './activeOrganization'; +import { getStoredActiveOrganizationId } from './activeOrganization'; +import { ACTIVE_ORGANIZATION_CHANGED_EVENT } from './activeOrganizationEvent'; import { resolvePublicApiBaseUrl } from './publicApiOrigin'; const CSRF_HEADER_NAME = 'X-CSRF-Token'; From 04e4fe1d43cb8c59f862db151bfa92511fb82839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Wed, 29 Jul 2026 17:32:55 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20CSRF=20=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EB=B0=8F=20origin=20=EA=B2=BD=EA=B3=84=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/client/lib/csrfToken.test.ts | 88 +++++++++++-- apps/client/lib/csrfToken.ts | 120 +++++++++++++++--- apps/gateway/adapters/csrf/observability.py | 4 +- apps/gateway/api/v1/endpoints/auth.py | 39 +++++- apps/gateway/application/csrf/bootstrap.py | 42 ++++++ apps/gateway/composition/csrf.py | 24 ++++ apps/gateway/main.py | 1 + apps/gateway/middleware/csrf.py | 14 +- apps/gateway/tests/api/test_auth_csrf.py | 78 +++++++++++- .../middleware/test_csrf_content_types.py | 23 +++- docs/architecture.md | 6 +- ...-cookie-authenticated-api-csrf-boundary.md | 19 ++- docs/features/auth/api_spec.md | 16 ++- docs/features/auth/component_spec.md | 6 +- docs/features/auth/requirements.md | 10 +- docs/features/auth/test_cases.md | 10 +- 16 files changed, 437 insertions(+), 63 deletions(-) create mode 100644 apps/gateway/application/csrf/bootstrap.py diff --git a/apps/client/lib/csrfToken.test.ts b/apps/client/lib/csrfToken.test.ts index 0cd8231ba..1cf59ab29 100644 --- a/apps/client/lib/csrfToken.test.ts +++ b/apps/client/lib/csrfToken.test.ts @@ -39,7 +39,9 @@ describe('getCsrfToken', () => { it('single-flights concurrent bootstrap and stores the token only in memory', async () => { const storageSpy = vi.spyOn(Storage.prototype, 'setItem'); let resolveFetch: ((response: Response) => void) | undefined; - const fetchMock = vi.fn( + const fetchMock = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise + >( () => new Promise((resolve) => { resolveFetch = resolve; @@ -56,6 +58,9 @@ describe('getCsrfToken', () => { 'csrf-token', ]); expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/v1/auth/csrf'); + const bootstrapHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); + expect(bootstrapHeaders.get('X-CSRF-Bootstrap')).toBe('1'); expect(storageSpy).not.toHaveBeenCalled(); }); @@ -63,7 +68,8 @@ describe('getCsrfToken', () => { const fetchMock = vi .fn() .mockResolvedValueOnce(csrfResponse('organization-a-token')) - .mockResolvedValueOnce(csrfResponse('organization-b-token')); + .mockResolvedValueOnce(csrfResponse('organization-b-token')) + .mockResolvedValueOnce(csrfResponse('organization-a-new-token')); vi.stubGlobal('fetch', fetchMock); await expect(getCsrfToken('organization-a')).resolves.toBe( @@ -72,12 +78,39 @@ describe('getCsrfToken', () => { await expect(getCsrfToken('organization-b')).resolves.toBe( 'organization-b-token', ); + await expect(getCsrfToken('organization-a')).resolves.toBe( + 'organization-a-new-token', + ); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(3); const secondHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers); expect(secondHeaders.get('X-Organization-Id')).toBe('organization-b'); }); + it('does not revive an in-flight token after lifecycle invalidation', async () => { + let resolveStale: ((response: Response) => void) | undefined; + const fetchMock = vi + .fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStale = resolve; + }), + ) + .mockResolvedValueOnce(csrfResponse('fresh-token')); + vi.stubGlobal('fetch', fetchMock); + + const staleRequest = getCsrfToken('organization-a'); + invalidateCsrfToken(); + const freshRequest = getCsrfToken('organization-a'); + resolveStale?.(csrfResponse('stale-token')); + + await expect(staleRequest).rejects.toThrow('invalidated'); + await expect(freshRequest).resolves.toBe('fresh-token'); + await expect(getCsrfToken('organization-a')).resolves.toBe('fresh-token'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it('retries bootstrap once after the Gateway clears an invalid auth cookie', async () => { const fetchMock = vi .fn() @@ -113,16 +146,20 @@ describe('attachCsrfProtection', () => { }); it('attaches a token to unsafe requests after organization headers resolve', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => csrfResponse('scoped-token')), - ); + const fetchMock = vi.fn< + (input: RequestInfo | URL, init?: RequestInit) => Promise + >(async () => csrfResponse('scoped-token')); + vi.stubGlobal('fetch', fetchMock); const seen: InternalAxiosRequestConfig[] = []; const adapter: AxiosAdapter = async (config) => { seen.push(config); return success(config); }; - const client = axios.create({ adapter, withCredentials: true }); + const client = axios.create({ + adapter, + baseURL: 'https://api.nodease.example/api/v1', + withCredentials: true, + }); attachCsrfProtection(client); client.interceptors.request.use((config) => { const headers = AxiosHeaders.from(config.headers); @@ -136,6 +173,9 @@ describe('attachCsrfProtection', () => { expect(AxiosHeaders.from(seen[0]?.headers).get('X-CSRF-Token')).toBe( 'scoped-token', ); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://api.nodease.example/api/v1/auth/csrf', + ); }); it('refreshes at most once for replay-safe requests', async () => { @@ -224,4 +264,36 @@ describe('csrfFetch', () => { expect(requestHeaders.get('X-Organization-Id')).toBe('organization-a'); expect(fetchMock.mock.calls[1]?.[1]?.credentials).toBe('include'); }); + + it('partitions bootstrap cookies by the actual mutation origin', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(csrfResponse('web-origin-token')) + .mockResolvedValueOnce(new Response('{}', { status: 200 })) + .mockResolvedValueOnce(csrfResponse('api-origin-token')) + .mockResolvedValueOnce(new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await csrfFetch('/api/v1/workflows', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + await csrfFetch('https://api.nodease.example/api/v1/workflows', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/v1/auth/csrf'); + expect(fetchMock.mock.calls[2]?.[0]).toBe( + 'https://api.nodease.example/api/v1/auth/csrf', + ); + expect( + new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get('X-CSRF-Token'), + ).toBe('web-origin-token'); + expect( + new Headers(fetchMock.mock.calls[3]?.[1]?.headers).get('X-CSRF-Token'), + ).toBe('api-origin-token'); + }); }); diff --git a/apps/client/lib/csrfToken.ts b/apps/client/lib/csrfToken.ts index ba35f48bb..2516669a8 100644 --- a/apps/client/lib/csrfToken.ts +++ b/apps/client/lib/csrfToken.ts @@ -10,6 +10,7 @@ import { ACTIVE_ORGANIZATION_CHANGED_EVENT } from './activeOrganizationEvent'; import { resolvePublicApiBaseUrl } from './publicApiOrigin'; const CSRF_HEADER_NAME = 'X-CSRF-Token'; +const CSRF_BOOTSTRAP_HEADER_NAME = 'X-CSRF-Bootstrap'; const ORGANIZATION_HEADER_NAME = 'X-Organization-Id'; const CSRF_FAILURE_CODE = 'auth.csrf_validation_failed'; const EXPIRY_SKEW_MS = 30_000; @@ -28,16 +29,73 @@ type CachedToken = { scope: string; }; +type BootstrapTarget = { + cacheKey: string; + url: string; +}; + type RetryableRequestConfig = InternalAxiosRequestConfig & { _csrfRetried?: boolean; }; -let cachedToken: CachedToken | null = null; -const inFlightByScope = new Map>(); +type InFlightBootstrap = { + generation: number; + scope: string; + promise: Promise; +}; + +const cachedTokenByOrigin = new Map(); +const inFlightByOrigin = new Map(); +let cacheGeneration = 0; const normalizeScope = (organizationId?: string | null) => organizationId?.trim() || ''; +const sameOriginBootstrapTarget = (): BootstrapTarget => ({ + cacheKey: + typeof window !== 'undefined' ? window.location.origin : 'same-origin', + url: '/api/v1/auth/csrf', +}); + +const absoluteBootstrapTarget = (value: string): BootstrapTarget | null => { + try { + const parsed = new URL(value); + if (!['http:', 'https:'].includes(parsed.protocol)) return null; + return { + cacheKey: parsed.origin, + url: `${parsed.origin}/api/v1/auth/csrf`, + }; + } catch { + return null; + } +}; + +const requestTargetValue = (target: RequestInfo | URL): string => + typeof target === 'string' + ? target + : target instanceof URL + ? target.toString() + : target.url; + +const resolveBootstrapTarget = ( + requestTarget?: RequestInfo | URL, +): BootstrapTarget => { + if (requestTarget !== undefined) { + return ( + absoluteBootstrapTarget(requestTargetValue(requestTarget)) ?? + sameOriginBootstrapTarget() + ); + } + return absoluteBootstrapTarget(apiBaseUrl) ?? sameOriginBootstrapTarget(); +}; + +const axiosRequestTarget = ( + config: InternalAxiosRequestConfig, +): string | undefined => { + if (config.url && absoluteBootstrapTarget(config.url)) return config.url; + return config.baseURL ?? config.url; +}; + const isUnsafeMethod = (method?: string) => UNSAFE_METHODS.has((method || 'GET').toUpperCase()); @@ -98,29 +156,48 @@ const parseBootstrapResponse = async ( }; export const invalidateCsrfToken = () => { - cachedToken = null; - inFlightByScope.clear(); + cacheGeneration += 1; + cachedTokenByOrigin.clear(); }; export const getCsrfToken = async ( organizationId?: string | null, + requestTarget?: RequestInfo | URL, ): Promise => { const scope = normalizeScope(organizationId); + const target = resolveBootstrapTarget(requestTarget); + const generation = cacheGeneration; + const cachedToken = cachedTokenByOrigin.get(target.cacheKey); if ( - cachedToken?.scope === scope && + cachedToken && + cachedToken.scope === scope && cachedToken.expiresAtMs > Date.now() + EXPIRY_SKEW_MS ) { return cachedToken.token; } - const existing = inFlightByScope.get(scope); - if (existing) return existing; + const existing = inFlightByOrigin.get(target.cacheKey); + if ( + existing && + existing.generation === generation && + existing.scope === scope + ) { + return existing.promise; + } - const bootstrap = (async () => { - const headers = new Headers({ Accept: 'application/json' }); + const predecessor = existing?.promise.catch(() => undefined); + const bootstrapWork = async () => { + if (predecessor) await predecessor; + if (generation !== cacheGeneration) { + throw new Error('CSRF token bootstrap was invalidated'); + } + const headers = new Headers({ + Accept: 'application/json', + [CSRF_BOOTSTRAP_HEADER_NAME]: '1', + }); if (scope) headers.set(ORGANIZATION_HEADER_NAME, scope); const requestToken = () => - fetch(`${apiBaseUrl}/auth/csrf`, { + fetch(target.url, { method: 'GET', headers, credentials: 'include', @@ -131,13 +208,23 @@ export const getCsrfToken = async ( // Retry once so the browser can establish an anonymous pre-auth binding. if (response.status === 401) response = await requestToken(); const parsed = await parseBootstrapResponse(response, scope); - cachedToken = parsed; + if (generation !== cacheGeneration) { + throw new Error('CSRF token bootstrap was invalidated'); + } + cachedTokenByOrigin.set(target.cacheKey, parsed); return parsed.token; - })().finally(() => { - inFlightByScope.delete(scope); - }); + }; - inFlightByScope.set(scope, bootstrap); + const bootstrap = bootstrapWork().finally(() => { + if (inFlightByOrigin.get(target.cacheKey)?.promise === bootstrap) { + inFlightByOrigin.delete(target.cacheKey); + } + }); + inFlightByOrigin.set(target.cacheKey, { + generation, + scope, + promise: bootstrap, + }); return bootstrap; }; @@ -148,6 +235,7 @@ export const attachCsrfProtection = (client: AxiosInstance) => { const organizationId = headers.get(ORGANIZATION_HEADER_NAME); const token = await getCsrfToken( typeof organizationId === 'string' ? organizationId : null, + axiosRequestTarget(config), ); headers.set(CSRF_HEADER_NAME, token); config.headers = headers; @@ -200,7 +288,7 @@ export const csrfFetch = async ( if (organizationId && !headers.has(ORGANIZATION_HEADER_NAME)) { headers.set(ORGANIZATION_HEADER_NAME, organizationId); } - headers.set(CSRF_HEADER_NAME, await getCsrfToken(organizationId)); + headers.set(CSRF_HEADER_NAME, await getCsrfToken(organizationId, input)); return fetch(input, { ...init, method, diff --git a/apps/gateway/adapters/csrf/observability.py b/apps/gateway/adapters/csrf/observability.py index 1dc4a0e7f..73cc0b600 100644 --- a/apps/gateway/adapters/csrf/observability.py +++ b/apps/gateway/adapters/csrf/observability.py @@ -15,7 +15,7 @@ CSRF_DENIALS = ( PrometheusCounter( "auth_csrf_denials_total", - "Rejected cookie-authenticated browser mutations.", + "Rejected cookie-authenticated browser requests.", ["reason", "policy", "method"], ) if PrometheusCounter @@ -38,7 +38,7 @@ class CsrfObservability: } ) _POLICIES = frozenset({"cookie_authenticated", "pre_auth_session"}) - _METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + _METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) _local_counters: ClassVar[Counter[tuple[str, str, str]]] = Counter() @classmethod diff --git a/apps/gateway/api/v1/endpoints/auth.py b/apps/gateway/api/v1/endpoints/auth.py index 9cc9c2df3..a50b09d1e 100644 --- a/apps/gateway/api/v1/endpoints/auth.py +++ b/apps/gateway/api/v1/endpoints/auth.py @@ -17,6 +17,9 @@ PasswordLoginInternalError, ) from apps.gateway.application.authentication.models import PasswordLoginCommand +from apps.gateway.application.csrf.bootstrap import ( + validate_csrf_bootstrap_request, +) from apps.gateway.application.csrf.token import ( CSRF_ANON_COOKIE_NAME, CSRF_COOKIE_NAME, @@ -28,7 +31,10 @@ build_password_login, login_network_resolver, ) -from apps.gateway.composition.csrf import csrf_token_service +from apps.gateway.composition.csrf import ( + csrf_token_service, + record_csrf_bootstrap_denial, +) from apps.gateway.services.auth_return_service import AuthReturnService from apps.gateway.services.auth_service import AuthService from apps.gateway.utils.api_errors import error_response @@ -203,6 +209,35 @@ def bootstrap_csrf_token( response: Response, db: Session = Depends(get_db), ): + bootstrap_denial = validate_csrf_bootstrap_request( + request.headers, + allowed_origins=getattr( + request.app.state, + "credentialed_cors_origins", + (), + ), + ) + if bootstrap_denial is not None: + try: + record_csrf_bootstrap_denial( + bootstrap_denial, + request_id=getattr(request.state, "request_id", None), + ) + except Exception as exc: + logger.error( + "CSRF bootstrap denial telemetry failed: error_type=%s", + type(exc).__name__, + ) + denied_response = error_response( + request, + 403, + "auth.csrf_validation_failed", + "CSRF validation failed.", + ) + denied_response.headers["Cache-Control"] = "no-store" + denied_response.headers["Pragma"] = "no-cache" + return denied_response + auth_cookie = request.cookies.get("auth_token") anonymous_seed: str | None = None if auth_cookie: @@ -210,7 +245,7 @@ def bootstrap_csrf_token( try: AuthService.get_user_from_token(db, auth_cookie) except HTTPException as exc: - if exc.status_code != 401: + if exc.status_code not in {401, 403}: raise record_audit( action=AuditAction.AUTH_PERMISSION_DENIED, diff --git a/apps/gateway/application/csrf/bootstrap.py b/apps/gateway/application/csrf/bootstrap.py new file mode 100644 index 000000000..c469e9d98 --- /dev/null +++ b/apps/gateway/application/csrf/bootstrap.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from apps.gateway.application.csrf.token import CsrfValidationReason + + +CSRF_BOOTSTRAP_HEADER_NAME = "X-CSRF-Bootstrap" +CSRF_BOOTSTRAP_HEADER_VALUE = "1" +_ALLOWED_FETCH_SITES = frozenset({"same-origin", "same-site"}) + + +def validate_csrf_bootstrap_request( + headers: Mapping[str, str], + *, + allowed_origins: Sequence[str], +) -> CsrfValidationReason | None: + """Validate that a browser script, rather than an ambient GET, requested a token.""" + if ( + headers.get(CSRF_BOOTSTRAP_HEADER_NAME) + != CSRF_BOOTSTRAP_HEADER_VALUE + ): + return CsrfValidationReason.FETCH_METADATA_INVALID + + fetch_site = headers.get("sec-fetch-site") + normalized_fetch_site = fetch_site.lower() if fetch_site is not None else None + if ( + normalized_fetch_site is not None + and normalized_fetch_site not in _ALLOWED_FETCH_SITES + ): + return CsrfValidationReason.FETCH_METADATA_INVALID + + origin = headers.get("origin") + if origin is not None: + if origin not in frozenset(allowed_origins): + return CsrfValidationReason.ORIGIN_INVALID + elif normalized_fetch_site != "same-origin": + # Same-origin safe GETs may omit Origin. Cross-origin requests must + # present an exact allowlisted Origin after the custom-header preflight. + return CsrfValidationReason.ORIGIN_INVALID + + return None diff --git a/apps/gateway/composition/csrf.py b/apps/gateway/composition/csrf.py index 14b4d342a..beadd7d4d 100644 --- a/apps/gateway/composition/csrf.py +++ b/apps/gateway/composition/csrf.py @@ -272,6 +272,30 @@ def record_csrf_auth_required( ) +def record_csrf_bootstrap_denial( + reason: CsrfValidationReason, + *, + request_id: str | None, +) -> None: + CsrfObservability.record( + reason=reason.value, + policy=CsrfRoutePolicyKind.PRE_AUTH_SESSION.value, + method="GET", + ) + record_audit( + action=AuditAction.AUTH_PERMISSION_DENIED, + category="action", + actor_type="system", + status="failure", + metadata={ + "reason": f"auth.csrf.{reason.value}", + "policy": CsrfRoutePolicyKind.PRE_AUTH_SESSION.value, + "method": "GET", + "request_id": request_id, + }, + ) + + def record_csrf_denial( reason: CsrfValidationReason, policy: CsrfRoutePolicy, diff --git a/apps/gateway/main.py b/apps/gateway/main.py index aaa7f9700..038a13514 100644 --- a/apps/gateway/main.py +++ b/apps/gateway/main.py @@ -183,6 +183,7 @@ async def permission_mutation_persistence_failed( origins_str, node_env=os.getenv("NODE_ENV"), ) +app.state.credentialed_cors_origins = tuple(origins) # 정적 파일 서빙 (widget.js) - 옵션 STATIC_DIR = BASE_DIR / "static" diff --git a/apps/gateway/middleware/csrf.py b/apps/gateway/middleware/csrf.py index f4ebcfa44..d998e53f7 100644 --- a/apps/gateway/middleware/csrf.py +++ b/apps/gateway/middleware/csrf.py @@ -122,13 +122,23 @@ def _content_type_allowed( return True raw_content_type = request.headers.get("content-type") + content_length = request.headers.get("content-length") + has_transfer_encoding = ( + request.headers.get("transfer-encoding") is not None + ) + if ( + content_kind is CsrfContentKind.BODY_OPTIONAL + and raw_content_type + and content_length == "0" + and not has_transfer_encoding + ): + return True if not raw_content_type: if content_kind is not CsrfContentKind.BODY_OPTIONAL: return False - content_length = request.headers.get("content-length") return ( content_length in {None, "", "0"} - and request.headers.get("transfer-encoding") is None + and not has_transfer_encoding ) media_type, *raw_parameters = raw_content_type.split(";") diff --git a/apps/gateway/tests/api/test_auth_csrf.py b/apps/gateway/tests/api/test_auth_csrf.py index 15a7d8da5..2a9286195 100644 --- a/apps/gateway/tests/api/test_auth_csrf.py +++ b/apps/gateway/tests/api/test_auth_csrf.py @@ -13,11 +13,19 @@ def _client() -> TestClient: app = FastAPI() + app.state.credentialed_cors_origins = ("https://client.example",) app.include_router(auth_endpoint.router, prefix="/auth") app.dependency_overrides[get_db] = lambda: object() return TestClient(app, base_url="http://localhost") +def _bootstrap_headers() -> dict[str, str]: + return { + "X-CSRF-Bootstrap": "1", + "Sec-Fetch-Site": "same-origin", + } + + def test_anonymous_csrf_bootstrap_sets_host_only_http_only_cookies(monkeypatch): monkeypatch.setattr( auth_endpoint, @@ -26,7 +34,14 @@ def test_anonymous_csrf_bootstrap_sets_host_only_http_only_cookies(monkeypatch): ) with _client() as client: - response = client.get("/auth/csrf") + response = client.get( + "/auth/csrf", + headers={ + **_bootstrap_headers(), + "Origin": "https://client.example", + "Sec-Fetch-Site": "same-site", + }, + ) assert response.status_code == 200 payload = response.json() @@ -66,7 +81,10 @@ def test_authenticated_csrf_bootstrap_validates_cookie_and_clears_anon_seed( client.cookies.set(CSRF_ANON_COOKIE_NAME, "stale-anonymous-seed") response = client.get( "/auth/csrf", - headers={"X-Organization-Id": "organization-a"}, + headers={ + **_bootstrap_headers(), + "X-Organization-Id": "organization-a", + }, ) assert response.status_code == 200 @@ -85,7 +103,7 @@ def reject_invalid_cookie(_db, _token): with _client() as client: client.cookies.set("auth_token", "invalid-auth-token") - response = client.get("/auth/csrf") + response = client.get("/auth/csrf", headers=_bootstrap_headers()) assert response.status_code == 401 assert response.json()["error"]["code"] == "auth.invalid" @@ -95,6 +113,60 @@ def reject_invalid_cookie(_db, _token): assert any(cookie.startswith(f"{CSRF_ANON_COOKIE_NAME}=") for cookie in cookies) +def test_inactive_auth_cookie_is_cleared_before_anonymous_recovery(monkeypatch): + def reject_inactive_cookie(_db, _token): + raise HTTPException(status_code=403, detail="inactive") + + monkeypatch.setattr(AuthService, "get_user_from_token", reject_inactive_cookie) + + with _client() as client: + client.cookies.set("auth_token", "inactive-auth-token") + response = client.get("/auth/csrf", headers=_bootstrap_headers()) + + assert response.status_code == 401 + assert response.json()["error"]["code"] == "auth.invalid" + cookies = response.headers.get_list("set-cookie") + assert any( + cookie.startswith("auth_token=") and "Max-Age=0" in cookie + for cookie in cookies + ) + assert any( + cookie.startswith(f"{CSRF_COOKIE_NAME}=") and "Max-Age=0" in cookie + for cookie in cookies + ) + assert any( + cookie.startswith(f"{CSRF_ANON_COOKIE_NAME}=") and "Max-Age=0" in cookie + for cookie in cookies + ) + + +def test_untrusted_bootstrap_requests_cannot_rotate_csrf_cookies(monkeypatch): + monkeypatch.setattr( + auth_endpoint, + "csrf_token_service", + lambda: CsrfTokenService.from_root_secret("csrf-endpoint-test-secret"), + ) + + with _client() as client: + missing_proof = client.get( + "/auth/csrf", + headers={"Sec-Fetch-Site": "cross-site"}, + ) + untrusted_origin = client.get( + "/auth/csrf", + headers={ + "X-CSRF-Bootstrap": "1", + "Origin": "https://attacker.example", + "Sec-Fetch-Site": "same-site", + }, + ) + + for response in (missing_proof, untrusted_origin): + assert response.status_code == 403 + assert response.json()["error"]["code"] == "auth.csrf_validation_failed" + assert response.headers.get_list("set-cookie") == [] + + def test_logout_clears_auth_and_csrf_cookie_families(): with _client() as client: response = client.post("/auth/logout") diff --git a/apps/gateway/tests/middleware/test_csrf_content_types.py b/apps/gateway/tests/middleware/test_csrf_content_types.py index 7da7c13ba..41a495fed 100644 --- a/apps/gateway/tests/middleware/test_csrf_content_types.py +++ b/apps/gateway/tests/middleware/test_csrf_content_types.py @@ -23,7 +23,7 @@ def _policy(path: str, content_kind: CsrfContentKind) -> CsrfRoutePolicy: return CsrfRoutePolicy( - method="POST" if path != "/optional" else "DELETE", + method="POST", path_template=path, path_pattern=re.compile(f"^{re.escape(path)}$"), policy_kind=CsrfRoutePolicyKind.COOKIE_AUTHENTICATED, @@ -47,7 +47,7 @@ async def multipart_endpoint(request: Request): effects["multipart"] += 1 return {"ok": True} - @app.delete("/optional") + @app.post("/optional") async def optional_endpoint(): effects["optional"] += 1 return {"ok": True} @@ -97,7 +97,15 @@ def test_json_utf8_multipart_and_empty_body_contracts_are_accepted(): headers=headers, files={"file": ("document.txt", b"content", "text/plain")}, ) - optional_response = client.delete("/optional", headers=headers) + optional_response = client.post( + "/optional", + headers={ + **headers, + "Content-Type": "application/x-www-form-urlencoded", + "Content-Length": "0", + }, + content=b"", + ) assert json_response.status_code == 200 assert multipart_response.status_code == 200 @@ -127,7 +135,16 @@ def test_unexpected_json_parameters_and_malformed_multipart_fail_before_effects( }, content=b"sentinel", ) + nonempty_optional_response = client.post( + "/optional", + headers={ + **headers, + "Content-Type": "application/x-www-form-urlencoded", + }, + content=b"sentinel", + ) assert json_response.status_code == 403 assert multipart_response.status_code == 403 + assert nonempty_optional_response.status_code == 403 assert effects == {"json": 0, "multipart": 0, "optional": 0} diff --git a/docs/architecture.md b/docs/architecture.md index 6c9c2c440..d97568325 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -672,9 +672,9 @@ Canonical content revision/input hash ### 사용자 인증 - 사용자 세션은 `auth_token` HttpOnly cookie 기준이다. user session용 Bearer token dependency는 없다. -- Cookie-authenticated/pre-auth unsafe Gateway API는 [ADR-0073](decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md)의 signed double-submit 경계를 사용한다. Safe `GET /api/v1/auth/csrf`가 10분 token을 body와 host-only HttpOnly cookie로 발급하며 token MAC은 auth cookie 또는 anonymous seed와 active organization/account scope에 결박된다. -- Gateway는 모든 unsafe route를 cookie, pre-auth, public anonymous 또는 server credential audience로 startup 시 분류한다. Cookie/pre-auth route는 body parsing과 DB·queue·storage·provider 이전에 exact configured Origin, Fetch Metadata, JSON/명시 multipart와 token equality/signature/session/scope를 검증한다. Public/server route는 login cookie를 principal로 해석하지 않는다. -- Browser Client는 CSRF token을 module memory에만 보관하고 active organization/auth lifecycle에서 폐기한다. 안전하게 replay 가능한 request만 고정 CSRF 오류 뒤 최대 한 번 갱신·재시도한다. Workflow SSE Next proxy는 strict header token만 outbound host-only cookie로 복제하고 original Origin/Fetch Metadata를 전달하며 Gateway가 최종 검증한다. +- Cookie-authenticated/pre-auth unsafe Gateway API는 [ADR-0073](decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md)의 signed double-submit 경계를 사용한다. Browser bootstrap header와 same-origin Fetch Metadata 또는 exact configured Origin을 증명한 `GET /api/v1/auth/csrf`만 10분 token을 body와 host-only HttpOnly cookie로 발급하며 token MAC은 auth cookie 또는 anonymous seed와 active organization/account scope에 결박된다. Ambient cross-site GET은 cookie를 회전시키지 않는다. +- Gateway는 모든 unsafe route를 cookie, pre-auth, public anonymous 또는 server credential audience로 startup 시 분류한다. Cookie/pre-auth route는 body parsing과 DB·queue·storage·provider 이전에 exact configured Origin, Fetch Metadata, JSON/명시 multipart/bodyless와 token equality/signature/session/scope를 검증한다. 명시적 zero-length `BODY_OPTIONAL` 요청은 Axios media type과 무관하게 허용하고 non-empty form body는 거부한다. Public/server route는 login cookie를 principal로 해석하지 않는다. +- Browser Client는 CSRF token을 실제 mutation origin과 organization/account scope별 module memory에만 보관하고 active organization/auth lifecycle에서 폐기한다. Token bootstrap과 host-only cookie는 mutation과 같은 origin에서 수행한다. 안전하게 replay 가능한 request만 고정 CSRF 오류 뒤 최대 한 번 갱신·재시도한다. Workflow SSE Next proxy는 strict header token만 outbound host-only cookie로 복제하고 original Origin/Fetch Metadata를 전달하며 Gateway가 최종 검증한다. - Middleware 외곽 순서는 webhook query redaction, Public Conversation CORS boundary, credentialed CORS, CSRF, Session 순이다. 따라서 Public iframe 경계를 유지하면서 configured Client가 CSRF `401/403`을 읽을 수 있다. - Google OAuth 로그인을 지원한다 (`/api/v1/auth/google/login` → callback). - 인증 내부 실행의 safe same-origin `next` 복귀는 현재 이메일/비밀번호 로그인에만 적용하며, unsafe URL은 `/dashboard`로 닫는다. Google OAuth callback은 기존 `/dashboard` 복귀를 유지한다. diff --git a/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md b/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md index 174fce8d5..55fa93d27 100644 --- a/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md +++ b/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md @@ -8,6 +8,8 @@ Gateway 사용자 인증은 `auth_token` HttpOnly JWT cookie를 사용한다. No Gateway에는 공통 `get_current_user` dependency를 사용하는 route 외에도 자체 cookie 인증 helper, 로그인 전 mutation, 익명 Public 실행, app secret 기반 실행과 webhook이 함께 존재한다. Cookie 존재 여부만으로 정책을 추론하면 Public audience를 사용자 권한으로 승격하거나 보호 mutation을 누락할 수 있다. +Safe GET도 응답의 `Set-Cookie` 부수효과를 가진다. Ambient cross-site image/navigation GET이 bootstrap token을 회전시키면 Client memory header와 host-only cookie가 달라져 non-replayable workflow mutation을 지속적으로 막을 수 있다. 또한 Client가 same-origin reverse proxy와 별도 공개 API origin을 함께 사용하면 token body와 host-only cookie가 서로 다른 host에 놓일 수 있다. + ## Options Considered ### Option A: CORS와 SameSite만 유지 @@ -25,6 +27,8 @@ Gateway에는 공통 `get_current_user` dependency를 사용하는 route 외에 - 장점: 별도 durable session row 없이 cookie injection, token 변조와 다른 session·organization replay를 막을 수 있다. - 단점: Client bootstrap/lifecycle, route inventory와 배포 순서를 함께 관리해야 한다. +Bootstrap의 `Set-Cookie` 회전 방어에서는 exact Origin만 요구하는 방안, 기존 cookie가 있을 때 회전하지 않는 방안, custom header와 Origin/Fetch Metadata를 결합하는 방안을 비교했다. Exact Origin만 요구하면 same-origin safe GET에서 브라우저가 Origin을 생략하는 경우를 지원하지 못한다. 기존 cookie 재사용만으로는 첫 ambient 요청과 organization scope 전환을 막지 못한다. 따라서 custom header로 cross-origin 요청을 preflight에 묶고, exact allowlisted Origin 또는 same-origin Fetch Metadata를 추가 검증하는 방안을 선택했다. + ## Decision Option C를 채택한다. @@ -33,9 +37,10 @@ Option C를 채택한다. 1. `GET /api/v1/auth/csrf`는 `v1.expiry.nonce.mac` 형식의 10분 HMAC token을 응답 body와 host-only HttpOnly `csrf_token` cookie에 함께 발급한다. 2. Token payload에는 auth cookie, 사용자, organization 또는 그 fingerprint 원문을 넣지 않는다. MAC은 domain-separated key, binding 종류, auth cookie 또는 anonymous seed의 HMAC, active `X-Organization-Id` 또는 account sentinel을 포함한다. -3. 인증 cookie가 없으면 host-only HttpOnly random `csrf_anon_seed`에 결박한 pre-auth token을 발급한다. 유효하지 않은 `auth_token`이 있으면 anonymous로 조용히 전환하지 않고 `401 auth.invalid`로 닫고 invalid auth/CSRF cookie를 삭제한다. Client는 cookie 삭제가 반영된 뒤 bootstrap을 한 번만 다시 시도할 수 있다. -4. Bootstrap 응답은 `Cache-Control: no-store`, `Pragma: no-cache`를 사용한다. Token과 seed cookie는 `/api/v1`, 600초, HttpOnly, host-only이며 non-local에서는 Secure와 SameSite=None, loopback에서는 SameSite=Lax를 사용한다. -5. Signup, password login, Google OAuth 성공과 logout은 이전 CSRF/anonymous cookie를 삭제한다. Client는 인증 전환과 active organization 변경 시 memory token을 폐기한다. +3. 인증 cookie가 없으면 host-only HttpOnly random `csrf_anon_seed`에 결박한 pre-auth token을 발급한다. 유효하지 않거나 비활성 계정에 결박된 `auth_token`이 있으면 anonymous로 조용히 전환하지 않고 `401 auth.invalid`로 닫고 invalid auth/CSRF cookie를 삭제한다. Client는 cookie 삭제가 반영된 뒤 bootstrap을 한 번만 다시 시도할 수 있다. +4. Bootstrap은 `X-CSRF-Bootstrap: 1`을 필수로 요구한다. Origin이 있으면 credentialed CORS allowlist와 exact match해야 하고, Origin이 생략된 same-origin GET은 `Sec-Fetch-Site: same-origin`이어야 한다. 존재하는 Fetch Metadata의 cross-site 값은 거부한다. 이 검증은 token service, DB와 Set-Cookie보다 먼저 수행한다. +5. Bootstrap 응답은 `Cache-Control: no-store`, `Pragma: no-cache`를 사용한다. Token과 seed cookie는 `/api/v1`, 600초, HttpOnly, host-only이며 non-local에서는 Secure와 SameSite=None, loopback에서는 SameSite=Lax를 사용한다. +6. Signup, password login, Google OAuth 성공과 logout은 이전 CSRF/anonymous cookie를 삭제한다. Client는 인증 전환과 active organization 변경 시 memory token을 폐기한다. ### 중앙 route policy와 검증 순서 @@ -44,7 +49,7 @@ Option C를 채택한다. 3. Cookie/pre-auth mutation은 body parsing, DB, queue, storage와 provider 호출 전에 다음 순서로 검증한다. - `CORS_ORIGINS`와 정확히 일치하는 `Origin` - 존재하는 경우 `Sec-Fetch-Site`가 `same-origin` 또는 `same-site` - - JSON 또는 route inventory에 등록한 multipart/bodyless 계약 + - JSON 또는 route inventory에 등록한 multipart/bodyless 계약. `BODY_OPTIONAL`은 transfer encoding 없이 `Content-Length: 0`인 요청을 media type과 무관하게 빈 본문으로 허용한다. - header/cookie equality와 token signature, binding kind, session/anonymous seed, organization scope, expiry 4. 인증 cookie가 없는 `cookie_authenticated` 요청은 CSRF token으로 사용자 identity를 만들지 않고 side effect 전에 `401 auth.required`로 닫는다. 5. `public_anonymous`와 `server_credential` route는 login cookie가 우연히 포함돼도 CSRF cookie 또는 사용자 principal을 사용하지 않는다. Public Chatbot, Public run과 app-secret webhook/run의 기존 audience를 유지한다. @@ -53,7 +58,7 @@ Option C를 채택한다. ### 오류, 관측과 Client 1. CSRF 실패는 항상 `403 auth.csrf_validation_failed`와 고정 message를 반환한다. 내부에서는 bounded reason, policy, method와 검증된 request ID만 metric/audit에 기록하며 token, cookie, Origin, session, organization과 path parameter 원문을 기록하지 않는다. -2. Client token은 module memory에만 저장하고 localStorage, sessionStorage, URL과 log에 남기지 않는다. 같은 scope의 동시 bootstrap은 하나로 합친다. +2. Client token은 module memory에만 저장하고 localStorage, sessionStorage, URL과 log에 남기지 않는다. Origin마다 현재 organization/account scope token 하나만 유지하고, 실제 mutation origin과 scope가 같은 동시 bootstrap만 하나로 합치며 host-only cookie와 bootstrap endpoint를 mutation origin에 맞춘다. Lifecycle generation 이전에 시작한 bootstrap은 cache를 되살리지 못하고, 같은 origin의 새 bootstrap은 이전 요청이 정리된 뒤 cookie를 갱신한다. 3. 공통 Axios client와 보호된 직접 fetch는 unsafe method에 token을 자동 첨부한다. CSRF 실패 시 PUT/DELETE 또는 idempotency key가 있는 요청만 새 token으로 최대 한 번 재시도한다. 일반 POST/PATCH는 자동 replay하지 않는다. 4. Workflow SSE의 same-origin Next proxy는 API host-only CSRF cookie를 직접 받을 수 없다. 이 단일 proxy는 엄격한 token 문자·길이 검사를 거친 `X-CSRF-Token`을 outbound `csrf_token` cookie로 복제하고, 원래 Origin, Fetch Metadata, organization과 request context를 Gateway에 전달한다. Gateway는 동일한 HMAC/session/scope 검증을 수행한다. @@ -68,6 +73,8 @@ Option C를 채택한다. - Signed token을 기존 검증된 `SECRET_KEY`에서 domain separation해 파생하면 secret 원문이나 신규 durable session 저장소 없이 현재 인증 구조에 맞출 수 있다. - Route audience를 먼저 분류하면 login cookie의 우연한 포함이 Public 또는 server credential route의 principal을 바꾸지 않는다. - Exact Origin, Fetch Metadata, content type와 token을 독립적으로 검증하면 어느 한 방어 계층의 오구성이 곧바로 mutation 허용으로 이어지지 않는다. +- Custom bootstrap header는 ambient image/navigation GET을 차단하고 cross-origin script 요청을 CORS preflight에 묶는다. Same-origin Fetch Metadata fallback은 safe GET에서 Origin이 생략되는 브라우저 동작을 지원한다. +- Host-only cookie는 origin 간 공유되지 않으므로 cache와 bootstrap도 실제 mutation origin별로 분리해야 header/cookie equality를 보장할 수 있다. - Non-idempotent 자동 replay를 금지하면 token expiry 복구가 중복 side effect로 바뀌지 않는다. ## Affected Files @@ -87,6 +94,7 @@ Option C를 채택한다. - 첫 unsafe browser mutation 전에 safe CSRF bootstrap 요청이 하나 추가될 수 있다. - Organization 변경, 인증 전환과 10분 만료 뒤 새 token이 필요하다. +- 다른 API origin으로 mutation을 보내면 같은 organization scope라도 해당 origin에서 별도 bootstrap을 수행한다. - 잘못된 Origin, content type, stale scope 또는 누락 token은 endpoint와 side effect에 도달하지 않는다. - 테스트 프로필은 중앙 middleware 자체 테스트와 route inventory test를 제외한 기존 API 테스트에서 enforcement를 비활성화할 수 있다. - Public와 server credential 호출에는 CSRF header를 추가하지 않으며 해당 route가 cookie principal을 사용하지 않는 별도 계약이 계속 필요하다. @@ -96,4 +104,5 @@ Option C를 채택한다. - 새 unsafe route와 multipart route는 route inventory, audience와 protected-resource 완결성 증거를 함께 추가한다. - 인증형 내부 Chatbot과 durable Conversation Memory route는 동일한 cookie policy를 상속하되 별도 access permission, storage namespace와 retention 계약을 구현한다. - 실제 배포 절차에서 Frontend/Gateway maintenance cutover와 rollback이 같은 contract revision을 유지하는지 검증한다. +- 지원 브라우저 변경 시 custom header preflight와 `Sec-Fetch-Site` same-origin fallback 호환성을 다시 검토한다. - 향후 server-side user session을 도입하면 외부 header/error 계약을 유지하면서 token binding validator 교체를 검토한다. diff --git a/docs/features/auth/api_spec.md b/docs/features/auth/api_spec.md index e6a2fd8f2..1f34125ef 100644 --- a/docs/features/auth/api_spec.md +++ b/docs/features/auth/api_spec.md @@ -22,11 +22,13 @@ Status: Draft 요청 본문: 없음. -선택 입력: +필수 browser context와 선택 binding 입력: | 입력 | 의미 | | --- | --- | -| `auth_token` cookie | 존재하면 유효한 사용자 session인지 검증하고 token을 그 cookie에 결박한다. Invalid cookie는 `401 auth.invalid`과 삭제 Set-Cookie를 반환하며 anonymous로 같은 응답에서 전환하지 않는다. | +| `X-CSRF-Bootstrap: 1` | Browser script가 의도적으로 token을 요청했음을 증명한다. 단순 image/navigation GET에는 이 header가 없어 발급 전에 거부된다. | +| `Origin` / `Sec-Fetch-Site` | 교차 출처 요청은 `CORS_ORIGINS` exact Origin을 요구한다. Origin이 생략된 same-origin GET은 `Sec-Fetch-Site: same-origin`이어야 한다. 존재하는 Fetch Metadata의 `cross-site` 값은 거부한다. | +| `auth_token` cookie | 존재하면 유효한 활성 사용자 session인지 검증하고 token을 그 cookie에 결박한다. Invalid 또는 inactive session은 `401 auth.invalid`과 삭제 Set-Cookie를 반환하며 anonymous로 같은 응답에서 전환하지 않는다. | | `X-Organization-Id` | 존재하면 token MAC의 active organization scope에 포함한다. 없으면 account scope를 사용한다. | | `csrf_anon_seed` cookie | auth cookie가 없을 때 유효한 random seed를 재사용하며, 없거나 malformed이면 새 seed를 발급한다. | @@ -39,7 +41,7 @@ Status: Draft } ``` -응답은 같은 token을 host-only HttpOnly `csrf_token` cookie로 설정한다. Anonymous bootstrap은 host-only HttpOnly `csrf_anon_seed`도 설정한다. `Cache-Control: no-store`, `Pragma: no-cache`가 필수다. Token은 `v1.expiry.nonce.mac` 형식이며 auth cookie, user와 organization 원문을 포함하지 않는다. +응답은 같은 token을 host-only HttpOnly `csrf_token` cookie로 설정한다. Anonymous bootstrap은 host-only HttpOnly `csrf_anon_seed`도 설정한다. `Cache-Control: no-store`, `Pragma: no-cache`가 필수다. Token은 `v1.expiry.nonce.mac` 형식이며 auth cookie, user와 organization 원문을 포함하지 않는다. Header/Origin/Fetch Metadata 검증 실패는 cookie를 설정하거나 회전시키지 않고 `403 auth.csrf_validation_failed`를 반환한다. ### `POST /auth/signup` 요청 본문: @@ -149,7 +151,7 @@ OAuth 입력: Google OAuth 콜백 요청과 세션 상태. | `X-CSRF-Token` | `/auth/csrf` body에서 받은 token | | `csrf_token` cookie | Header token과 같은 host-only HttpOnly cookie | | `X-Organization-Id` | Organization-scoped token을 발급받은 요청은 같은 값 | -| `Content-Type` | 기본 `application/json`(선택적 `charset=utf-8`), inventory에 등록된 multipart 또는 bodyless route만 예외 | +| `Content-Type` | 기본 `application/json`(선택적 `charset=utf-8`), inventory에 등록된 multipart 또는 bodyless route만 예외. `BODY_OPTIONAL`은 transfer encoding 없이 `Content-Length: 0`인 경우 Axios의 빈 POST media type도 허용 | 현재 명시 예외는 Public Chatbot/Public run의 `public_anonymous`, app secret run/webhook의 `server_credential`, signed one-time state를 사용하는 OAuth GET route다. Login cookie가 예외 route에 포함돼도 cookie user principal이나 private permission으로 승격하지 않는다. @@ -215,7 +217,7 @@ CSRF cookie 계약: | `csrf_token` | `/api/v1` | 600초 | HttpOnly | 미설정(host-only) | loopback: SameSite=Lax, non-local: Secure/SameSite=None | | `csrf_anon_seed` | `/api/v1` | 600초 | HttpOnly | 미설정(host-only) | loopback: SameSite=Lax, non-local: Secure/SameSite=None | -Signup, login, OAuth 성공과 logout은 두 CSRF cookie를 삭제한다. Invalid auth cookie가 있는 bootstrap은 auth/CSRF cookie를 삭제하고 `401`을 반환한다. Client는 삭제 반영 뒤 anonymous bootstrap을 최대 한 번 재시도한다. +Signup, login, OAuth 성공과 logout은 두 CSRF cookie를 삭제한다. Invalid 또는 inactive auth cookie가 있는 bootstrap은 auth/CSRF cookie를 삭제하고 `401`을 반환한다. Client는 삭제 반영 뒤 anonymous bootstrap을 최대 한 번 재시도한다. 회원가입, 로그인, Google 콜백은 `auth_token`을 `max_age` 6시간의 HTTP-only 쿠키로 설정한다. 이메일/비밀번호 signup 및 login의 경우: @@ -260,8 +262,8 @@ HTTP 예외는 다음 형식으로 반환된다. | 상태 | 엔드포인트 | 상세 / 본문 | 조건 | | --- | --- | --- | --- | -| 401 | `GET /auth/csrf` | `auth.invalid` envelope과 auth/CSRF cookie 삭제 | 존재하는 `auth_token`이 유효하지 않다. Anonymous fallback은 같은 응답에서 수행하지 않는다. | -| 403 | 모든 cookie/pre-auth unsafe route | `auth.csrf_validation_failed` 고정 envelope | Origin, Fetch Metadata, content type, token equality/signature/binding/scope/expiry 중 하나가 실패한다. | +| 401 | `GET /auth/csrf` | `auth.invalid` envelope과 auth/CSRF cookie 삭제 | 존재하는 `auth_token`이 유효하지 않거나 비활성 계정에 결박됐다. Anonymous fallback은 같은 응답에서 수행하지 않는다. | +| 403 | `GET /auth/csrf`와 모든 cookie/pre-auth unsafe route | `auth.csrf_validation_failed` 고정 envelope | Bootstrap proof, Origin, Fetch Metadata, content type, token equality/signature/binding/scope/expiry 중 하나가 실패한다. Bootstrap 실패는 cookie를 설정하지 않는다. | | GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. | Safe bootstrap; resource permission 없음 | | 400 | `POST /auth/signup` | `이미 등록된 이메일입니다` | 이메일이 이미 존재한다. | | 400 | `GET /auth/google/callback` | `OAuth authentication failed` | token 교환, token/user info 타입, user info 조회 또는 email 검증에 실패한다. Provider exception 원문은 반환하지 않는다. | diff --git a/docs/features/auth/component_spec.md b/docs/features/auth/component_spec.md index ed918a5f2..f024e757e 100644 --- a/docs/features/auth/component_spec.md +++ b/docs/features/auth/component_spec.md @@ -237,11 +237,11 @@ Status: Draft ### Client CSRF Token Manager -- `csrfToken.ts`는 `/auth/csrf` 응답을 runtime 검증하고 token, expiry와 organization/account scope를 module memory에만 저장한다. -- 같은 scope의 동시 요청은 하나의 bootstrap Promise를 공유한다. Reload와 tab은 token을 공유하지 않는다. +- `csrfToken.ts`는 실제 mutation origin의 `/api/v1/auth/csrf`에 `X-CSRF-Bootstrap: 1`을 보내고 응답을 runtime 검증한 뒤 token과 expiry를 module memory에만 저장한다. +- Origin마다 현재 organization/account scope의 token 하나만 유지한다. 같은 mutation origin과 scope의 동시 요청만 하나의 bootstrap Promise와 cached token을 공유하며, scope 전환은 같은 origin의 이전 token을 대체한다. 다른 origin, reload와 tab은 token을 공유하지 않는다. - Axios request interceptor는 active organization header가 결정된 뒤 unsafe request에 `X-CSRF-Token`을 추가한다. Response interceptor는 고정 CSRF error에서 cache를 지우며 PUT/DELETE 또는 idempotency key 요청만 최대 한 번 재시도한다. - `csrfFetch`는 Settings, Wizard, RAG stream과 Workflow stream처럼 Axios를 통하지 않는 protected mutation에 같은 계약을 제공한다. Public Chatbot/Public run, app-secret 실행과 presigned object upload에는 적용하지 않는다. -- Signup/login/logout 성공, OAuth navigation과 `nodease-active-organization-changed` event는 cached token을 폐기한다. Invalid HttpOnly auth cookie bootstrap `401`은 cookie 삭제 반영을 위해 최대 한 번만 재시도한다. +- Signup/login/logout 성공, OAuth navigation과 `nodease-active-organization-changed` event는 cache generation을 올리고 cached token을 폐기한다. 이전 generation의 진행 중 bootstrap은 cache를 되살리지 못하며, 같은 origin의 새 bootstrap은 이전 요청 정리 뒤 cookie를 마지막으로 갱신한다. Invalid 또는 inactive-session HttpOnly auth cookie bootstrap `401`은 cookie 삭제 반영을 위해 최대 한 번만 재시도한다. ### Workflow Stream Proxy diff --git a/docs/features/auth/requirements.md b/docs/features/auth/requirements.md index 3cf5731f4..b010202f1 100644 --- a/docs/features/auth/requirements.md +++ b/docs/features/auth/requirements.md @@ -93,20 +93,20 @@ Auth는 보호된 Gateway API가 `auth_token` 쿠키에서 현재 사용자를 - AUTH-REQ-068: Production Helm 배포에서 Ingress가 활성화되면 실제 peer topology에 맞는 trusted proxy CIDR이 필수여야 한다. Direct Gateway 배포는 빈 목록으로 forwarded address를 무시할 수 있으며, 광역 CIDR을 추측해 기본값으로 제공하지 않아야 한다. - AUTH-REQ-069: Bundled Docker Compose는 development mode를 명시적으로 기본 적용해야 하며, 운영 사용 시 `NODE_ENV=production`과 dedicated login fingerprint keyring을 설정해 production startup 검증을 활성화해야 한다. -- AUTH-REQ-070: 시스템은 `GET /auth/csrf`에서 10분 만료 signed double-submit token을 응답 body와 host-only HttpOnly `csrf_token` cookie로 발급해야 한다. +- AUTH-REQ-070: 시스템은 browser script가 `X-CSRF-Bootstrap: 1`을 보내고 same-origin Fetch Metadata 또는 exact allowlisted Origin을 증명한 `GET /auth/csrf`에서만 10분 만료 signed double-submit token을 응답 body와 host-only HttpOnly `csrf_token` cookie로 발급해야 한다. Ambient cross-site GET은 cookie를 회전시키지 않고 고정 `403`으로 닫아야 한다. - AUTH-REQ-071: CSRF token은 auth cookie 또는 random anonymous seed, binding 종류와 normalized active organization/account scope에 domain-separated HMAC으로 결박해야 하며 이 값들의 원문을 token payload에 포함하지 않아야 한다. -- AUTH-REQ-072: 인증 cookie가 없는 bootstrap은 host-only HttpOnly `csrf_anon_seed`와 pre-auth token을 발급해야 한다. 유효하지 않은 auth cookie는 anonymous로 조용히 전환하지 않고 `401 auth.invalid`로 닫고 invalid auth/CSRF cookie를 삭제해야 한다. +- AUTH-REQ-072: 인증 cookie가 없는 bootstrap은 host-only HttpOnly `csrf_anon_seed`와 pre-auth token을 발급해야 한다. 유효하지 않거나 비활성 계정에 결박된 auth cookie는 anonymous로 조용히 전환하지 않고 `401 auth.invalid`로 닫고 invalid auth/CSRF cookie를 삭제해야 한다. - AUTH-REQ-073: `csrf_token`과 `csrf_anon_seed`는 `path=/api/v1`, 600초, HttpOnly, host-only여야 한다. Non-local에서는 Secure/SameSite=None, loopback에서는 SameSite=Lax를 사용해야 한다. - AUTH-REQ-074: Signup, password login, Google OAuth 성공과 logout은 stale CSRF/anonymous cookie를 삭제해야 한다. - AUTH-REQ-075: 모든 unsafe Gateway route는 `cookie_authenticated`, `pre_auth_session`, `public_anonymous`, `server_credential` 중 정확히 하나로 분류되어야 하며 미분류, 중복 또는 존재하지 않는 명시 예외는 startup과 architecture test를 실패시켜야 한다. - AUTH-REQ-076: Cookie/pre-auth mutation은 body parsing과 side effect 전에 configured exact Origin을 요구해야 한다. `Sec-Fetch-Site`가 있으면 `same-origin` 또는 `same-site`만 허용해야 한다. -- AUTH-REQ-077: Cookie/pre-auth mutation은 기본적으로 canonical JSON만 허용하고 route inventory에 등록된 multipart와 bodyless 요청만 예외로 허용해야 한다. +- AUTH-REQ-077: Cookie/pre-auth mutation은 기본적으로 canonical JSON만 허용하고 route inventory에 등록된 multipart와 bodyless 요청만 예외로 허용해야 한다. `BODY_OPTIONAL` route는 transfer encoding이 없고 `Content-Length: 0`으로 증명된 요청이면 Axios가 붙인 media type과 무관하게 빈 본문으로 처리할 수 있어야 한다. - AUTH-REQ-078: Cookie/pre-auth mutation은 `X-CSRF-Token`과 CSRF cookie의 equality, signature, version, expiry, binding kind, auth/anonymous binding과 organization/account scope를 검증해야 한다. - AUTH-REQ-079: CSRF 검증은 controller, credential verifier, DB, queue, storage, retrieval과 provider 호출보다 먼저 수행되어야 한다. 인증 cookie가 없는 cookie-authenticated mutation은 side effect 전에 `401 auth.required`로 닫아야 한다. - AUTH-REQ-080: Public anonymous와 server credential route는 login cookie 존재 여부로 audience나 principal을 바꾸지 않고 CSRF token을 private 권한 근거로 사용하지 않아야 한다. - AUTH-REQ-081: 모든 CSRF 거부는 `403 auth.csrf_validation_failed`와 고정 message를 반환하고, 내부 audit/metric에는 bounded reason, policy, method와 safe request ID만 기록해야 한다. Token, cookie, Origin, session, organization과 path parameter 원문은 기록하지 않아야 한다. -- AUTH-REQ-082: Client는 CSRF token을 module memory에만 보관하고 같은 scope bootstrap을 single-flight해야 한다. LocalStorage, sessionStorage, URL과 log에 token을 남기지 않아야 한다. -- AUTH-REQ-083: Client는 login/signup/logout/OAuth와 active organization 변경 때 cached token을 폐기해야 한다. Invalid auth cookie를 삭제한 bootstrap `401`은 한 번만 재시도할 수 있다. +- AUTH-REQ-082: Client는 CSRF token을 module memory에만 보관하고 실제 mutation origin마다 현재 organization/account scope token 하나만 유지해야 한다. Origin과 scope가 모두 같은 bootstrap만 single-flight 및 cache 공유하고, bootstrap은 mutation과 같은 origin의 `/api/v1/auth/csrf`를 사용해야 하며 LocalStorage, sessionStorage, URL과 log에 token을 남기지 않아야 한다. +- AUTH-REQ-083: Client는 login/signup/logout/OAuth와 active organization 변경 때 cached token을 폐기해야 한다. 전환 전에 시작한 bootstrap은 cache를 되살리거나 전환 뒤 요청에 token을 제공하지 않아야 하며, 같은 origin의 새 bootstrap은 이전 요청 정리 뒤 수행되어 마지막 host-only cookie와 memory token을 일치시켜야 한다. Invalid auth cookie를 삭제한 bootstrap `401`은 한 번만 재시도할 수 있다. - AUTH-REQ-084: Client는 CSRF 실패 뒤 PUT/DELETE 또는 idempotency key가 있는 요청만 token refresh 후 최대 한 번 재시도하고, 일반 POST/PATCH를 자동 replay하지 않아야 한다. - AUTH-REQ-085: OAuth GET navigation/callback은 custom CSRF header 대신 기존 signed, expiring, one-time state를 유지해야 한다. - AUTH-REQ-086: `CORS_ORIGINS`는 CSRF exact-Origin allowlist에 재사용하되 CORS 허용을 CSRF 성공으로 간주하지 않아야 한다. CORS middleware는 허용된 Client가 CSRF `401/403`을 읽을 수 있도록 CSRF middleware 바깥에 있어야 한다. diff --git a/docs/features/auth/test_cases.md b/docs/features/auth/test_cases.md index 8529650ad..5da693543 100644 --- a/docs/features/auth/test_cases.md +++ b/docs/features/auth/test_cases.md @@ -125,9 +125,9 @@ Status: Draft | AUTH-TC-CS002 | Header/cookie double-submit 값은 constant-time equality와 signature를 모두 통과해야 한다. | Header/cookie 누락·불일치·MAC 변조 중 하나를 보낸다. | Fixed `403 auth.csrf_validation_failed`, endpoint 미진입. | | AUTH-TC-CS003 | Token은 다른 auth cookie, anonymous seed, binding kind와 organization/account scope에서 replay되지 않아야 한다. | 한 binding에서 발급한 token을 다른 binding/scope에 사용한다. | `token_invalid`. | | AUTH-TC-CS004 | Expired token과 현재 시각보다 TTL+skew를 초과해 미래인 token을 거부해야 한다. | 만료 뒤 또는 발급 시각보다 31초 이전 시각에서 검증한다. | `token_expired` 또는 `token_invalid`. | -| AUTH-TC-CS005 | Anonymous `/auth/csrf` bootstrap은 no-store body token과 host-only HttpOnly token/seed cookie를 발급해야 한다. | Auth cookie 없이 GET한다. | `200`, body/cookie token 일치, Domain 미설정, Path `/api/v1`. | +| AUTH-TC-CS005 | Browser-proven anonymous `/auth/csrf` bootstrap은 no-store body token과 host-only HttpOnly token/seed cookie를 발급해야 한다. | Bootstrap header와 same-origin Fetch Metadata를 포함하고 Auth cookie 없이 GET한다. | `200`, body/cookie token 일치, Domain 미설정, Path `/api/v1`. | | AUTH-TC-CS006 | Authenticated bootstrap은 auth cookie를 검증하고 stale anonymous seed를 삭제해야 한다. | Valid auth cookie와 기존 seed를 함께 보낸다. | Auth-bound token, seed `Max-Age=0`. | -| AUTH-TC-CS007 | Invalid auth cookie bootstrap은 anonymous로 같은 응답에서 전환하지 않아야 한다. | Invalid auth cookie로 GET한다. | `401 auth.invalid`, auth/CSRF cookie 삭제; Client는 한 번만 재bootstrap. | +| AUTH-TC-CS007 | Invalid 또는 inactive-session auth cookie bootstrap은 anonymous로 같은 응답에서 전환하지 않아야 한다. | Invalid JWT와 inactive account cookie로 각각 GET한다. | 모두 `401 auth.invalid`, auth/CSRF cookie 삭제; Client는 한 번만 재bootstrap. | | AUTH-TC-CS008 | Signup/login/logout은 pre-auth 또는 auth-bound token 없이 credential/DB lifecycle에 진입하지 않아야 한다. | Exact Origin은 있지만 token이 없거나 forged token이다. | Fixed 403, endpoint effect 0. | | AUTH-TC-CS009 | Login/signup/OAuth success와 logout은 stale CSRF cookie family를 삭제해야 한다. | 각 성공 응답의 Set-Cookie를 검사한다. | `csrf_token`, `csrf_anon_seed` 삭제. | | AUTH-TC-CS010 | 모든 unsafe route는 정확히 하나의 route policy를 가져야 한다. | 신규 unauthenticated POST를 registry 없이 추가하거나 명시 예외를 삭제한다. | Startup/architecture test 실패. | @@ -135,16 +135,18 @@ Status: Draft | AUTH-TC-CS012 | Missing/null/unlisted Origin은 body parsing과 side effect 전에 거부해야 한다. | Valid token에 Origin을 누락하거나 attacker Origin을 보낸다. | Fixed 403, body/endpoint effect 0. | | AUTH-TC-CS013 | Fetch Metadata가 있으면 same-origin/same-site만 허용해야 한다. | `Sec-Fetch-Site: cross-site`를 보낸다. | Fixed 403; header가 없고 다른 증거가 valid이면 호환 허용. | | AUTH-TC-CS014 | JSON route는 UTF-8 JSON만 허용해야 한다. | text/plain, form, JSON profile/latin1 parameter를 보낸다. | `content_type_invalid`, body/endpoint effect 0. | -| AUTH-TC-CS015 | 승인 multipart와 bodyless route만 해당 content 예외를 사용해야 한다. | Valid multipart upload, malformed boundary, 빈 DELETE를 각각 보낸다. | Valid multipart/DELETE 성공, malformed boundary 403. | +| AUTH-TC-CS015 | 승인 multipart와 bodyless route만 해당 content 예외를 사용해야 한다. | Valid multipart upload, malformed boundary, form media type과 명시적 zero length인 빈 Axios POST, non-empty form POST를 각각 보낸다. | Valid multipart/zero-length POST 성공, malformed multipart와 non-empty form POST는 403. | | AUTH-TC-CS016 | 인증 cookie 없는 cookie-authenticated mutation은 token으로 identity를 만들지 않아야 한다. | Origin과 payload만 보호 route에 보낸다. | Side effect 전 `401 auth.required`. | | AUTH-TC-CS017 | CSRF denial 관측값은 bounded label만 포함해야 한다. | Token/Origin/session/org/path sentinel을 실패 요청에 주입한다. | Response/log/audit/metric에 sentinel 없음; reason/policy/method/request ID만 기록. | | AUTH-TC-CS018 | Production/development enforcement는 기본 활성화되고 test만 disable할 수 있어야 한다. | Production disabled 또는 unknown mode로 구성한다. | Startup 오류; `NODE_ENV=test` disabled만 허용. | -| AUTH-TC-CS019 | Client token manager는 same-scope single-flight와 memory-only storage를 유지해야 한다. | 동시 unsafe 요청과 storage spy를 사용한다. | Bootstrap 1회, local/session storage write 0회. | +| AUTH-TC-CS019 | Client token manager는 origin별 current-scope token 하나, same-origin-and-scope single-flight와 memory-only storage를 유지해야 한다. | 같은 scope로 web/API origin mutation을 보내고, 같은 origin에서 A→B→A scope로 전환하며 storage spy를 사용한다. | Origin별 bootstrap/cookie 분리, 같은 origin의 이전 scope cache 재사용 없음, local/session storage write 0회. | | AUTH-TC-CS020 | Organization/auth lifecycle은 cached token을 폐기해야 한다. | Organization 변경, signup/login/logout/OAuth 전환 뒤 다음 mutation을 보낸다. | 새 scope/session bootstrap; old token replay 실패. | | AUTH-TC-CS021 | Fixed CSRF 오류의 자동 replay는 안전한 요청 한 번으로 제한해야 한다. | PUT과 idempotency 없는 POST에서 첫 요청을 403으로 만든다. | PUT 최대 1회 재시도, POST 재시도 0회, 무한 loop 없음. | | AUTH-TC-CS022 | 보호 direct fetch는 token과 active organization header를 함께 보내야 한다. | Settings/Wizard/RAG stream mutation을 호출한다. | `X-CSRF-Token`, credential, 동일 organization scope 포함. | | AUTH-TC-CS023 | Workflow stream proxy는 browser security context와 host-only token cookie를 안전하게 중계해야 한다. | Origin, Fetch Metadata, header token, stale cookie와 Authorization을 함께 보낸다. | Context/token 전달, stale CSRF cookie 교체, Authorization 미전달; Gateway가 최종 검증. | | AUTH-TC-CS024 | Middleware 순서는 허용된 Client가 안전한 CSRF 오류를 읽고 Public/webhook 외곽 경계를 유지해야 한다. | `app.user_middleware` 순서를 검사한다. | Webhook redaction → Public CORS → credentialed CORS → CSRF → Session 순서. | +| AUTH-TC-CS025 | Ambient cross-site GET은 bootstrap cookie를 회전시키지 않아야 한다. | Custom bootstrap header 없이 cross-site image/navigation 요청을 보내거나 unlisted same-site Origin에서 header를 보낸다. | Fixed 403, Set-Cookie 없음, token service/DB 미진입. | +| AUTH-TC-CS026 | Auth/organization lifecycle 전환 전의 in-flight bootstrap은 stale token을 되살리지 않아야 한다. | Bootstrap A가 pending인 동안 cache를 invalidate하고 같은 origin/scope bootstrap B를 시작한 뒤 A를 늦게 완료한다. | A caller는 mutation 전 실패, B는 A 정리 뒤 발급되어 최종 cookie/cache를 소유하고 이후 요청이 B를 재사용. | ## Component And Hook Tests | ID | 검증 조건 | 최소 실패 조건 | 기대 결과 | From 477d9ce4b58ea589a2e48c1e92dc9cc8ef553ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Wed, 29 Jul 2026 19:02:18 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20CSRF=20=EB=8F=99=EC=8B=9C=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EB=B0=8F=20=EA=B0=90=EC=82=AC=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/client/lib/csrfToken.test.ts | 66 +++++++++++++++ apps/client/lib/csrfToken.ts | 54 ++++++++++-- apps/gateway/application/csrf/token.py | 2 + apps/gateway/core/request_id.py | 19 +++++ apps/gateway/main.py | 4 +- apps/gateway/middleware/csrf.py | 40 +++++---- .../tests/api/test_organizations_api.py | 44 +++++----- .../tests/api/test_permission_denied_audit.py | 11 ++- .../gateway/tests/api/test_permissions_api.py | 48 +++++------ .../tests/api/test_request_id_middleware.py | 35 ++++++-- apps/gateway/tests/api/test_teams_api.py | 36 ++++---- .../application/csrf/test_token_service.py | 15 ++++ .../architecture/test_csrf_route_inventory.py | 17 ++++ .../tests/middleware/test_csrf_protection.py | 83 +++++++++++++++++-- docs/architecture.md | 3 +- ...-cookie-authenticated-api-csrf-boundary.md | 12 ++- docs/features/auth/api_spec.md | 10 +-- docs/features/auth/component_spec.md | 5 +- docs/features/auth/requirements.md | 5 ++ docs/features/auth/test_cases.md | 6 ++ 20 files changed, 398 insertions(+), 117 deletions(-) create mode 100644 apps/gateway/core/request_id.py diff --git a/apps/client/lib/csrfToken.test.ts b/apps/client/lib/csrfToken.test.ts index 1cf59ab29..4cdba1da7 100644 --- a/apps/client/lib/csrfToken.test.ts +++ b/apps/client/lib/csrfToken.test.ts @@ -214,6 +214,72 @@ describe('attachCsrfProtection', () => { expect(attempts).toBe(2); }); + it('coalesces one refresh when concurrent replay-safe requests reject the same token', async () => { + let resolveRefresh: ((response: Response) => void) | undefined; + let markRefreshStarted: (() => void) | undefined; + const refreshStarted = new Promise((resolve) => { + markRefreshStarted = resolve; + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(csrfResponse('expired-token')) + .mockImplementationOnce(() => { + markRefreshStarted?.(); + return new Promise((resolve) => { + resolveRefresh = resolve; + }); + }); + vi.stubGlobal('fetch', fetchMock); + let markSecondExpiredAttempt: (() => void) | undefined; + const secondExpiredAttempt = new Promise((resolve) => { + markSecondExpiredAttempt = resolve; + }); + const seenTokens: string[] = []; + const adapter: AxiosAdapter = async (config) => { + const token = String( + AxiosHeaders.from(config.headers).get('X-CSRF-Token'), + ); + seenTokens.push(token); + if (token === 'expired-token') { + if (config.url?.endsWith('/b')) { + await refreshStarted; + markSecondExpiredAttempt?.(); + } + return Promise.reject({ + isAxiosError: true, + config, + response: { + status: 403, + data: { + error: { code: 'auth.csrf_validation_failed' }, + }, + }, + }); + } + return success(config); + }; + const client = axios.create({ adapter, withCredentials: true }); + attachCsrfProtection(client); + + const requests = Promise.all([ + client.put('/protected/a', { value: 1 }), + client.delete('/protected/b'), + ]); + await secondExpiredAttempt; + await new Promise((resolve) => setTimeout(resolve, 0)); + resolveRefresh?.(csrfResponse('refreshed-token')); + + await expect(requests).resolves.toHaveLength(2); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(seenTokens.filter((token) => token === 'expired-token')).toHaveLength( + 2, + ); + expect( + seenTokens.filter((token) => token === 'refreshed-token'), + ).toHaveLength(2); + }); + it('does not automatically replay non-idempotent requests', async () => { vi.stubGlobal( 'fetch', diff --git a/apps/client/lib/csrfToken.ts b/apps/client/lib/csrfToken.ts index 2516669a8..c88b63336 100644 --- a/apps/client/lib/csrfToken.ts +++ b/apps/client/lib/csrfToken.ts @@ -160,6 +160,28 @@ export const invalidateCsrfToken = () => { cachedTokenByOrigin.clear(); }; +const invalidateRejectedCsrfToken = ( + organizationId: string | null | undefined, + requestTarget: RequestInfo | URL | undefined, + rejectedToken: unknown, +) => { + if (typeof rejectedToken !== 'string' || rejectedToken.length === 0) { + return false; + } + const scope = normalizeScope(organizationId); + const target = resolveBootstrapTarget(requestTarget); + const cachedToken = cachedTokenByOrigin.get(target.cacheKey); + if ( + !cachedToken || + cachedToken.scope !== scope || + cachedToken.token !== rejectedToken + ) { + return false; + } + invalidateCsrfToken(); + return true; +}; + export const getCsrfToken = async ( organizationId?: string | null, requestTarget?: RequestInfo | URL, @@ -248,8 +270,16 @@ export const attachCsrfProtection = (client: AxiosInstance) => { const error = rawError as AxiosError; if (!isFixedCsrfFailure(error)) return Promise.reject(rawError); - invalidateCsrfToken(); const config = error.config as RetryableRequestConfig | undefined; + if (config) { + const headers = AxiosHeaders.from(config.headers); + const organizationId = headers.get(ORGANIZATION_HEADER_NAME); + invalidateRejectedCsrfToken( + typeof organizationId === 'string' ? organizationId : null, + axiosRequestTarget(config), + headers.get(CSRF_HEADER_NAME), + ); + } if (!config || config._csrfRetried || !isReplaySafe(config)) { return Promise.reject(rawError); } @@ -288,24 +318,30 @@ export const csrfFetch = async ( if (organizationId && !headers.has(ORGANIZATION_HEADER_NAME)) { headers.set(ORGANIZATION_HEADER_NAME, organizationId); } - headers.set(CSRF_HEADER_NAME, await getCsrfToken(organizationId, input)); - return fetch(input, { + const token = await getCsrfToken(organizationId, input); + headers.set(CSRF_HEADER_NAME, token); + const response = await fetch(input, { ...init, method, headers, credentials: init.credentials ?? 'include', }); + return { organizationId, response, token }; }; - const firstResponse = await send(); - if (!(await responseHasFixedCsrfFailure(firstResponse))) { - return firstResponse; + const firstAttempt = await send(); + if (!(await responseHasFixedCsrfFailure(firstAttempt.response))) { + return firstAttempt.response; } - invalidateCsrfToken(); + invalidateRejectedCsrfToken( + firstAttempt.organizationId, + input, + firstAttempt.token, + ); const headers = new Headers(init.headers); - if (!fetchReplaySafe(method, headers)) return firstResponse; - return send(); + if (!fetchReplaySafe(method, headers)) return firstAttempt.response; + return (await send()).response; }; if (typeof window !== 'undefined') { diff --git a/apps/gateway/application/csrf/token.py b/apps/gateway/application/csrf/token.py index 406a74b19..c7b2bcd9c 100644 --- a/apps/gateway/application/csrf/token.py +++ b/apps/gateway/application/csrf/token.py @@ -154,6 +154,8 @@ def validate( if ( len(header_token) > _MAX_TOKEN_LENGTH or len(cookie_token) > _MAX_TOKEN_LENGTH + or not header_token.isascii() + or not cookie_token.isascii() ): return CsrfValidationReason.TOKEN_INVALID if not hmac.compare_digest(header_token, cookie_token): diff --git a/apps/gateway/core/request_id.py b/apps/gateway/core/request_id.py new file mode 100644 index 000000000..0d9ec1f7e --- /dev/null +++ b/apps/gateway/core/request_id.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import uuid + + +def safe_request_id(value: object) -> str: + """Return a canonical RFC 4122 UUID without reflecting untrusted text.""" + if isinstance(value, str): + try: + parsed = uuid.UUID(value) + except (AttributeError, ValueError): + parsed = None + if ( + parsed is not None + and parsed.variant == uuid.RFC_4122 + and str(parsed) == value + ): + return value + return str(uuid.uuid4()) diff --git a/apps/gateway/main.py b/apps/gateway/main.py index 038a13514..891911da3 100644 --- a/apps/gateway/main.py +++ b/apps/gateway/main.py @@ -2,7 +2,6 @@ # .env 파일을 기본값으로 로드 ( 개발 환경 ) import logging import sys -import uuid from pathlib import Path from dotenv import load_dotenv @@ -61,6 +60,7 @@ parse_credentialed_cors_origins, resolve_session_signing_secret, ) +from apps.gateway.core.request_id import safe_request_id from apps.gateway.lifespan import lifespan # Import lifespan from module from apps.gateway.middleware.webhook_query_redaction import ( WebhookQueryRedactionMiddleware, @@ -86,7 +86,7 @@ # 요청별 request_id를 보장하고 audit 로그용 요청 metadata를 전파한다. @app.middleware("http") async def add_request_id(request: Request, call_next): - request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) + request_id = safe_request_id(request.headers.get("X-Request-ID")) request.state.request_id = request_id token = set_current_metadata( { diff --git a/apps/gateway/middleware/csrf.py b/apps/gateway/middleware/csrf.py index d998e53f7..16f63d19c 100644 --- a/apps/gateway/middleware/csrf.py +++ b/apps/gateway/middleware/csrf.py @@ -3,10 +3,10 @@ import inspect import logging import re -import uuid from collections.abc import Callable, Sequence from typing import Any +from anyio import CapacityLimiter, to_thread from fastapi import Request from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware @@ -27,6 +27,7 @@ CsrfTokenService, CsrfValidationReason, ) +from apps.gateway.core.request_id import safe_request_id logger = logging.getLogger(__name__) @@ -40,6 +41,7 @@ _JSON_MEDIA_TYPE = "application/json" _MULTIPART_MEDIA_TYPE = "multipart/form-data" _MULTIPART_BOUNDARY_PATTERN = re.compile(r"^[0-9A-Za-z._-]{1,70}$") +_TELEMETRY_CONCURRENCY_LIMIT = 4 class CsrfProtectionMiddleware(BaseHTTPMiddleware): @@ -61,6 +63,7 @@ def __init__( self._enforcement_enabled = enforcement_enabled self._on_denied = on_denied self._on_auth_required = on_auth_required + self._telemetry_limiter = CapacityLimiter(_TELEMETRY_CONCURRENCY_LIMIT) async def dispatch(self, request: Request, call_next) -> Response: policy = self._registry.match(request.method, request.url.path) @@ -178,35 +181,37 @@ def _parse_parameters( parameters[name] = value.lower() if name == "charset" else value return parameters - @staticmethod - def _safe_request_id(value: object) -> str: - if ( - isinstance(value, str) - and 1 <= len(value) <= 128 - and all(ord(character) >= 32 for character in value) - ): - return value - return str(uuid.uuid4()) + async def _run_telemetry_callback( + self, + callback: Callable[..., Any], + *args: Any, + ) -> None: + callback_result = await to_thread.run_sync( + callback, + *args, + limiter=self._telemetry_limiter, + ) + if inspect.isawaitable(callback_result): + await callback_result async def _authentication_required( self, request: Request, policy: CsrfRoutePolicy, ) -> Response: - request_id = self._safe_request_id( + request_id = safe_request_id( request.headers.get("X-Request-ID") or getattr(request.state, "request_id", None) ) request.state.request_id = request_id if self._on_auth_required is not None: try: - callback_result = self._on_auth_required( + await self._run_telemetry_callback( + self._on_auth_required, policy, request.method.upper(), request_id, ) - if inspect.isawaitable(callback_result): - await callback_result except Exception as exc: logger.error( "Authentication denial telemetry failed: error_type=%s", @@ -233,20 +238,19 @@ async def _denied( policy: CsrfRoutePolicy, reason: CsrfValidationReason, ) -> Response: - request_id = self._safe_request_id( + request_id = safe_request_id( request.headers.get("X-Request-ID") or getattr(request.state, "request_id", None) ) request.state.request_id = request_id try: - callback_result = self._on_denied( + await self._run_telemetry_callback( + self._on_denied, reason, policy, request.method.upper(), request_id, ) - if inspect.isawaitable(callback_result): - await callback_result except Exception as exc: logger.error( "CSRF denial telemetry failed: error_type=%s", diff --git a/apps/gateway/tests/api/test_organizations_api.py b/apps/gateway/tests/api/test_organizations_api.py index 7c5a3c111..41f64f12b 100644 --- a/apps/gateway/tests/api/test_organizations_api.py +++ b/apps/gateway/tests/api/test_organizations_api.py @@ -378,7 +378,7 @@ def test_route_requires_current_organization_header(self): response = TestClient(app).get( "/api/v1/organizations/current", - headers={"X-Request-ID": "req-test"}, + headers={"X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b"}, ) self.assertEqual(response.status_code, 400) @@ -400,7 +400,7 @@ def test_route_rejects_invalid_current_organization_header(self): "/api/v1/organizations/current", headers={ "X-Organization-Id": "not-a-uuid", - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, ) @@ -428,7 +428,7 @@ def test_route_hides_current_organization_outside_user_memberships(self): "/api/v1/organizations/current", headers={ "X-Organization-Id": str(organization_id), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, ) @@ -736,14 +736,14 @@ def test_member_management_routes_require_matching_organization_header(self): ] header_cases = [ ( - {"X-Request-ID": "req-test"}, + {"X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b"}, 400, _error("organization.required", "X-Organization-Id header is required."), ), ( { "X-Organization-Id": "not-a-uuid", - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, 422, _error( @@ -755,7 +755,7 @@ def test_member_management_routes_require_matching_organization_header(self): ( { "X-Organization-Id": str(header_organization_id), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, 404, _error("resource.not_found", "Organization not found."), @@ -834,7 +834,7 @@ def test_member_routes_wrap_service_errors(self): kwargs["json"] = {"organization_auth_state": "manager"} else: kwargs["json"] = {"user_id": str(target_user_id)} - headers = {"X-Request-ID": "req-test"} + headers = {"X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b"} if service_name != "accept_invitation": headers["X-Organization-Id"] = str(organization_id) response = getattr(TestClient(app), method)( @@ -845,7 +845,7 @@ def test_member_routes_wrap_service_errors(self): self.assertEqual(response.status_code, exc.status_code) self.assertEqual(response.json()["error"]["code"], code) - self.assertEqual(response.json()["error"]["request_id"], "req-test") + self.assertEqual(response.json()["error"]["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") def test_route_hides_organization_outside_user_memberships(self): # 조직이 없거나 현재 사용자의 membership scope 밖이면 존재 여부를 노출하지 않고 404로 숨긴다. @@ -859,7 +859,7 @@ def test_route_hides_organization_outside_user_memberships(self): response = TestClient(app).get( f"/api/v1/organizations/{organization_id}", - headers={"X-Request-ID": "req-test"}, + headers={"X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b"}, ) self.assertEqual(response.status_code, 404) @@ -908,7 +908,7 @@ def test_patch_organization_requires_organization_header(self): response = TestClient(app).patch( f"/api/v1/organizations/{organization_id}", - headers={"X-Request-ID": "req-test"}, + headers={"X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b"}, json={"name": "Acme Korea"}, ) @@ -929,7 +929,7 @@ def test_patch_organization_rejects_invalid_organization_header(self): f"/api/v1/organizations/{organization_id}", headers={ "X-Organization-Id": "not-a-uuid", - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={"name": "Acme Korea"}, ) @@ -956,7 +956,7 @@ def test_patch_organization_hides_header_path_mismatch(self): f"/api/v1/organizations/{organization_id}", headers={ "X-Organization-Id": str(header_organization_id), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={"name": "Acme Korea"}, ) @@ -983,7 +983,7 @@ def test_patch_organization_rejects_non_manager(self): f"/api/v1/organizations/{organization_id}", headers={ "X-Organization-Id": str(organization_id), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={"name": "Acme Korea"}, ) @@ -1015,7 +1015,7 @@ def test_patch_organization_non_manager_records_scoped_permission_denial(self): f"/api/v1/organizations/{organization_id}", headers={ "X-Organization-Id": str(organization_id), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={"name": "Acme Korea"}, ) @@ -1050,7 +1050,7 @@ def test_patch_organization_hides_missing_or_out_of_scope_organization(self): f"/api/v1/organizations/{organization_id}", headers={ "X-Organization-Id": str(organization_id), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={"name": "Acme Korea"}, ) @@ -1078,7 +1078,7 @@ def test_patch_organization_hides_different_organization_from_query(self): f"/api/v1/organizations/{organization_id}", headers={ "X-Organization-Id": str(organization_id), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={"name": "Acme Korea"}, ) @@ -1107,7 +1107,7 @@ def test_patch_organization_hides_inactive_organization(self): f"/api/v1/organizations/{organization_id}", headers={ "X-Organization-Id": str(organization_id), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={"name": "Acme Korea"}, ) @@ -1135,7 +1135,7 @@ def test_patch_organization_rejects_empty_update(self): f"/api/v1/organizations/{organization_id}", headers={ "X-Organization-Id": str(organization_id), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={}, ) @@ -1162,7 +1162,7 @@ def test_patch_organization_rejects_blank_name(self): f"/api/v1/organizations/{organization_id}", headers={ "X-Organization-Id": str(organization_id), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={"name": " "}, ) @@ -1193,14 +1193,14 @@ def test_patch_organization_rejects_name_longer_than_database_column(self): f"/api/v1/organizations/{organization_id}", headers={ "X-Organization-Id": str(organization_id), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={"name": "A" * 256}, ) self.assertEqual(response.status_code, 422) self.assertEqual(response.json()["error"]["code"], "validation.failed") - self.assertEqual(response.json()["error"]["request_id"], "req-test") + self.assertEqual(response.json()["error"]["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") self.assertEqual( response.json()["error"]["message"], "Request validation failed.", @@ -1488,7 +1488,7 @@ def _error(code, message, details=None): "error": { "code": code, "message": message, - "request_id": "req-test", + "request_id": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", "details": details or {}, } } diff --git a/apps/gateway/tests/api/test_permission_denied_audit.py b/apps/gateway/tests/api/test_permission_denied_audit.py index 0ebc7711b..d9f793831 100644 --- a/apps/gateway/tests/api/test_permission_denied_audit.py +++ b/apps/gateway/tests/api/test_permission_denied_audit.py @@ -11,6 +11,9 @@ from apps.shared.db.session import get_db +REQUEST_ID = "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b" + + class TestPermissionDeniedAudit(unittest.TestCase): def setUp(self): self.client = TestClient(app) @@ -25,7 +28,7 @@ def test_unauthorized_request_records_audit(self): with patch("apps.gateway.main.record_audit") as record_audit: response = self.client.get( "/api/v1/auth/me", - headers={"X-Request-ID": "req-test"}, + headers={"X-Request-ID": REQUEST_ID}, ) self.assertEqual(response.status_code, 401) @@ -36,7 +39,7 @@ def test_unauthorized_request_records_audit(self): self.assertEqual(event["metadata"]["method"], "GET") self.assertEqual(event["metadata"]["path"], "/api/v1/auth/me") self.assertEqual(event["metadata"]["status_code"], 401) - self.assertEqual(event["metadata"]["request_id"], "req-test") + self.assertEqual(event["metadata"]["request_id"], REQUEST_ID) def test_forbidden_request_records_audit(self): mock_db_session = MagicMock() @@ -53,7 +56,7 @@ def test_forbidden_request_records_audit(self): json={}, headers={ "Authorization": "Bearer wrong-token", - "X-Request-ID": "req-test", + "X-Request-ID": REQUEST_ID, }, ) @@ -65,7 +68,7 @@ def test_forbidden_request_records_audit(self): self.assertEqual(event["metadata"]["method"], "POST") self.assertEqual(event["metadata"]["path"], "/api/v1/hooks/test-slug") self.assertEqual(event["metadata"]["status_code"], 403) - self.assertEqual(event["metadata"]["request_id"], "req-test") + self.assertEqual(event["metadata"]["request_id"], REQUEST_ID) def test_already_recorded_permission_denial_skips_global_auth_audit(self): request = Request( diff --git a/apps/gateway/tests/api/test_permissions_api.py b/apps/gateway/tests/api/test_permissions_api.py index e47b0b0b3..ca6d88dbb 100644 --- a/apps/gateway/tests/api/test_permissions_api.py +++ b/apps/gateway/tests/api/test_permissions_api.py @@ -144,7 +144,7 @@ def test_put_team_workflow_permission_creates_row_for_organization_manager(self) "auth_state": "builder", }, ) - self.assertEqual(audit.audit_metadata["request_id"], "req-test") + self.assertEqual(audit.audit_metadata["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") self.assertEqual(audit.audit_metadata["actor"]["id"], str(user_id)) def test_put_team_workflow_permission_rejects_primary_changed_while_waiting(self): @@ -580,7 +580,7 @@ def test_put_user_knowledge_permission_creates_row_for_organization_manager(self self.assertEqual(audit.after["user_id"], str(target_user_id)) self.assertEqual(audit.after["auth_state"], "builder") self.assertNotIn("assigned_at", audit.after) - self.assertEqual(audit.audit_metadata["request_id"], "req-test") + self.assertEqual(audit.audit_metadata["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") self.assertEqual(audit.audit_metadata["actor"]["id"], str(user_id)) self.assertEqual( audit.audit_metadata["organization_id"], str(organization_id) @@ -1086,7 +1086,7 @@ def test_put_team_workflow_permission_updates_row_for_workflow_manager(self): self.assertEqual(audit.before["team_id"], str(team_id)) self.assertNotIn("assigned_by", audit.before) self.assertNotIn("assigned_at", audit.before) - self.assertEqual(audit.audit_metadata["request_id"], "req-test") + self.assertEqual(audit.audit_metadata["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") self.assertEqual(audit.audit_metadata["actor"]["id"], str(user_id)) def test_put_team_workflow_permission_rejects_member_without_manage(self): @@ -1551,7 +1551,7 @@ def test_put_team_llm_permission_rejects_invalid_auth_state(self): self.assertEqual(response.status_code, 422) self.assertEqual(response.json()["error"]["code"], "validation.failed") - self.assertEqual(response.json()["error"]["request_id"], "req-test") + self.assertEqual(response.json()["error"]["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") self.assertEqual( response.json()["error"]["details"]["errors"][0]["loc"], ["body", "auth_state"], @@ -2054,7 +2054,7 @@ def test_put_team_workflow_permission_rejects_invalid_auth_state(self): self.assertEqual(response.status_code, 422) self.assertEqual(response.json()["error"]["code"], "validation.failed") - self.assertEqual(response.json()["error"]["request_id"], "req-test") + self.assertEqual(response.json()["error"]["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") self.assertEqual( response.json()["error"]["details"]["errors"][0]["loc"], ["body", "auth_state"], @@ -2636,7 +2636,7 @@ def test_delete_team_workflow_permission_deletes_row_for_organization_manager(se self.assertEqual(audit.before["workflow_id"], str(workflow_id)) self.assertEqual(audit.before["team_id"], str(team_id)) self.assertIsNone(audit.after) - self.assertEqual(audit.audit_metadata["request_id"], "req-test") + self.assertEqual(audit.audit_metadata["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") self.assertEqual(audit.audit_metadata["actor"]["id"], str(user_id)) def test_delete_team_workflow_permission_allows_workflow_manager(self): @@ -2843,7 +2843,7 @@ def test_delete_team_llm_permission_deletes_row_for_organization_manager(self): self.assertEqual(audit.before["team_id"], str(team_id)) self.assertEqual(audit.before["auth_state"], "operator") self.assertIsNone(audit.after) - self.assertEqual(audit.audit_metadata["request_id"], "req-test") + self.assertEqual(audit.audit_metadata["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") self.assertEqual(audit.audit_metadata["actor"]["id"], str(user_id)) def test_delete_team_llm_permission_allows_credential_manager(self): @@ -3068,7 +3068,7 @@ def test_delete_user_workflow_permission_deletes_row_for_organization_manager(se self.assertEqual( audit.audit_metadata["organization_id"], str(organization_id) ) - self.assertEqual(audit.audit_metadata["request_id"], "req-test") + self.assertEqual(audit.audit_metadata["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") _assert_audit_added_before_commit(self, session) def test_delete_user_workflow_permission_locks_subject_before_app_scope(self): @@ -3332,7 +3332,7 @@ def test_delete_user_llm_permission_deletes_row_for_organization_manager(self): audit.audit_metadata["organization_id"], str(organization_id) ) self.assertIsNone(audit.after) - self.assertEqual(audit.audit_metadata["request_id"], "req-test") + self.assertEqual(audit.audit_metadata["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") _assert_audit_added_before_commit(self, session) def test_delete_user_llm_permission_allows_credential_manager(self): @@ -3511,7 +3511,7 @@ def _put_permission( _ensure_active_user_row(session, user_id) app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -3548,7 +3548,7 @@ def _get_workflow_permissions( """fake DB session과 fake 인증 결과로 workflow permission 목록 endpoint를 호출한다.""" app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -3584,7 +3584,7 @@ def _get_knowledge_permissions( """fake DB session과 fake 인증 결과로 KB permission 목록 endpoint를 호출한다.""" app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -3622,7 +3622,7 @@ def _put_user_permission( """fake DB session과 fake 인증 결과로 user 권한 PUT endpoint를 호출한다.""" app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -3662,7 +3662,7 @@ def _put_knowledge_permission( _ensure_active_user_row(session, user_id) app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -3701,7 +3701,7 @@ def _put_user_knowledge_permission( """fake DB session과 fake 인증 결과로 KB user 직접 권한 PUT endpoint를 호출한다.""" app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -3741,7 +3741,7 @@ def _put_llm_permission( _ensure_active_user_row(session, user_id) app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -3780,7 +3780,7 @@ def _put_user_llm_permission( """fake DB session과 fake 인증 결과로 LLM credential user 권한 PUT endpoint를 호출한다.""" app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -3818,7 +3818,7 @@ def _delete_permission( """fake DB session과 fake 인증 결과로 권한 DELETE endpoint를 호출한다.""" app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -3855,7 +3855,7 @@ def _delete_llm_permission( """fake DB session과 fake 인증 결과로 LLM credential team 권한 DELETE endpoint를 호출한다.""" app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -3892,7 +3892,7 @@ def _delete_user_permission( """fake DB session과 fake 인증 결과로 user 권한 DELETE endpoint를 호출한다.""" app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -3929,7 +3929,7 @@ def _delete_knowledge_permission( """fake DB session과 fake 인증 결과로 KB team 권한 DELETE endpoint를 호출한다.""" app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -3966,7 +3966,7 @@ def _delete_user_knowledge_permission( """fake DB session과 fake 인증 결과로 KB user 직접 권한 DELETE endpoint를 호출한다.""" app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -4003,7 +4003,7 @@ def _delete_user_llm_permission( """fake DB session과 fake 인증 결과로 LLM credential user 권한 DELETE endpoint를 호출한다.""" app.dependency_overrides[get_db] = lambda: session headers = { - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", } if include_auth_cookie: headers["Cookie"] = "auth_token=token" @@ -5299,7 +5299,7 @@ def _error(code, message, details=None): "error": { "code": code, "message": message, - "request_id": "req-test", + "request_id": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", "details": details or {}, } } diff --git a/apps/gateway/tests/api/test_request_id_middleware.py b/apps/gateway/tests/api/test_request_id_middleware.py index 76e7afab8..8cd19ec3c 100644 --- a/apps/gateway/tests/api/test_request_id_middleware.py +++ b/apps/gateway/tests/api/test_request_id_middleware.py @@ -1,4 +1,5 @@ import unittest +from uuid import UUID from fastapi.testclient import TestClient @@ -7,25 +8,49 @@ from apps.shared.audit.context import clear_current_metadata, set_current_metadata +REQUEST_ID = "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b" + + class TestRequestIdMiddleware(unittest.TestCase): def setUp(self): self.client = TestClient(app) def test_request_id_header_is_preserved(self): - response = self.client.get("/", headers={"X-Request-ID": "req-test"}) + response = self.client.get("/", headers={"X-Request-ID": REQUEST_ID}) - self.assertEqual(response.headers["X-Request-ID"], "req-test") + self.assertEqual(response.headers["X-Request-ID"], REQUEST_ID) def test_request_id_header_is_generated(self): response = self.client.get("/") - self.assertTrue(response.headers.get("X-Request-ID")) + self.assertEqual( + str(UUID(response.headers["X-Request-ID"])), + response.headers["X-Request-ID"], + ) + + def test_noncanonical_request_id_is_replaced_instead_of_echoed(self): + csrf_token_sentinel = ( + "v1.1780000000.bm9uY2U.Y3NyZi10b2tlbi1tdXN0LW5vdC1yZWFjaC1hdWRpdA" + ) + + response = self.client.get( + "/", + headers={"X-Request-ID": csrf_token_sentinel}, + ) + + safe_request_id = response.headers["X-Request-ID"] + self.assertNotEqual(safe_request_id, csrf_token_sentinel) + self.assertEqual(str(UUID(safe_request_id)), safe_request_id) class TestAuditContext(unittest.TestCase): def test_audit_metadata_reads_request_context_without_request_arg(self): token = set_current_metadata( - {"ip": "127.0.0.1", "user_agent": "test-agent", "request_id": "req-test"} + { + "ip": "127.0.0.1", + "user_agent": "test-agent", + "request_id": REQUEST_ID, + } ) try: self.assertEqual( @@ -33,7 +58,7 @@ def test_audit_metadata_reads_request_context_without_request_arg(self): { "ip": "127.0.0.1", "user_agent": "test-agent", - "request_id": "req-test", + "request_id": REQUEST_ID, }, ) finally: diff --git a/apps/gateway/tests/api/test_teams_api.py b/apps/gateway/tests/api/test_teams_api.py index f7932cb58..92687c100 100644 --- a/apps/gateway/tests/api/test_teams_api.py +++ b/apps/gateway/tests/api/test_teams_api.py @@ -310,7 +310,7 @@ def test_list_teams_returns_auth_envelope_for_unauthenticated_request(self): ): response = TestClient(app).get( "/api/v1/teams", - headers={"X-Request-ID": "req-test"}, + headers={"X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b"}, ) self.assertEqual(response.status_code, 401) @@ -456,7 +456,7 @@ def test_create_team_returns_auth_before_body_validation(self): "/api/v1/teams", headers={ "X-Organization-Id": str(uuid4()), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={}, ) @@ -806,7 +806,7 @@ def test_update_team_returns_auth_envelope_for_unauthenticated_request(self): f"/api/v1/teams/{uuid4()}", headers={ "X-Organization-Id": str(uuid4()), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={"name": "Builders"}, ) @@ -834,7 +834,7 @@ def test_update_team_rejects_invalid_team_id_route_parameter(self): self.assertEqual(response.status_code, 422) self.assertEqual(response.json()["error"]["code"], "validation.failed") - self.assertEqual(response.json()["error"]["request_id"], "req-test") + self.assertEqual(response.json()["error"]["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") self.assertEqual( response.json()["error"]["message"], "Request validation failed.", @@ -1107,7 +1107,7 @@ def test_add_team_member_returns_auth_before_body_validation(self): f"/api/v1/teams/{uuid4()}/members", headers={ "X-Organization-Id": str(uuid4()), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={}, ) @@ -1173,7 +1173,7 @@ def test_add_team_member_returns_auth_envelope_for_unauthenticated_request(self) f"/api/v1/teams/{uuid4()}/members", headers={ "X-Organization-Id": str(uuid4()), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, json={"user_id": str(uuid4())}, ) @@ -1200,7 +1200,7 @@ def test_add_team_member_rejects_invalid_team_id_route_parameter(self): self.assertEqual(response.status_code, 422) self.assertEqual(response.json()["error"]["code"], "validation.failed") - self.assertEqual(response.json()["error"]["request_id"], "req-test") + self.assertEqual(response.json()["error"]["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") self.assertEqual( response.json()["error"]["message"], "Request validation failed.", @@ -1404,7 +1404,7 @@ def test_remove_team_member_returns_auth_envelope_for_unauthenticated_request(se f"/api/v1/teams/{uuid4()}/members/{uuid4()}", headers={ "X-Organization-Id": str(uuid4()), - "X-Request-ID": "req-test", + "X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", }, ) @@ -1430,7 +1430,7 @@ def test_remove_team_member_rejects_invalid_team_id_route_parameter(self): self.assertEqual(response.status_code, 422) self.assertEqual(response.json()["error"]["code"], "validation.failed") - self.assertEqual(response.json()["error"]["request_id"], "req-test") + self.assertEqual(response.json()["error"]["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") self.assertEqual( response.json()["error"]["message"], "Request validation failed.", @@ -1456,7 +1456,7 @@ def test_remove_team_member_rejects_invalid_user_id_route_parameter(self): self.assertEqual(response.status_code, 422) self.assertEqual(response.json()["error"]["code"], "validation.failed") - self.assertEqual(response.json()["error"]["request_id"], "req-test") + self.assertEqual(response.json()["error"]["request_id"], "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b") self.assertEqual( response.json()["error"]["message"], "Request validation failed.", @@ -1649,7 +1649,7 @@ def _get_teams( ): session.authenticate(user_id) app.dependency_overrides[get_db] = lambda: session - headers = {"X-Request-ID": "req-test"} + headers = {"X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b"} if raw_organization_id is not None: headers["X-Organization-Id"] = raw_organization_id elif organization_id is not None: @@ -1675,7 +1675,7 @@ def _get_team_members( ): session.authenticate(user_id) app.dependency_overrides[get_db] = lambda: session - headers = {"X-Request-ID": "req-test"} + headers = {"X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b"} if raw_organization_id is not None: headers["X-Organization-Id"] = raw_organization_id elif organization_id is not None: @@ -1701,7 +1701,7 @@ def _post_team( ): session.authenticate(user_id) app.dependency_overrides[get_db] = lambda: session - headers = {"X-Request-ID": "req-test"} + headers = {"X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b"} if raw_organization_id is not None: headers["X-Organization-Id"] = raw_organization_id elif organization_id is not None: @@ -1729,7 +1729,7 @@ def _patch_team( ): session.authenticate(user_id) app.dependency_overrides[get_db] = lambda: session - headers = {"X-Request-ID": "req-test"} + headers = {"X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b"} if raw_organization_id is not None: headers["X-Organization-Id"] = raw_organization_id elif organization_id is not None: @@ -1757,7 +1757,7 @@ def _post_team_member( ): session.authenticate(user_id) app.dependency_overrides[get_db] = lambda: session - headers = {"X-Request-ID": "req-test"} + headers = {"X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b"} if raw_organization_id is not None: headers["X-Organization-Id"] = raw_organization_id elif organization_id is not None: @@ -1785,7 +1785,7 @@ def _delete_team_member( ): session.authenticate(user_id) app.dependency_overrides[get_db] = lambda: session - headers = {"X-Request-ID": "req-test"} + headers = {"X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b"} if raw_organization_id is not None: headers["X-Organization-Id"] = raw_organization_id elif organization_id is not None: @@ -1811,7 +1811,7 @@ def _delete_team( ): session.authenticate(user_id) app.dependency_overrides[get_db] = lambda: session - headers = {"X-Request-ID": "req-test"} + headers = {"X-Request-ID": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b"} if raw_organization_id is not None: headers["X-Organization-Id"] = raw_organization_id elif organization_id is not None: @@ -2201,7 +2201,7 @@ def _error(code, message, details=None): "error": { "code": code, "message": message, - "request_id": "req-test", + "request_id": "98d6d88b-8d7a-46fd-8d12-f2024d2fac4b", "details": details or {}, } } diff --git a/apps/gateway/tests/application/csrf/test_token_service.py b/apps/gateway/tests/application/csrf/test_token_service.py index e084aec1b..dd7f4fe9f 100644 --- a/apps/gateway/tests/application/csrf/test_token_service.py +++ b/apps/gateway/tests/application/csrf/test_token_service.py @@ -86,6 +86,21 @@ def test_missing_mismatched_and_malformed_tokens_are_rejected( assert reason is expected +def test_non_ascii_double_submit_tokens_are_rejected_without_exception( + service: CsrfTokenService, +): + reason = service.validate( + header_token="é", + cookie_token="é", + binding_kind=CsrfBindingKind.PRE_AUTH, + binding_secret="anonymous-seed", + organization_scope=None, + now=datetime(2026, 7, 29, tzinfo=timezone.utc), + ) + + assert reason is CsrfValidationReason.TOKEN_INVALID + + @pytest.mark.parametrize( ("binding_kind", "binding_secret", "organization_scope"), [ diff --git a/apps/gateway/tests/architecture/test_csrf_route_inventory.py b/apps/gateway/tests/architecture/test_csrf_route_inventory.py index 2db047344..819aa459a 100644 --- a/apps/gateway/tests/architecture/test_csrf_route_inventory.py +++ b/apps/gateway/tests/architecture/test_csrf_route_inventory.py @@ -1,4 +1,5 @@ from collections import Counter +from pathlib import Path import pytest from fastapi import FastAPI @@ -14,6 +15,9 @@ from apps.gateway.main import app +REPOSITORY_ROOT = Path(__file__).resolve().parents[4] + + def test_every_gateway_unsafe_route_has_exactly_one_csrf_policy(): registry = build_csrf_route_policy_registry(app) @@ -90,3 +94,16 @@ def new_public_mutation(): with pytest.raises(CsrfRouteInventoryError, match="unclassified unsafe route"): build_csrf_route_policy_registry(unclassified_app) + + +def test_auth_api_spec_lists_csrf_endpoint_only_in_endpoint_inventory(): + api_spec = ( + REPOSITORY_ROOT / "docs" / "features" / "auth" / "api_spec.md" + ).read_text(encoding="utf-8") + endpoint_row = ( + "| GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 " + "10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. " + "| Safe bootstrap; resource permission 없음 |" + ) + + assert api_spec.count(endpoint_row) == 1 diff --git a/apps/gateway/tests/middleware/test_csrf_protection.py b/apps/gateway/tests/middleware/test_csrf_protection.py index f808bfa4a..4e939eb59 100644 --- a/apps/gateway/tests/middleware/test_csrf_protection.py +++ b/apps/gateway/tests/middleware/test_csrf_protection.py @@ -1,5 +1,7 @@ import re +import threading +import httpx import pytest from fastapi import FastAPI, Request from fastapi.testclient import TestClient @@ -21,7 +23,8 @@ ORIGIN = "https://client.example" -NOW_HEADER = {"X-Request-ID": "csrf-request-id"} +REQUEST_ID = "b6cd0468-834d-4d26-868e-f25c887efe06" +NOW_HEADER = {"X-Request-ID": REQUEST_ID} def _policy( @@ -43,6 +46,8 @@ def _policy( def _build_app( token_service: CsrfTokenService, denied: list[tuple[str, str, str, str]], + *, + on_denied_override=None, ): app = FastAPI() effects = {"protected": 0, "public": 0, "pre_auth": 0} @@ -89,7 +94,7 @@ def on_denied(reason, policy, method, request_id): token_service=token_service, allowed_origins=(ORIGIN,), enforcement_enabled=True, - on_denied=on_denied, + on_denied=on_denied_override or on_denied, ) return app, effects @@ -185,7 +190,7 @@ def test_invalid_browser_boundary_is_rejected_before_body_or_side_effect( "error": { "code": "auth.csrf_validation_failed", "message": "CSRF validation failed.", - "request_id": "csrf-request-id", + "request_id": REQUEST_ID, } } assert effects["protected"] == 0 @@ -194,7 +199,7 @@ def test_invalid_browser_boundary_is_rejected_before_body_or_side_effect( expected_reason, "cookie_authenticated", "POST", - "csrf-request-id", + REQUEST_ID, ) ] @@ -217,13 +222,81 @@ def test_cookie_authenticated_route_without_auth_cookie_returns_401_before_effec "error": { "code": "auth.required", "message": "Authentication is required.", - "request_id": "csrf-request-id", + "request_id": REQUEST_ID, } } assert effects["protected"] == 0 assert denied == [] +@pytest.mark.asyncio +async def test_denial_telemetry_runs_outside_the_gateway_event_loop( + token_service: CsrfTokenService, +): + callback_threads: list[int] = [] + event_loop_thread = threading.get_ident() + + def on_denied(*_args): + callback_threads.append(threading.get_ident()) + + app, effects = _build_app( + token_service, + [], + on_denied_override=on_denied, + ) + headers, token = _authenticated_headers(token_service) + headers["Origin"] = "https://attacker.example" + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url=ORIGIN, + ) as client: + client.cookies.set("auth_token", "session-a") + client.cookies.set(CSRF_COOKIE_NAME, token) + response = await client.post( + "/protected", + headers=headers, + json={"value": 1}, + ) + + assert response.status_code == 403 + assert effects["protected"] == 0 + assert callback_threads + assert callback_threads[0] != event_loop_thread + + +def test_csrf_denial_replaces_noncanonical_request_id_before_telemetry( + token_service: CsrfTokenService, +): + denied: list[tuple[str, str, str, str]] = [] + app, effects = _build_app(token_service, denied) + headers, token = _authenticated_headers(token_service) + headers["Origin"] = "https://attacker.example" + headers["X-Request-ID"] = token + + with TestClient(app, base_url=ORIGIN) as client: + client.cookies.set("auth_token", "session-a") + client.cookies.set(CSRF_COOKIE_NAME, token) + response = client.post("/protected", headers=headers, json={"value": 1}) + + safe_request_id = response.json()["error"]["request_id"] + assert safe_request_id != token + assert re.fullmatch( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-" + r"[89ab][0-9a-f]{3}-[0-9a-f]{12}", + safe_request_id, + ) + assert denied == [ + ( + "origin_invalid", + "cookie_authenticated", + "POST", + safe_request_id, + ) + ] + assert effects["protected"] == 0 + + def test_pre_auth_token_uses_anonymous_seed_without_auth_cookie( token_service: CsrfTokenService, ): diff --git a/docs/architecture.md b/docs/architecture.md index d97568325..4c950cf3a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -674,8 +674,9 @@ Canonical content revision/input hash - 사용자 세션은 `auth_token` HttpOnly cookie 기준이다. user session용 Bearer token dependency는 없다. - Cookie-authenticated/pre-auth unsafe Gateway API는 [ADR-0073](decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md)의 signed double-submit 경계를 사용한다. Browser bootstrap header와 same-origin Fetch Metadata 또는 exact configured Origin을 증명한 `GET /api/v1/auth/csrf`만 10분 token을 body와 host-only HttpOnly cookie로 발급하며 token MAC은 auth cookie 또는 anonymous seed와 active organization/account scope에 결박된다. Ambient cross-site GET은 cookie를 회전시키지 않는다. - Gateway는 모든 unsafe route를 cookie, pre-auth, public anonymous 또는 server credential audience로 startup 시 분류한다. Cookie/pre-auth route는 body parsing과 DB·queue·storage·provider 이전에 exact configured Origin, Fetch Metadata, JSON/명시 multipart/bodyless와 token equality/signature/session/scope를 검증한다. 명시적 zero-length `BODY_OPTIONAL` 요청은 Axios media type과 무관하게 허용하고 non-empty form body는 거부한다. Public/server route는 login cookie를 principal로 해석하지 않는다. -- Browser Client는 CSRF token을 실제 mutation origin과 organization/account scope별 module memory에만 보관하고 active organization/auth lifecycle에서 폐기한다. Token bootstrap과 host-only cookie는 mutation과 같은 origin에서 수행한다. 안전하게 replay 가능한 request만 고정 CSRF 오류 뒤 최대 한 번 갱신·재시도한다. Workflow SSE Next proxy는 strict header token만 outbound host-only cookie로 복제하고 original Origin/Fetch Metadata를 전달하며 Gateway가 최종 검증한다. +- Browser Client는 CSRF token을 실제 mutation origin과 organization/account scope별 module memory에만 보관하고 active organization/auth lifecycle에서 폐기한다. Token bootstrap과 host-only cookie는 mutation과 같은 origin에서 수행한다. 동일한 rejected token의 동시 실패는 current-token 비교로 generation을 한 번만 폐기하고 하나의 refresh를 공유하며, 안전하게 replay 가능한 request만 고정 CSRF 오류 뒤 최대 한 번 갱신·재시도한다. Workflow SSE Next proxy는 strict header token만 outbound host-only cookie로 복제하고 original Origin/Fetch Metadata를 전달하며 Gateway가 최종 검증한다. - Middleware 외곽 순서는 webhook query redaction, Public Conversation CORS boundary, credentialed CORS, CSRF, Session 순이다. 따라서 Public iframe 경계를 유지하면서 configured Client가 CSRF `401/403`을 읽을 수 있다. +- Gateway는 외부 `X-Request-ID` 중 canonical RFC 4122 UUID만 보존하고 다른 값은 새 UUID로 대체한다. CSRF token은 ASCII 형식을 equality 이전에 검증하며, CSRF 거부의 동기 audit/metric persistence는 전용 bounded worker thread에서 수행해 event loop와 raw request ID를 보호한다. - Google OAuth 로그인을 지원한다 (`/api/v1/auth/google/login` → callback). - 인증 내부 실행의 safe same-origin `next` 복귀는 현재 이메일/비밀번호 로그인에만 적용하며, unsafe URL은 `/dashboard`로 닫는다. Google OAuth callback은 기존 `/dashboard` 복귀를 유지한다. - Bearer secret은 public run/webhook endpoint의 app secret 인증에만 사용한다. Public webhook은 [ADR-0041](decisions/ADR-0041-public-webhook-ingress-security-boundary.md)에 따라 query `token`을 거부하고 정확히 하나의 Bearer 또는 `X-Webhook-Secret` header만 허용한다. [ADR-0056](decisions/ADR-0056-app-auth-secret-issuance-and-rotation.md)에 따라 일반 App·Deployment 응답은 원문을 반환하지 않고, 명시적 one-time rotation API만 신규 원문을 반환한다. Gateway는 App current/previous 비가역 verifier를 권위로 사용하며 row lock·version CAS·최대 5분 grace·즉시 폐기를 집행한다. diff --git a/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md b/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md index 55fa93d27..e1c8c7c62 100644 --- a/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md +++ b/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md @@ -10,6 +10,8 @@ Gateway에는 공통 `get_current_user` dependency를 사용하는 route 외에 Safe GET도 응답의 `Set-Cookie` 부수효과를 가진다. Ambient cross-site image/navigation GET이 bootstrap token을 회전시키면 Client memory header와 host-only cookie가 달라져 non-replayable workflow mutation을 지속적으로 막을 수 있다. 또한 Client가 same-origin reverse proxy와 별도 공개 API origin을 함께 사용하면 token body와 host-only cookie가 서로 다른 host에 놓일 수 있다. +운영 검토에서 세 경계가 추가로 확인됐다. 동일한 만료 token을 사용한 동시 요청이 각각 cache generation을 올리면 먼저 시작한 정상 refresh가 무효화될 수 있다. Python의 문자열 `compare_digest`는 비ASCII 입력에서 exception을 내므로 형식 검증보다 먼저 호출할 수 없다. 또한 외부 request ID 원문을 CSRF audit에 복사하거나 동기 PostgreSQL audit commit을 async middleware에서 직접 실행하면 token/PII 보존과 event-loop 정체가 발생할 수 있다. + ## Options Considered ### Option A: CORS와 SameSite만 유지 @@ -29,6 +31,8 @@ Safe GET도 응답의 `Set-Cookie` 부수효과를 가진다. Ambient cross-site Bootstrap의 `Set-Cookie` 회전 방어에서는 exact Origin만 요구하는 방안, 기존 cookie가 있을 때 회전하지 않는 방안, custom header와 Origin/Fetch Metadata를 결합하는 방안을 비교했다. Exact Origin만 요구하면 same-origin safe GET에서 브라우저가 Origin을 생략하는 경우를 지원하지 못한다. 기존 cookie 재사용만으로는 첫 ambient 요청과 organization scope 전환을 막지 못한다. 따라서 custom header로 cross-origin 요청을 preflight에 묶고, exact allowlisted Origin 또는 same-origin Fetch Metadata를 추가 검증하는 방안을 선택했다. +후속 hardening에서는 모든 `403`이 무조건 generation을 올리는 방식과 rejected token이 current cache와 일치할 때만 compare-and-invalidate하는 방식을 비교해 후자를 선택했다. Request ID는 임의 printable 문자열 allowlist 대신 canonical RFC 4122 UUID만 보존하고 나머지는 서버 UUID로 대체한다. Audit persistence는 event loop 직접 호출, fire-and-forget queue, bounded thread 실행을 비교했다. 감사 유실을 허용하지 않으면서 event loop를 보호하기 위해 요청이 완료를 기다리는 전용 bounded thread 실행을 선택했다. + ## Decision Option C를 채택한다. @@ -57,10 +61,11 @@ Option C를 채택한다. ### 오류, 관측과 Client -1. CSRF 실패는 항상 `403 auth.csrf_validation_failed`와 고정 message를 반환한다. 내부에서는 bounded reason, policy, method와 검증된 request ID만 metric/audit에 기록하며 token, cookie, Origin, session, organization과 path parameter 원문을 기록하지 않는다. +1. CSRF 실패는 항상 `403 auth.csrf_validation_failed`와 고정 message를 반환한다. 내부에서는 bounded reason, policy, method와 검증된 request ID만 metric/audit에 기록하며 token, cookie, Origin, session, organization과 path parameter 원문을 기록하지 않는다. 외부 request ID는 canonical RFC 4122 UUID만 보존하고 나머지는 새 UUID로 대체한다. 2. Client token은 module memory에만 저장하고 localStorage, sessionStorage, URL과 log에 남기지 않는다. Origin마다 현재 organization/account scope token 하나만 유지하고, 실제 mutation origin과 scope가 같은 동시 bootstrap만 하나로 합치며 host-only cookie와 bootstrap endpoint를 mutation origin에 맞춘다. Lifecycle generation 이전에 시작한 bootstrap은 cache를 되살리지 못하고, 같은 origin의 새 bootstrap은 이전 요청이 정리된 뒤 cookie를 갱신한다. -3. 공통 Axios client와 보호된 직접 fetch는 unsafe method에 token을 자동 첨부한다. CSRF 실패 시 PUT/DELETE 또는 idempotency key가 있는 요청만 새 token으로 최대 한 번 재시도한다. 일반 POST/PATCH는 자동 replay하지 않는다. +3. 공통 Axios client와 보호된 직접 fetch는 unsafe method에 token을 자동 첨부한다. 동일한 rejected token의 동시 실패는 current origin/scope/token 비교로 generation을 한 번만 폐기하고 하나의 refresh를 공유한다. CSRF 실패 시 PUT/DELETE 또는 idempotency key가 있는 요청만 새 token으로 최대 한 번 재시도한다. 일반 POST/PATCH는 자동 replay하지 않는다. 4. Workflow SSE의 same-origin Next proxy는 API host-only CSRF cookie를 직접 받을 수 없다. 이 단일 proxy는 엄격한 token 문자·길이 검사를 거친 `X-CSRF-Token`을 outbound `csrf_token` cookie로 복제하고, 원래 Origin, Fetch Metadata, organization과 request context를 Gateway에 전달한다. Gateway는 동일한 HMAC/session/scope 검증을 수행한다. +5. Header/cookie token은 constant-time equality 전에 bounded ASCII 형식을 검증한다. CSRF denial의 동기 metric/audit callback은 전용 bounded thread limiter로 event loop 밖에서 실행하고, 완료를 기다리되 callback exception이 고정 응답을 바꾸지 않게 격리한다. ### Enforcement와 배포 @@ -76,12 +81,15 @@ Option C를 채택한다. - Custom bootstrap header는 ambient image/navigation GET을 차단하고 cross-origin script 요청을 CORS preflight에 묶는다. Same-origin Fetch Metadata fallback은 safe GET에서 Origin이 생략되는 브라우저 동작을 지원한다. - Host-only cookie는 origin 간 공유되지 않으므로 cache와 bootstrap도 실제 mutation origin별로 분리해야 header/cookie equality를 보장할 수 있다. - Non-idempotent 자동 replay를 금지하면 token expiry 복구가 중복 side effect로 바뀌지 않는다. +- Rejected token과 current cache를 비교하면 늦은 동일 실패가 이미 진행 중인 정상 refresh를 취소하지 않으면서 실제 새 token 거부는 다시 폐기할 수 있다. +- Canonical UUID replacement와 bounded thread 실행은 감사 상관관계를 유지하면서 header 원문 보존과 sync DB commit의 event-loop 점유를 막는다. ## Affected Files - `apps/gateway/application/csrf/*` - `apps/gateway/adapters/csrf/*` - `apps/gateway/composition/csrf.py` +- `apps/gateway/core/request_id.py` - `apps/gateway/middleware/csrf.py` - `apps/gateway/api/v1/endpoints/auth.py` - `apps/gateway/main.py` diff --git a/docs/features/auth/api_spec.md b/docs/features/auth/api_spec.md index 1f34125ef..1146d9283 100644 --- a/docs/features/auth/api_spec.md +++ b/docs/features/auth/api_spec.md @@ -41,14 +41,16 @@ Status: Draft } ``` -응답은 같은 token을 host-only HttpOnly `csrf_token` cookie로 설정한다. Anonymous bootstrap은 host-only HttpOnly `csrf_anon_seed`도 설정한다. `Cache-Control: no-store`, `Pragma: no-cache`가 필수다. Token은 `v1.expiry.nonce.mac` 형식이며 auth cookie, user와 organization 원문을 포함하지 않는다. Header/Origin/Fetch Metadata 검증 실패는 cookie를 설정하거나 회전시키지 않고 `403 auth.csrf_validation_failed`를 반환한다. +응답은 같은 token을 host-only HttpOnly `csrf_token` cookie로 설정한다. Anonymous bootstrap은 host-only HttpOnly `csrf_anon_seed`도 설정한다. `Cache-Control: no-store`, `Pragma: no-cache`가 필수다. Token은 ASCII `v1.expiry.nonce.mac` 형식이며 auth cookie, user와 organization 원문을 포함하지 않는다. 비ASCII token은 equality 비교 전에 `token_invalid`로 닫고, equality를 통과한 비정규 token은 canonical parsing에서 `token_invalid`로 닫는다. Header/Origin/Fetch Metadata 검증 실패는 cookie를 설정하거나 회전시키지 않고 `403 auth.csrf_validation_failed`를 반환한다. + +`X-Request-ID`는 canonical RFC 4122 UUID만 보존한다. 다른 값은 서버가 생성한 UUID로 대체하며 입력 원문을 응답이나 CSRF 감사 metadata에 복사하지 않는다. + ### `POST /auth/signup` 요청 본문: | 필드 | 타입 | 필수 | 비고 | | --- | --- | --- | --- | -| GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. | Safe bootstrap; resource permission 없음 | | `email` | `EmailStr` | 예 | Pydantic 이메일 검증을 통과해야 한다. | | `password` | `string` | 예 | salt가 포함된 SHA-256 비밀번호 해시로 저장된다. | | `name` | `string` | 예 | 사용자 표시 이름이다. | @@ -61,7 +63,6 @@ Status: Draft | 필드 | 타입 | 필수 | 비고 | | --- | --- | --- | --- | -| GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. | Safe bootstrap; resource permission 없음 | | `email` | `EmailStr` | 예 | Pydantic 이메일 검증을 통과해야 한다. | | `password` | `string` | 예 | 저장된 비밀번호 해시와 비교된다. | @@ -125,7 +126,6 @@ Query: | 필드 | 타입 | 필수 | 제약 | | --- | --- | --- | --- | -| GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. | Safe bootstrap; resource permission 없음 | | `next` | `string` | 아니요 | 최대 2,048자. Gateway가 상대 same-origin 경로로 다시 검증하며 안전하지 않으면 `/dashboard`를 저장한다. | 성공 응답: Google OAuth 인증 화면으로 이동하는 리디렉션 응답. @@ -224,7 +224,6 @@ Signup, login, OAuth 성공과 logout은 두 CSRF cookie를 삭제한다. Invali | 환경 | `path` | `secure` | `samesite` | `domain` | | --- | --- | --- | --- | --- | -| GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. | Safe bootstrap; resource permission 없음 | | Localhost 또는 `127.0.0.1` 호스트 | `/` | `false` | `lax` | 설정하지 않음 | | Non-local 호스트 | `/` | `true` | `none` | `COOKIE_DOMAIN`, 또는 마지막 두 호스트 라벨 앞에 `.`를 붙인 값 | @@ -264,7 +263,6 @@ HTTP 예외는 다음 형식으로 반환된다. | --- | --- | --- | --- | | 401 | `GET /auth/csrf` | `auth.invalid` envelope과 auth/CSRF cookie 삭제 | 존재하는 `auth_token`이 유효하지 않거나 비활성 계정에 결박됐다. Anonymous fallback은 같은 응답에서 수행하지 않는다. | | 403 | `GET /auth/csrf`와 모든 cookie/pre-auth unsafe route | `auth.csrf_validation_failed` 고정 envelope | Bootstrap proof, Origin, Fetch Metadata, content type, token equality/signature/binding/scope/expiry 중 하나가 실패한다. Bootstrap 실패는 cookie를 설정하지 않는다. | -| GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. | Safe bootstrap; resource permission 없음 | | 400 | `POST /auth/signup` | `이미 등록된 이메일입니다` | 이메일이 이미 존재한다. | | 400 | `GET /auth/google/callback` | `OAuth authentication failed` | token 교환, token/user info 타입, user info 조회 또는 email 검증에 실패한다. Provider exception 원문은 반환하지 않는다. | | 503 | `GET /auth/google/login` | `OAuth login is unavailable` | provider authorization 시작에 실패한다. Exception 원문은 반환하지 않는다. | diff --git a/docs/features/auth/component_spec.md b/docs/features/auth/component_spec.md index f024e757e..eb4383ae5 100644 --- a/docs/features/auth/component_spec.md +++ b/docs/features/auth/component_spec.md @@ -231,7 +231,10 @@ Status: Draft ### Gateway CSRF Guard - Guard는 endpoint보다 먼저 Origin, Fetch Metadata, content type, double-submit equality와 HMAC/session/scope를 검증한다. +- Header/cookie token은 constant-time equality 전에 bounded ASCII 형식인지 확인해 비ASCII 입력을 exception 없는 `token_invalid`로 닫는다. - 실패 body는 고정 `auth.csrf_validation_failed`만 노출한다. Bounded reason은 metric/audit adapter 내부에서만 사용한다. +- Gateway ingress와 CSRF guard는 같은 request ID helper를 사용한다. Canonical RFC 4122 UUID만 보존하고 그 밖의 header 원문은 새 UUID로 대체한다. +- 동기 audit/metric callback은 CSRF middleware의 전용 capacity limiter를 사용하는 worker thread에서 실행한다. 요청 coroutine은 결과를 기다리되 DB commit으로 event loop를 막지 않으며 callback exception은 고정 응답 뒤로 격리한다. - 인증 cookie가 없는 protected mutation은 token을 identity로 사용하지 않고 `401 auth.required`로 종료한다. - CORS는 guard 바깥에서 허용 origin이 오류 응답을 읽게 하고, Public Conversation CORS와 webhook query redaction의 더 바깥 경계를 유지한다. @@ -239,7 +242,7 @@ Status: Draft - `csrfToken.ts`는 실제 mutation origin의 `/api/v1/auth/csrf`에 `X-CSRF-Bootstrap: 1`을 보내고 응답을 runtime 검증한 뒤 token과 expiry를 module memory에만 저장한다. - Origin마다 현재 organization/account scope의 token 하나만 유지한다. 같은 mutation origin과 scope의 동시 요청만 하나의 bootstrap Promise와 cached token을 공유하며, scope 전환은 같은 origin의 이전 token을 대체한다. 다른 origin, reload와 tab은 token을 공유하지 않는다. -- Axios request interceptor는 active organization header가 결정된 뒤 unsafe request에 `X-CSRF-Token`을 추가한다. Response interceptor는 고정 CSRF error에서 cache를 지우며 PUT/DELETE 또는 idempotency key 요청만 최대 한 번 재시도한다. +- Axios request interceptor는 active organization header가 결정된 뒤 unsafe request에 `X-CSRF-Token`을 추가한다. Response interceptor는 고정 CSRF error가 현재 cache의 동일 origin/scope/token을 거부한 경우에만 generation을 올린다. 동일 token을 사용한 동시 `403`은 한 refresh bootstrap을 공유하며 PUT/DELETE 또는 idempotency key 요청만 최대 한 번 재시도한다. - `csrfFetch`는 Settings, Wizard, RAG stream과 Workflow stream처럼 Axios를 통하지 않는 protected mutation에 같은 계약을 제공한다. Public Chatbot/Public run, app-secret 실행과 presigned object upload에는 적용하지 않는다. - Signup/login/logout 성공, OAuth navigation과 `nodease-active-organization-changed` event는 cache generation을 올리고 cached token을 폐기한다. 이전 generation의 진행 중 bootstrap은 cache를 되살리지 못하며, 같은 origin의 새 bootstrap은 이전 요청 정리 뒤 cookie를 마지막으로 갱신한다. Invalid 또는 inactive-session HttpOnly auth cookie bootstrap `401`은 cookie 삭제 반영을 위해 최대 한 번만 재시도한다. diff --git a/docs/features/auth/requirements.md b/docs/features/auth/requirements.md index b010202f1..b4e4f9863 100644 --- a/docs/features/auth/requirements.md +++ b/docs/features/auth/requirements.md @@ -112,6 +112,11 @@ Auth는 보호된 Gateway API가 `auth_token` 쿠키에서 현재 사용자를 - AUTH-REQ-086: `CORS_ORIGINS`는 CSRF exact-Origin allowlist에 재사용하되 CORS 허용을 CSRF 성공으로 간주하지 않아야 한다. CORS middleware는 허용된 Client가 CSRF `401/403`을 읽을 수 있도록 CSRF middleware 바깥에 있어야 한다. - AUTH-REQ-087: Workflow stream proxy는 original Origin, Fetch Metadata, CSRF token과 organization context를 전달하고, 엄격히 검증한 header token만 outbound host-only CSRF cookie로 복제해야 한다. 다른 proxy/public adapter는 이 예외를 일반화하지 않아야 한다. - AUTH-REQ-088: CSRF enforcement는 development와 production에서 기본 활성화되어야 한다. Disabled mode는 `NODE_ENV=test`에서만 허용하고 production disabled/unknown mode는 startup을 실패시켜야 한다. +- AUTH-REQ-089: Header/cookie CSRF token은 constant-time equality 이전에 길이와 ASCII 형식을 검증해야 한다. 비ASCII 또는 비정규 입력은 exception이나 `500` 없이 고정 `403`의 내부 `token_invalid` reason으로 닫아야 한다. +- AUTH-REQ-090: 같은 origin/scope의 동일한 rejected token을 사용한 동시 안전 요청은 token cache generation을 한 번만 폐기하고 하나의 refresh bootstrap을 공유해야 한다. 늦게 도착한 동일 token의 `403`이 이미 시작한 refresh를 무효화하거나 정상 요청 하나를 실패시켜서는 안 된다. +- AUTH-REQ-091: 외부 `X-Request-ID`는 canonical RFC 4122 UUID만 보존하고 다른 값은 서버 생성 UUID로 대체해야 한다. Token, PII 또는 임의 header 원문을 응답, audit와 log의 request ID로 반사하지 않아야 한다. +- AUTH-REQ-092: CSRF 거부 audit/metric callback의 동기 DB 또는 I/O 작업은 Gateway event loop 밖의 전용 bounded thread 경계에서 수행해야 한다. Callback 실패는 고정 `401/403` 계약을 바꾸지 않아야 한다. + ## Policies And Edge Cases - CORS, SameSite와 CSRF token은 서로 대체하지 않는 독립 방어 계층이다. diff --git a/docs/features/auth/test_cases.md b/docs/features/auth/test_cases.md index 5da693543..1a4aaaf19 100644 --- a/docs/features/auth/test_cases.md +++ b/docs/features/auth/test_cases.md @@ -147,6 +147,12 @@ Status: Draft | AUTH-TC-CS024 | Middleware 순서는 허용된 Client가 안전한 CSRF 오류를 읽고 Public/webhook 외곽 경계를 유지해야 한다. | `app.user_middleware` 순서를 검사한다. | Webhook redaction → Public CORS → credentialed CORS → CSRF → Session 순서. | | AUTH-TC-CS025 | Ambient cross-site GET은 bootstrap cookie를 회전시키지 않아야 한다. | Custom bootstrap header 없이 cross-site image/navigation 요청을 보내거나 unlisted same-site Origin에서 header를 보낸다. | Fixed 403, Set-Cookie 없음, token service/DB 미진입. | | AUTH-TC-CS026 | Auth/organization lifecycle 전환 전의 in-flight bootstrap은 stale token을 되살리지 않아야 한다. | Bootstrap A가 pending인 동안 cache를 invalidate하고 같은 origin/scope bootstrap B를 시작한 뒤 A를 늦게 완료한다. | A caller는 mutation 전 실패, B는 A 정리 뒤 발급되어 최종 cookie/cache를 소유하고 이후 요청이 B를 재사용. | +| AUTH-TC-CS027 | 동일한 만료 token을 사용한 동시 안전 요청은 refresh를 서로 무효화하지 않아야 한다. | 두 PUT/DELETE가 같은 token으로 403을 받고 첫 refresh가 pending인 동안 두 번째 403을 처리한다. | Generation 폐기와 bootstrap 각 1회, 두 요청 모두 새 token으로 한 번만 재시도해 성공. | +| AUTH-TC-CS028 | 비ASCII double-submit token은 exception 없이 거부해야 한다. | Header/cookie에 같은 비ASCII 문자열을 보낸다. | `compare_digest` 전에 `token_invalid`, fixed 403, endpoint effect 0. | +| AUTH-TC-CS029 | CSRF 감사 request ID는 token/PII header를 반사하지 않아야 한다. | 유효한 CSRF token 또는 임의 문자열을 `X-Request-ID`에도 넣고 거부를 유도한다. | 응답과 audit에는 새 canonical UUID만 있고 입력 원문은 없음. | +| AUTH-TC-CS030 | 동기 CSRF 거부 감사 persistence는 event loop를 점유하지 않아야 한다. | Sync callback에서 DB/I/O 대기를 모사하고 callback thread를 기록한다. | Callback은 bounded worker thread에서 실행되고 고정 403 계약 유지. | +| AUTH-TC-CS031 | Auth API endpoint 행은 endpoint inventory에만 있어야 한다. | `/auth/csrf` endpoint 행을 field/cookie/error table에 중복한다. | 문서 구조 테스트 실패; endpoint 행 정확히 1개. | + ## Component And Hook Tests | ID | 검증 조건 | 최소 실패 조건 | 기대 결과 | From 32dc308417a1dc1aac863245113e8f0770acf980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Wed, 29 Jul 2026 20:34:13 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20CSRF=20=EC=A1=B0=EC=A7=81=20scope=20?= =?UTF-8?q?=EB=B0=8F=20bootstrap=20=EA=B0=90=EC=82=AC=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/modals/CodeWizardModal.tsx | 10 +- .../components/modals/PromptWizardModal.tsx | 10 +- .../components/modals/TemplateWizardModal.tsx | 10 +- .../modals/WizardModalOrganization.test.tsx | 9 ++ apps/gateway/api/v1/endpoints/auth.py | 109 +++++++++++------- apps/gateway/application/csrf/telemetry.py | 25 ++++ apps/gateway/application/csrf/token.py | 23 +++- apps/gateway/middleware/csrf.py | 22 +--- apps/gateway/tests/api/test_auth_csrf.py | 67 +++++++++++ .../tests/application/csrf/test_telemetry.py | 35 ++++++ .../application/csrf/test_token_service.py | 19 +++ docs/architecture.md | 4 +- ...-cookie-authenticated-api-csrf-boundary.md | 12 +- docs/features/auth/api_spec.md | 4 +- docs/features/auth/component_spec.md | 6 +- docs/features/auth/requirements.md | 3 + docs/features/auth/test_cases.md | 3 + 17 files changed, 287 insertions(+), 84 deletions(-) create mode 100644 apps/gateway/application/csrf/telemetry.py create mode 100644 apps/gateway/tests/application/csrf/test_telemetry.py diff --git a/apps/client/app/features/workflow/components/modals/CodeWizardModal.tsx b/apps/client/app/features/workflow/components/modals/CodeWizardModal.tsx index c0f8e77f9..39060c940 100644 --- a/apps/client/app/features/workflow/components/modals/CodeWizardModal.tsx +++ b/apps/client/app/features/workflow/components/modals/CodeWizardModal.tsx @@ -13,7 +13,10 @@ import { } from 'lucide-react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { getStoredActiveOrganizationId } from '@/lib/activeOrganization'; +import { + activeOrganizationHeaders, + getStoredActiveOrganizationId, +} from '@/lib/activeOrganization'; import { csrfFetch } from '@/lib/csrfToken'; // 서버 에러 응답 타입 정의 (개선점 1: 에러 스키마 명확화) @@ -122,7 +125,10 @@ export function CodeWizardModal({ const resolvedOrganizationId = getWizardOrganizationId(); const res = await csrfFetch('/api/v1/code-wizard/generate', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...activeOrganizationHeaders(resolvedOrganizationId), + }, credentials: 'include', body: JSON.stringify({ description: description, diff --git a/apps/client/app/features/workflow/components/modals/PromptWizardModal.tsx b/apps/client/app/features/workflow/components/modals/PromptWizardModal.tsx index e9e55853c..8bde9d70d 100644 --- a/apps/client/app/features/workflow/components/modals/PromptWizardModal.tsx +++ b/apps/client/app/features/workflow/components/modals/PromptWizardModal.tsx @@ -10,7 +10,10 @@ import { ArrowRight, Info, } from 'lucide-react'; -import { getStoredActiveOrganizationId } from '@/lib/activeOrganization'; +import { + activeOrganizationHeaders, + getStoredActiveOrganizationId, +} from '@/lib/activeOrganization'; import { csrfFetch } from '@/lib/csrfToken'; interface PromptWizardModalProps { @@ -109,7 +112,10 @@ export function PromptWizardModal({ const resolvedOrganizationId = getWizardOrganizationId(); const res = await csrfFetch('/api/v1/prompt-wizard/improve', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...activeOrganizationHeaders(resolvedOrganizationId), + }, credentials: 'include', body: JSON.stringify({ prompt_type: promptType, diff --git a/apps/client/app/features/workflow/components/modals/TemplateWizardModal.tsx b/apps/client/app/features/workflow/components/modals/TemplateWizardModal.tsx index 0549759f1..a46f131a2 100644 --- a/apps/client/app/features/workflow/components/modals/TemplateWizardModal.tsx +++ b/apps/client/app/features/workflow/components/modals/TemplateWizardModal.tsx @@ -12,7 +12,10 @@ import { ChevronDown, Code, } from 'lucide-react'; -import { getStoredActiveOrganizationId } from '@/lib/activeOrganization'; +import { + activeOrganizationHeaders, + getStoredActiveOrganizationId, +} from '@/lib/activeOrganization'; import { csrfFetch } from '@/lib/csrfToken'; // 템플릿 타입 정의 @@ -143,7 +146,10 @@ export function TemplateWizardModal({ const resolvedOrganizationId = getWizardOrganizationId(); const res = await csrfFetch('/api/v1/template-wizard/improve', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...activeOrganizationHeaders(resolvedOrganizationId), + }, credentials: 'include', body: JSON.stringify({ template_type: templateType, diff --git a/apps/client/app/features/workflow/components/modals/WizardModalOrganization.test.tsx b/apps/client/app/features/workflow/components/modals/WizardModalOrganization.test.tsx index a82171649..04fbf1f83 100644 --- a/apps/client/app/features/workflow/components/modals/WizardModalOrganization.test.tsx +++ b/apps/client/app/features/workflow/components/modals/WizardModalOrganization.test.tsx @@ -35,6 +35,12 @@ const lastPostBody = () => { return JSON.parse(String(postCall?.init?.body)); }; +const lastPostOrganizationId = () => { + const postCall = fetchCalls().find((call) => call.init?.method === 'POST'); + expect(postCall).toBeTruthy(); + return new Headers(postCall?.init?.headers).get('X-Organization-Id'); +}; + const createFetchMock = () => vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { if (init?.method === 'POST') { @@ -99,6 +105,7 @@ describe('Wizard modals organization scope', () => { expect(lastPostBody()).toMatchObject({ organization_id: activeOrganizationId, }); + expect(lastPostOrganizationId()).toBe(activeOrganizationId); }); it('falls back to stored active organization when organizationId prop is omitted', async () => { @@ -170,6 +177,7 @@ describe('Wizard modals organization scope', () => { expect(lastPostBody()).toMatchObject({ organization_id: activeOrganizationId, }); + expect(lastPostOrganizationId()).toBe(activeOrganizationId); }); it('passes organizationId prop to template wizard check and improve requests', async () => { @@ -202,6 +210,7 @@ describe('Wizard modals organization scope', () => { expect(lastPostBody()).toMatchObject({ organization_id: activeOrganizationId, }); + expect(lastPostOrganizationId()).toBe(activeOrganizationId); }); it('rechecks credentials when prompt wizard organizationId changes', async () => { diff --git a/apps/gateway/api/v1/endpoints/auth.py b/apps/gateway/api/v1/endpoints/auth.py index a50b09d1e..1b1808327 100644 --- a/apps/gateway/api/v1/endpoints/auth.py +++ b/apps/gateway/api/v1/endpoints/auth.py @@ -3,8 +3,10 @@ import re import secrets from collections.abc import Mapping +from functools import partial from urllib.parse import urlsplit +from anyio import to_thread from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session @@ -20,11 +22,13 @@ from apps.gateway.application.csrf.bootstrap import ( validate_csrf_bootstrap_request, ) +from apps.gateway.application.csrf.telemetry import run_bounded_csrf_telemetry from apps.gateway.application.csrf.token import ( CSRF_ANON_COOKIE_NAME, CSRF_COOKIE_NAME, CSRF_ORGANIZATION_HEADER_NAME, CsrfBindingKind, + CsrfValidationReason, ) from apps.gateway.auth.oauth import oauth from apps.gateway.composition.authentication import ( @@ -203,8 +207,34 @@ def _clear_csrf_cookie_family(request: Request, response: Response) -> None: ) +async def _csrf_bootstrap_denied( + request: Request, + reason: CsrfValidationReason, +) -> Response: + try: + await run_bounded_csrf_telemetry( + record_csrf_bootstrap_denial, + reason, + request_id=getattr(request.state, "request_id", None), + ) + except Exception as exc: + logger.error( + "CSRF bootstrap denial telemetry failed: error_type=%s", + type(exc).__name__, + ) + denied_response = error_response( + request, + 403, + "auth.csrf_validation_failed", + "CSRF validation failed.", + ) + denied_response.headers["Cache-Control"] = "no-store" + denied_response.headers["Pragma"] = "no-cache" + return denied_response + + @router.get("/csrf", response_model=CsrfTokenResponse) -def bootstrap_csrf_token( +async def bootstrap_csrf_token( request: Request, response: Response, db: Session = Depends(get_db), @@ -218,45 +248,46 @@ def bootstrap_csrf_token( ), ) if bootstrap_denial is not None: - try: - record_csrf_bootstrap_denial( - bootstrap_denial, - request_id=getattr(request.state, "request_id", None), - ) - except Exception as exc: - logger.error( - "CSRF bootstrap denial telemetry failed: error_type=%s", - type(exc).__name__, - ) - denied_response = error_response( - request, - 403, - "auth.csrf_validation_failed", - "CSRF validation failed.", - ) - denied_response.headers["Cache-Control"] = "no-store" - denied_response.headers["Pragma"] = "no-cache" - return denied_response + return await _csrf_bootstrap_denied(request, bootstrap_denial) + + token_service = csrf_token_service() + organization_scope = request.headers.get(CSRF_ORGANIZATION_HEADER_NAME) + scope_denial = token_service.validate_organization_scope(organization_scope) + if scope_denial is not None: + return await _csrf_bootstrap_denied(request, scope_denial) auth_cookie = request.cookies.get("auth_token") anonymous_seed: str | None = None if auth_cookie: # Invalid authentication must never downgrade to an anonymous binding. try: - AuthService.get_user_from_token(db, auth_cookie) + await to_thread.run_sync( + AuthService.get_user_from_token, + db, + auth_cookie, + ) except HTTPException as exc: if exc.status_code not in {401, 403}: raise - record_audit( - action=AuditAction.AUTH_PERMISSION_DENIED, - category="action", - actor_type="system", - status="failure", - metadata={ - "reason": "auth.csrf_bootstrap_invalid_session", - **_request_meta(request), - }, - ) + try: + await run_bounded_csrf_telemetry( + partial( + record_audit, + action=AuditAction.AUTH_PERMISSION_DENIED, + category="action", + actor_type="system", + status="failure", + metadata={ + "reason": "auth.csrf_bootstrap_invalid_session", + **_request_meta(request), + }, + ) + ) + except Exception as audit_exc: + logger.error( + "CSRF invalid-session telemetry failed: error_type=%s", + type(audit_exc).__name__, + ) invalid_response = error_response( request, 401, @@ -285,17 +316,11 @@ def bootstrap_csrf_token( binding_kind = CsrfBindingKind.PRE_AUTH binding_secret = anonymous_seed - try: - issued = csrf_token_service().issue( - binding_kind=binding_kind, - binding_secret=binding_secret, - organization_scope=request.headers.get(CSRF_ORGANIZATION_HEADER_NAME), - ) - except ValueError: - raise HTTPException( - status_code=400, - detail="Invalid CSRF request context", - ) from None + issued = token_service.issue( + binding_kind=binding_kind, + binding_secret=binding_secret, + organization_scope=organization_scope, + ) _set_csrf_cookie( response, diff --git a/apps/gateway/application/csrf/telemetry.py b/apps/gateway/application/csrf/telemetry.py new file mode 100644 index 000000000..6c179a04d --- /dev/null +++ b/apps/gateway/application/csrf/telemetry.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import inspect +from collections.abc import Callable +from functools import partial +from typing import Any + +from anyio import CapacityLimiter, to_thread + + +CSRF_TELEMETRY_CONCURRENCY_LIMIT = 4 +_csrf_telemetry_limiter = CapacityLimiter(CSRF_TELEMETRY_CONCURRENCY_LIMIT) + + +async def run_bounded_csrf_telemetry( + callback: Callable[..., Any], + *args: Any, + **kwargs: Any, +) -> None: + callback_result = await to_thread.run_sync( + partial(callback, *args, **kwargs), + limiter=_csrf_telemetry_limiter, + ) + if inspect.isawaitable(callback_result): + await callback_result diff --git a/apps/gateway/application/csrf/token.py b/apps/gateway/application/csrf/token.py index c7b2bcd9c..9ef487506 100644 --- a/apps/gateway/application/csrf/token.py +++ b/apps/gateway/application/csrf/token.py @@ -33,6 +33,7 @@ class CsrfValidationReason(str, Enum): TOKEN_MISMATCH = "token_mismatch" TOKEN_INVALID = "token_invalid" TOKEN_EXPIRED = "token_expired" + ORGANIZATION_SCOPE_INVALID = "organization_scope_invalid" ORIGIN_INVALID = "origin_invalid" FETCH_METADATA_INVALID = "fetch_metadata_invalid" CONTENT_TYPE_INVALID = "content_type_invalid" @@ -162,6 +163,9 @@ def validate( return CsrfValidationReason.TOKEN_MISMATCH if not binding_secret: return CsrfValidationReason.TOKEN_INVALID + scope_reason = self.validate_organization_scope(organization_scope) + if scope_reason is not None: + return scope_reason try: version, raw_expiry, raw_nonce, raw_mac = header_token.split(".") @@ -231,17 +235,28 @@ def _mac( ) return hmac.new(self._signing_key, message, hashlib.sha256).digest() + @staticmethod + def validate_organization_scope( + organization_scope: str | None, + ) -> CsrfValidationReason | None: + try: + CsrfTokenService._normalize_scope(organization_scope) + except ValueError: + return CsrfValidationReason.ORGANIZATION_SCOPE_INVALID + return None + @staticmethod def _normalize_scope(organization_scope: str | None) -> bytes: if organization_scope is None: return _ACCOUNT_SCOPE + if len(organization_scope) > 128 or any( + ord(character) < 32 or ord(character) == 127 + for character in organization_scope + ): + raise ValueError("Invalid CSRF organization scope") normalized = organization_scope.strip() if not normalized: return _ACCOUNT_SCOPE - if len(normalized) > 128 or any( - ord(character) < 32 for character in normalized - ): - raise ValueError("Invalid CSRF organization scope") return normalized.encode("utf-8") @staticmethod diff --git a/apps/gateway/middleware/csrf.py b/apps/gateway/middleware/csrf.py index 16f63d19c..b71fa71c3 100644 --- a/apps/gateway/middleware/csrf.py +++ b/apps/gateway/middleware/csrf.py @@ -1,12 +1,10 @@ from __future__ import annotations -import inspect import logging import re from collections.abc import Callable, Sequence from typing import Any -from anyio import CapacityLimiter, to_thread from fastapi import Request from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware @@ -27,6 +25,7 @@ CsrfTokenService, CsrfValidationReason, ) +from apps.gateway.application.csrf.telemetry import run_bounded_csrf_telemetry from apps.gateway.core.request_id import safe_request_id logger = logging.getLogger(__name__) @@ -41,7 +40,6 @@ _JSON_MEDIA_TYPE = "application/json" _MULTIPART_MEDIA_TYPE = "multipart/form-data" _MULTIPART_BOUNDARY_PATTERN = re.compile(r"^[0-9A-Za-z._-]{1,70}$") -_TELEMETRY_CONCURRENCY_LIMIT = 4 class CsrfProtectionMiddleware(BaseHTTPMiddleware): @@ -63,7 +61,6 @@ def __init__( self._enforcement_enabled = enforcement_enabled self._on_denied = on_denied self._on_auth_required = on_auth_required - self._telemetry_limiter = CapacityLimiter(_TELEMETRY_CONCURRENCY_LIMIT) async def dispatch(self, request: Request, call_next) -> Response: policy = self._registry.match(request.method, request.url.path) @@ -181,19 +178,6 @@ def _parse_parameters( parameters[name] = value.lower() if name == "charset" else value return parameters - async def _run_telemetry_callback( - self, - callback: Callable[..., Any], - *args: Any, - ) -> None: - callback_result = await to_thread.run_sync( - callback, - *args, - limiter=self._telemetry_limiter, - ) - if inspect.isawaitable(callback_result): - await callback_result - async def _authentication_required( self, request: Request, @@ -206,7 +190,7 @@ async def _authentication_required( request.state.request_id = request_id if self._on_auth_required is not None: try: - await self._run_telemetry_callback( + await run_bounded_csrf_telemetry( self._on_auth_required, policy, request.method.upper(), @@ -244,7 +228,7 @@ async def _denied( ) request.state.request_id = request_id try: - await self._run_telemetry_callback( + await run_bounded_csrf_telemetry( self._on_denied, reason, policy, diff --git a/apps/gateway/tests/api/test_auth_csrf.py b/apps/gateway/tests/api/test_auth_csrf.py index 2a9286195..1c0e524a9 100644 --- a/apps/gateway/tests/api/test_auth_csrf.py +++ b/apps/gateway/tests/api/test_auth_csrf.py @@ -1,3 +1,5 @@ +import threading + from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient @@ -6,6 +8,7 @@ CSRF_ANON_COOKIE_NAME, CSRF_COOKIE_NAME, CsrfTokenService, + CsrfValidationReason, ) from apps.gateway.services.auth_service import AuthService from apps.shared.db.session import get_db @@ -167,6 +170,70 @@ def test_untrusted_bootstrap_requests_cannot_rotate_csrf_cookies(monkeypatch): assert response.headers.get_list("set-cookie") == [] +def test_invalid_organization_scope_uses_fixed_csrf_denial(monkeypatch): + recorded_reasons: list[str] = [] + + def record_denial(reason, *, request_id): + recorded_reasons.append(reason.value) + + monkeypatch.setattr( + auth_endpoint, + "csrf_token_service", + lambda: CsrfTokenService.from_root_secret("csrf-endpoint-test-secret"), + ) + monkeypatch.setattr( + auth_endpoint, + "record_csrf_bootstrap_denial", + record_denial, + ) + + with _client() as client: + response = client.get( + "/auth/csrf", + headers={ + **_bootstrap_headers(), + "X-Organization-Id": "o" * 129, + }, + ) + + assert response.status_code == 403 + assert response.json()["error"]["code"] == "auth.csrf_validation_failed" + assert response.json()["error"]["message"] == "CSRF validation failed." + assert response.headers.get_list("set-cookie") == [] + assert recorded_reasons == ["organization_scope_invalid"] + + +def test_bootstrap_denial_audit_runs_outside_request_execution_thread(monkeypatch): + request_threads: list[int] = [] + audit_threads: list[int] = [] + + def reject_bootstrap(_headers, *, allowed_origins): + request_threads.append(threading.get_ident()) + return CsrfValidationReason.FETCH_METADATA_INVALID + + def record_denial(_reason, *, request_id): + audit_threads.append(threading.get_ident()) + + monkeypatch.setattr( + auth_endpoint, + "validate_csrf_bootstrap_request", + reject_bootstrap, + ) + monkeypatch.setattr( + auth_endpoint, + "record_csrf_bootstrap_denial", + record_denial, + ) + + with _client() as client: + response = client.get("/auth/csrf") + + assert response.status_code == 403 + assert request_threads + assert audit_threads + assert request_threads[0] != audit_threads[0] + + def test_logout_clears_auth_and_csrf_cookie_families(): with _client() as client: response = client.post("/auth/logout") diff --git a/apps/gateway/tests/application/csrf/test_telemetry.py b/apps/gateway/tests/application/csrf/test_telemetry.py new file mode 100644 index 000000000..b9ab48842 --- /dev/null +++ b/apps/gateway/tests/application/csrf/test_telemetry.py @@ -0,0 +1,35 @@ +import asyncio +import importlib +import threading +import time + +import pytest + + +@pytest.mark.asyncio +async def test_shared_csrf_telemetry_runner_bounds_sync_callback_concurrency(): + try: + telemetry = importlib.import_module( + "apps.gateway.application.csrf.telemetry" + ) + except ModuleNotFoundError: + pytest.fail("shared CSRF telemetry runner is not implemented") + + active = 0 + maximum_active = 0 + lock = threading.Lock() + + def callback(): + nonlocal active, maximum_active + with lock: + active += 1 + maximum_active = max(maximum_active, active) + time.sleep(0.03) + with lock: + active -= 1 + + await asyncio.gather( + *(telemetry.run_bounded_csrf_telemetry(callback) for _ in range(12)) + ) + + assert 1 <= maximum_active <= 4 diff --git a/apps/gateway/tests/application/csrf/test_token_service.py b/apps/gateway/tests/application/csrf/test_token_service.py index dd7f4fe9f..1570985e2 100644 --- a/apps/gateway/tests/application/csrf/test_token_service.py +++ b/apps/gateway/tests/application/csrf/test_token_service.py @@ -101,6 +101,25 @@ def test_non_ascii_double_submit_tokens_are_rejected_without_exception( assert reason is CsrfValidationReason.TOKEN_INVALID +@pytest.mark.parametrize( + "organization_scope", + [ + "o" * 129, + "organization-a\x1f", + ], +) +def test_invalid_organization_scope_is_rejected_before_token_issue( + service: CsrfTokenService, + organization_scope: str, +): + with pytest.raises(ValueError, match="Invalid CSRF organization scope"): + service.issue( + binding_kind=CsrfBindingKind.PRE_AUTH, + binding_secret="anonymous-seed", + organization_scope=organization_scope, + ) + + @pytest.mark.parametrize( ("binding_kind", "binding_secret", "organization_scope"), [ diff --git a/docs/architecture.md b/docs/architecture.md index 4c950cf3a..c227c013d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -674,9 +674,9 @@ Canonical content revision/input hash - 사용자 세션은 `auth_token` HttpOnly cookie 기준이다. user session용 Bearer token dependency는 없다. - Cookie-authenticated/pre-auth unsafe Gateway API는 [ADR-0073](decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md)의 signed double-submit 경계를 사용한다. Browser bootstrap header와 same-origin Fetch Metadata 또는 exact configured Origin을 증명한 `GET /api/v1/auth/csrf`만 10분 token을 body와 host-only HttpOnly cookie로 발급하며 token MAC은 auth cookie 또는 anonymous seed와 active organization/account scope에 결박된다. Ambient cross-site GET은 cookie를 회전시키지 않는다. - Gateway는 모든 unsafe route를 cookie, pre-auth, public anonymous 또는 server credential audience로 startup 시 분류한다. Cookie/pre-auth route는 body parsing과 DB·queue·storage·provider 이전에 exact configured Origin, Fetch Metadata, JSON/명시 multipart/bodyless와 token equality/signature/session/scope를 검증한다. 명시적 zero-length `BODY_OPTIONAL` 요청은 Axios media type과 무관하게 허용하고 non-empty form body는 거부한다. Public/server route는 login cookie를 principal로 해석하지 않는다. -- Browser Client는 CSRF token을 실제 mutation origin과 organization/account scope별 module memory에만 보관하고 active organization/auth lifecycle에서 폐기한다. Token bootstrap과 host-only cookie는 mutation과 같은 origin에서 수행한다. 동일한 rejected token의 동시 실패는 current-token 비교로 generation을 한 번만 폐기하고 하나의 refresh를 공유하며, 안전하게 replay 가능한 request만 고정 CSRF 오류 뒤 최대 한 번 갱신·재시도한다. Workflow SSE Next proxy는 strict header token만 outbound host-only cookie로 복제하고 original Origin/Fetch Metadata를 전달하며 Gateway가 최종 검증한다. +- Browser Client는 CSRF token을 실제 mutation origin과 organization/account scope별 module memory에만 보관하고 active organization/auth lifecycle에서 폐기한다. Token bootstrap과 host-only cookie는 mutation과 같은 origin에서 수행한다. Workflow organization을 알고 있는 Wizard/direct-fetch consumer는 그 authoritative ID를 body와 organization header에 함께 명시한다. 동일한 rejected token의 동시 실패는 current-token 비교로 generation을 한 번만 폐기하고 하나의 refresh를 공유하며, 안전하게 replay 가능한 request만 고정 CSRF 오류 뒤 최대 한 번 갱신·재시도한다. Workflow SSE Next proxy는 strict header token만 outbound host-only cookie로 복제하고 original Origin/Fetch Metadata를 전달하며 Gateway가 최종 검증한다. - Middleware 외곽 순서는 webhook query redaction, Public Conversation CORS boundary, credentialed CORS, CSRF, Session 순이다. 따라서 Public iframe 경계를 유지하면서 configured Client가 CSRF `401/403`을 읽을 수 있다. -- Gateway는 외부 `X-Request-ID` 중 canonical RFC 4122 UUID만 보존하고 다른 값은 새 UUID로 대체한다. CSRF token은 ASCII 형식을 equality 이전에 검증하며, CSRF 거부의 동기 audit/metric persistence는 전용 bounded worker thread에서 수행해 event loop와 raw request ID를 보호한다. +- Gateway는 외부 `X-Request-ID` 중 canonical RFC 4122 UUID만 보존하고 다른 값은 새 UUID로 대체한다. CSRF token은 ASCII 형식을 equality 이전에 검증하고 malformed bootstrap organization scope는 발급 전에 고정 403으로 닫는다. Middleware와 bootstrap의 동기 audit/metric persistence는 하나의 process-shared 전용 bounded worker 경계에서 수행해 event loop, 공용 sync worker와 raw request ID를 보호한다. - Google OAuth 로그인을 지원한다 (`/api/v1/auth/google/login` → callback). - 인증 내부 실행의 safe same-origin `next` 복귀는 현재 이메일/비밀번호 로그인에만 적용하며, unsafe URL은 `/dashboard`로 닫는다. Google OAuth callback은 기존 `/dashboard` 복귀를 유지한다. - Bearer secret은 public run/webhook endpoint의 app secret 인증에만 사용한다. Public webhook은 [ADR-0041](decisions/ADR-0041-public-webhook-ingress-security-boundary.md)에 따라 query `token`을 거부하고 정확히 하나의 Bearer 또는 `X-Webhook-Secret` header만 허용한다. [ADR-0056](decisions/ADR-0056-app-auth-secret-issuance-and-rotation.md)에 따라 일반 App·Deployment 응답은 원문을 반환하지 않고, 명시적 one-time rotation API만 신규 원문을 반환한다. Gateway는 App current/previous 비가역 verifier를 권위로 사용하며 row lock·version CAS·최대 5분 grace·즉시 폐기를 집행한다. diff --git a/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md b/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md index e1c8c7c62..8f45e2e12 100644 --- a/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md +++ b/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md @@ -10,7 +10,7 @@ Gateway에는 공통 `get_current_user` dependency를 사용하는 route 외에 Safe GET도 응답의 `Set-Cookie` 부수효과를 가진다. Ambient cross-site image/navigation GET이 bootstrap token을 회전시키면 Client memory header와 host-only cookie가 달라져 non-replayable workflow mutation을 지속적으로 막을 수 있다. 또한 Client가 same-origin reverse proxy와 별도 공개 API origin을 함께 사용하면 token body와 host-only cookie가 서로 다른 host에 놓일 수 있다. -운영 검토에서 세 경계가 추가로 확인됐다. 동일한 만료 token을 사용한 동시 요청이 각각 cache generation을 올리면 먼저 시작한 정상 refresh가 무효화될 수 있다. Python의 문자열 `compare_digest`는 비ASCII 입력에서 exception을 내므로 형식 검증보다 먼저 호출할 수 없다. 또한 외부 request ID 원문을 CSRF audit에 복사하거나 동기 PostgreSQL audit commit을 async middleware에서 직접 실행하면 token/PII 보존과 event-loop 정체가 발생할 수 있다. +운영 검토에서 세 경계가 추가로 확인됐다. 동일한 만료 token을 사용한 동시 요청이 각각 cache generation을 올리면 먼저 시작한 정상 refresh가 무효화될 수 있다. Python의 문자열 `compare_digest`는 비ASCII 입력에서 exception을 내므로 형식 검증보다 먼저 호출할 수 없다. 또한 외부 request ID 원문을 CSRF audit에 복사하거나 동기 PostgreSQL audit commit을 async middleware에서 직접 실행하면 token/PII 보존과 event-loop 정체가 발생할 수 있다. Workflow 조직을 body에 전달하면서 CSRF scope header를 ambient active organization에 맡기면 실제 credential 소비 조직과 token binding이 달라질 수 있고, bootstrap endpoint가 malformed scope와 거부 감사를 middleware와 다른 오류·실행 경계로 처리하면 고정 응답과 가용성 계약을 우회한다. ## Options Considered @@ -31,7 +31,7 @@ Safe GET도 응답의 `Set-Cookie` 부수효과를 가진다. Ambient cross-site Bootstrap의 `Set-Cookie` 회전 방어에서는 exact Origin만 요구하는 방안, 기존 cookie가 있을 때 회전하지 않는 방안, custom header와 Origin/Fetch Metadata를 결합하는 방안을 비교했다. Exact Origin만 요구하면 same-origin safe GET에서 브라우저가 Origin을 생략하는 경우를 지원하지 못한다. 기존 cookie 재사용만으로는 첫 ambient 요청과 organization scope 전환을 막지 못한다. 따라서 custom header로 cross-origin 요청을 preflight에 묶고, exact allowlisted Origin 또는 same-origin Fetch Metadata를 추가 검증하는 방안을 선택했다. -후속 hardening에서는 모든 `403`이 무조건 generation을 올리는 방식과 rejected token이 current cache와 일치할 때만 compare-and-invalidate하는 방식을 비교해 후자를 선택했다. Request ID는 임의 printable 문자열 allowlist 대신 canonical RFC 4122 UUID만 보존하고 나머지는 서버 UUID로 대체한다. Audit persistence는 event loop 직접 호출, fire-and-forget queue, bounded thread 실행을 비교했다. 감사 유실을 허용하지 않으면서 event loop를 보호하기 위해 요청이 완료를 기다리는 전용 bounded thread 실행을 선택했다. +후속 hardening에서는 모든 `403`이 무조건 generation을 올리는 방식과 rejected token이 current cache와 일치할 때만 compare-and-invalidate하는 방식을 비교해 후자를 선택했다. Request ID는 임의 printable 문자열 allowlist 대신 canonical RFC 4122 UUID만 보존하고 나머지는 서버 UUID로 대체한다. Audit persistence는 event loop 직접 호출, fire-and-forget queue, bounded thread 실행을 비교했다. 감사 유실을 허용하지 않으면서 event loop를 보호하기 위해 요청이 완료를 기다리는 전용 bounded thread 실행을 선택했다. Middleware와 bootstrap이 별도 limiter를 소유하는 방안은 합산 DB concurrency와 공용 sync worker 점유를 제한하지 못하므로 하나의 process-shared CSRF telemetry limiter를 사용한다. ## Decision @@ -42,7 +42,7 @@ Option C를 채택한다. 1. `GET /api/v1/auth/csrf`는 `v1.expiry.nonce.mac` 형식의 10분 HMAC token을 응답 body와 host-only HttpOnly `csrf_token` cookie에 함께 발급한다. 2. Token payload에는 auth cookie, 사용자, organization 또는 그 fingerprint 원문을 넣지 않는다. MAC은 domain-separated key, binding 종류, auth cookie 또는 anonymous seed의 HMAC, active `X-Organization-Id` 또는 account sentinel을 포함한다. 3. 인증 cookie가 없으면 host-only HttpOnly random `csrf_anon_seed`에 결박한 pre-auth token을 발급한다. 유효하지 않거나 비활성 계정에 결박된 `auth_token`이 있으면 anonymous로 조용히 전환하지 않고 `401 auth.invalid`로 닫고 invalid auth/CSRF cookie를 삭제한다. Client는 cookie 삭제가 반영된 뒤 bootstrap을 한 번만 다시 시도할 수 있다. -4. Bootstrap은 `X-CSRF-Bootstrap: 1`을 필수로 요구한다. Origin이 있으면 credentialed CORS allowlist와 exact match해야 하고, Origin이 생략된 same-origin GET은 `Sec-Fetch-Site: same-origin`이어야 한다. 존재하는 Fetch Metadata의 cross-site 값은 거부한다. 이 검증은 token service, DB와 Set-Cookie보다 먼저 수행한다. +4. Bootstrap은 `X-CSRF-Bootstrap: 1`을 필수로 요구한다. Origin이 있으면 credentialed CORS allowlist와 exact match해야 하고, Origin이 생략된 same-origin GET은 `Sec-Fetch-Site: same-origin`이어야 한다. 존재하는 Fetch Metadata의 cross-site 값은 거부한다. `X-Organization-Id`의 길이와 제어 문자는 token 발급 전에 검증하고 실패를 `organization_scope_invalid` 내부 reason의 고정 CSRF 거부로 처리한다. 이 검증은 token service 발급, DB와 Set-Cookie보다 먼저 수행한다. 5. Bootstrap 응답은 `Cache-Control: no-store`, `Pragma: no-cache`를 사용한다. Token과 seed cookie는 `/api/v1`, 600초, HttpOnly, host-only이며 non-local에서는 Secure와 SameSite=None, loopback에서는 SameSite=Lax를 사용한다. 6. Signup, password login, Google OAuth 성공과 logout은 이전 CSRF/anonymous cookie를 삭제한다. Client는 인증 전환과 active organization 변경 시 memory token을 폐기한다. @@ -63,9 +63,9 @@ Option C를 채택한다. 1. CSRF 실패는 항상 `403 auth.csrf_validation_failed`와 고정 message를 반환한다. 내부에서는 bounded reason, policy, method와 검증된 request ID만 metric/audit에 기록하며 token, cookie, Origin, session, organization과 path parameter 원문을 기록하지 않는다. 외부 request ID는 canonical RFC 4122 UUID만 보존하고 나머지는 새 UUID로 대체한다. 2. Client token은 module memory에만 저장하고 localStorage, sessionStorage, URL과 log에 남기지 않는다. Origin마다 현재 organization/account scope token 하나만 유지하고, 실제 mutation origin과 scope가 같은 동시 bootstrap만 하나로 합치며 host-only cookie와 bootstrap endpoint를 mutation origin에 맞춘다. Lifecycle generation 이전에 시작한 bootstrap은 cache를 되살리지 못하고, 같은 origin의 새 bootstrap은 이전 요청이 정리된 뒤 cookie를 갱신한다. -3. 공통 Axios client와 보호된 직접 fetch는 unsafe method에 token을 자동 첨부한다. 동일한 rejected token의 동시 실패는 current origin/scope/token 비교로 generation을 한 번만 폐기하고 하나의 refresh를 공유한다. CSRF 실패 시 PUT/DELETE 또는 idempotency key가 있는 요청만 새 token으로 최대 한 번 재시도한다. 일반 POST/PATCH는 자동 replay하지 않는다. +3. 공통 Axios client와 보호된 직접 fetch는 unsafe method에 token을 자동 첨부한다. Workflow나 다른 보호 리소스의 authoritative organization을 이미 알고 있는 consumer는 그 값을 body와 `X-Organization-Id`에 함께 명시하고 ambient active organization fallback에 맡기지 않는다. 동일한 rejected token의 동시 실패는 current origin/scope/token 비교로 generation을 한 번만 폐기하고 하나의 refresh를 공유한다. CSRF 실패 시 PUT/DELETE 또는 idempotency key가 있는 요청만 새 token으로 최대 한 번 재시도한다. 일반 POST/PATCH는 자동 replay하지 않는다. 4. Workflow SSE의 same-origin Next proxy는 API host-only CSRF cookie를 직접 받을 수 없다. 이 단일 proxy는 엄격한 token 문자·길이 검사를 거친 `X-CSRF-Token`을 outbound `csrf_token` cookie로 복제하고, 원래 Origin, Fetch Metadata, organization과 request context를 Gateway에 전달한다. Gateway는 동일한 HMAC/session/scope 검증을 수행한다. -5. Header/cookie token은 constant-time equality 전에 bounded ASCII 형식을 검증한다. CSRF denial의 동기 metric/audit callback은 전용 bounded thread limiter로 event loop 밖에서 실행하고, 완료를 기다리되 callback exception이 고정 응답을 바꾸지 않게 격리한다. +5. Header/cookie token은 constant-time equality 전에 bounded ASCII 형식을 검증한다. Middleware mutation 거부, bootstrap proof/scope 거부와 invalid-session audit의 동기 metric/audit callback은 process-shared 전용 bounded thread limiter로 event loop 밖에서 실행하고, 완료를 기다리되 callback exception이 고정 응답을 바꾸지 않게 격리한다. ### Enforcement와 배포 @@ -82,7 +82,7 @@ Option C를 채택한다. - Host-only cookie는 origin 간 공유되지 않으므로 cache와 bootstrap도 실제 mutation origin별로 분리해야 header/cookie equality를 보장할 수 있다. - Non-idempotent 자동 replay를 금지하면 token expiry 복구가 중복 side effect로 바뀌지 않는다. - Rejected token과 current cache를 비교하면 늦은 동일 실패가 이미 진행 중인 정상 refresh를 취소하지 않으면서 실제 새 token 거부는 다시 폐기할 수 있다. -- Canonical UUID replacement와 bounded thread 실행은 감사 상관관계를 유지하면서 header 원문 보존과 sync DB commit의 event-loop 점유를 막는다. +- Canonical UUID replacement와 process-shared bounded thread 실행은 감사 상관관계를 유지하면서 header 원문 보존, sync DB commit의 event-loop 점유와 bootstrap 폭주에 의한 공용 worker 고갈을 막는다. ## Affected Files diff --git a/docs/features/auth/api_spec.md b/docs/features/auth/api_spec.md index 1146d9283..1cfa3ec38 100644 --- a/docs/features/auth/api_spec.md +++ b/docs/features/auth/api_spec.md @@ -29,7 +29,7 @@ Status: Draft | `X-CSRF-Bootstrap: 1` | Browser script가 의도적으로 token을 요청했음을 증명한다. 단순 image/navigation GET에는 이 header가 없어 발급 전에 거부된다. | | `Origin` / `Sec-Fetch-Site` | 교차 출처 요청은 `CORS_ORIGINS` exact Origin을 요구한다. Origin이 생략된 same-origin GET은 `Sec-Fetch-Site: same-origin`이어야 한다. 존재하는 Fetch Metadata의 `cross-site` 값은 거부한다. | | `auth_token` cookie | 존재하면 유효한 활성 사용자 session인지 검증하고 token을 그 cookie에 결박한다. Invalid 또는 inactive session은 `401 auth.invalid`과 삭제 Set-Cookie를 반환하며 anonymous로 같은 응답에서 전환하지 않는다. | -| `X-Organization-Id` | 존재하면 token MAC의 active organization scope에 포함한다. 없으면 account scope를 사용한다. | +| `X-Organization-Id` | 존재하면 token MAC의 active organization scope에 포함한다. 없으면 account scope를 사용한다. 128자를 넘거나 제어 문자를 포함하면 발급 전에 거부한다. | | `csrf_anon_seed` cookie | auth cookie가 없을 때 유효한 random seed를 재사용하며, 없거나 malformed이면 새 seed를 발급한다. | 성공 응답: `200 OK`. @@ -41,7 +41,7 @@ Status: Draft } ``` -응답은 같은 token을 host-only HttpOnly `csrf_token` cookie로 설정한다. Anonymous bootstrap은 host-only HttpOnly `csrf_anon_seed`도 설정한다. `Cache-Control: no-store`, `Pragma: no-cache`가 필수다. Token은 ASCII `v1.expiry.nonce.mac` 형식이며 auth cookie, user와 organization 원문을 포함하지 않는다. 비ASCII token은 equality 비교 전에 `token_invalid`로 닫고, equality를 통과한 비정규 token은 canonical parsing에서 `token_invalid`로 닫는다. Header/Origin/Fetch Metadata 검증 실패는 cookie를 설정하거나 회전시키지 않고 `403 auth.csrf_validation_failed`를 반환한다. +응답은 같은 token을 host-only HttpOnly `csrf_token` cookie로 설정한다. Anonymous bootstrap은 host-only HttpOnly `csrf_anon_seed`도 설정한다. `Cache-Control: no-store`, `Pragma: no-cache`가 필수다. Token은 ASCII `v1.expiry.nonce.mac` 형식이며 auth cookie, user와 organization 원문을 포함하지 않는다. 비ASCII token은 equality 비교 전에 `token_invalid`로 닫고, equality를 통과한 비정규 token은 canonical parsing에서 `token_invalid`로 닫는다. Header/Origin/Fetch Metadata 검증과 organization scope 형식 검증 실패는 cookie를 설정하거나 회전시키지 않고 `403 auth.csrf_validation_failed`를 반환한다. Scope 형식 실패의 내부 reason은 `organization_scope_invalid`이며 원문은 감사에 기록하지 않는다. `X-Request-ID`는 canonical RFC 4122 UUID만 보존한다. 다른 값은 서버가 생성한 UUID로 대체하며 입력 원문을 응답이나 CSRF 감사 metadata에 복사하지 않는다. diff --git a/docs/features/auth/component_spec.md b/docs/features/auth/component_spec.md index eb4383ae5..95d66a80e 100644 --- a/docs/features/auth/component_spec.md +++ b/docs/features/auth/component_spec.md @@ -232,9 +232,9 @@ Status: Draft - Guard는 endpoint보다 먼저 Origin, Fetch Metadata, content type, double-submit equality와 HMAC/session/scope를 검증한다. - Header/cookie token은 constant-time equality 전에 bounded ASCII 형식인지 확인해 비ASCII 입력을 exception 없는 `token_invalid`로 닫는다. -- 실패 body는 고정 `auth.csrf_validation_failed`만 노출한다. Bounded reason은 metric/audit adapter 내부에서만 사용한다. +- 실패 body는 고정 `auth.csrf_validation_failed`만 노출한다. Bootstrap organization scope는 token 발급 전에 길이와 제어 문자를 검증하고 실패를 `organization_scope_invalid` bounded reason으로 변환한다. Bounded reason은 metric/audit adapter 내부에서만 사용한다. - Gateway ingress와 CSRF guard는 같은 request ID helper를 사용한다. Canonical RFC 4122 UUID만 보존하고 그 밖의 header 원문은 새 UUID로 대체한다. -- 동기 audit/metric callback은 CSRF middleware의 전용 capacity limiter를 사용하는 worker thread에서 실행한다. 요청 coroutine은 결과를 기다리되 DB commit으로 event loop를 막지 않으며 callback exception은 고정 응답 뒤로 격리한다. +- 동기 audit/metric callback은 middleware와 bootstrap endpoint가 공유하는 process-shared 전용 capacity limiter의 worker thread에서 실행한다. 요청 coroutine은 결과를 기다리되 DB commit으로 event loop와 공용 sync worker를 막지 않으며 callback exception은 고정 응답 뒤로 격리한다. - 인증 cookie가 없는 protected mutation은 token을 identity로 사용하지 않고 `401 auth.required`로 종료한다. - CORS는 guard 바깥에서 허용 origin이 오류 응답을 읽게 하고, Public Conversation CORS와 webhook query redaction의 더 바깥 경계를 유지한다. @@ -243,7 +243,7 @@ Status: Draft - `csrfToken.ts`는 실제 mutation origin의 `/api/v1/auth/csrf`에 `X-CSRF-Bootstrap: 1`을 보내고 응답을 runtime 검증한 뒤 token과 expiry를 module memory에만 저장한다. - Origin마다 현재 organization/account scope의 token 하나만 유지한다. 같은 mutation origin과 scope의 동시 요청만 하나의 bootstrap Promise와 cached token을 공유하며, scope 전환은 같은 origin의 이전 token을 대체한다. 다른 origin, reload와 tab은 token을 공유하지 않는다. - Axios request interceptor는 active organization header가 결정된 뒤 unsafe request에 `X-CSRF-Token`을 추가한다. Response interceptor는 고정 CSRF error가 현재 cache의 동일 origin/scope/token을 거부한 경우에만 generation을 올린다. 동일 token을 사용한 동시 `403`은 한 refresh bootstrap을 공유하며 PUT/DELETE 또는 idempotency key 요청만 최대 한 번 재시도한다. -- `csrfFetch`는 Settings, Wizard, RAG stream과 Workflow stream처럼 Axios를 통하지 않는 protected mutation에 같은 계약을 제공한다. Public Chatbot/Public run, app-secret 실행과 presigned object upload에는 적용하지 않는다. +- `csrfFetch`는 Settings, Wizard, RAG stream과 Workflow stream처럼 Axios를 통하지 않는 protected mutation에 같은 계약을 제공한다. Workflow organization을 받은 Code/Prompt/Template Wizard는 그 authoritative ID를 body와 `X-Organization-Id`에 함께 보내 ambient active organization fallback이 token scope를 바꾸지 못하게 한다. Public Chatbot/Public run, app-secret 실행과 presigned object upload에는 적용하지 않는다. - Signup/login/logout 성공, OAuth navigation과 `nodease-active-organization-changed` event는 cache generation을 올리고 cached token을 폐기한다. 이전 generation의 진행 중 bootstrap은 cache를 되살리지 못하며, 같은 origin의 새 bootstrap은 이전 요청 정리 뒤 cookie를 마지막으로 갱신한다. Invalid 또는 inactive-session HttpOnly auth cookie bootstrap `401`은 cookie 삭제 반영을 위해 최대 한 번만 재시도한다. ### Workflow Stream Proxy diff --git a/docs/features/auth/requirements.md b/docs/features/auth/requirements.md index b4e4f9863..5f11e5cb4 100644 --- a/docs/features/auth/requirements.md +++ b/docs/features/auth/requirements.md @@ -116,6 +116,9 @@ Auth는 보호된 Gateway API가 `auth_token` 쿠키에서 현재 사용자를 - AUTH-REQ-090: 같은 origin/scope의 동일한 rejected token을 사용한 동시 안전 요청은 token cache generation을 한 번만 폐기하고 하나의 refresh bootstrap을 공유해야 한다. 늦게 도착한 동일 token의 `403`이 이미 시작한 refresh를 무효화하거나 정상 요청 하나를 실패시켜서는 안 된다. - AUTH-REQ-091: 외부 `X-Request-ID`는 canonical RFC 4122 UUID만 보존하고 다른 값은 서버 생성 UUID로 대체해야 한다. Token, PII 또는 임의 header 원문을 응답, audit와 log의 request ID로 반사하지 않아야 한다. - AUTH-REQ-092: CSRF 거부 audit/metric callback의 동기 DB 또는 I/O 작업은 Gateway event loop 밖의 전용 bounded thread 경계에서 수행해야 한다. Callback 실패는 고정 `401/403` 계약을 바꾸지 않아야 한다. +- AUTH-REQ-093: Workflow 또는 다른 보호 리소스의 authoritative organization을 알고 있는 direct-fetch consumer는 같은 organization ID를 request body와 `X-Organization-Id`에 명시해야 한다. Ambient active organization과 리소스 organization이 다를 때 token scope가 ambient 값으로 대체되어서는 안 된다. +- AUTH-REQ-094: Bootstrap의 `X-Organization-Id`가 128자를 넘거나 제어 문자를 포함하면 token 발급, DB 접근과 cookie 변경 전에 `organization_scope_invalid` bounded reason의 고정 `403 auth.csrf_validation_failed`로 닫아야 한다. +- AUTH-REQ-095: Middleware mutation 거부, bootstrap proof/scope 거부와 invalid-session 감사 callback은 하나의 process-shared 전용 capacity limiter를 사용해야 한다. Bootstrap 거부 폭주가 공용 sync worker 또는 DB connection concurrency를 점유해서는 안 된다. ## Policies And Edge Cases diff --git a/docs/features/auth/test_cases.md b/docs/features/auth/test_cases.md index 1a4aaaf19..ff215fcf4 100644 --- a/docs/features/auth/test_cases.md +++ b/docs/features/auth/test_cases.md @@ -152,6 +152,9 @@ Status: Draft | AUTH-TC-CS029 | CSRF 감사 request ID는 token/PII header를 반사하지 않아야 한다. | 유효한 CSRF token 또는 임의 문자열을 `X-Request-ID`에도 넣고 거부를 유도한다. | 응답과 audit에는 새 canonical UUID만 있고 입력 원문은 없음. | | AUTH-TC-CS030 | 동기 CSRF 거부 감사 persistence는 event loop를 점유하지 않아야 한다. | Sync callback에서 DB/I/O 대기를 모사하고 callback thread를 기록한다. | Callback은 bounded worker thread에서 실행되고 고정 403 계약 유지. | | AUTH-TC-CS031 | Auth API endpoint 행은 endpoint inventory에만 있어야 한다. | `/auth/csrf` endpoint 행을 field/cookie/error table에 중복한다. | 문서 구조 테스트 실패; endpoint 행 정확히 1개. | +| AUTH-TC-CS032 | Wizard mutation의 리소스 organization과 CSRF scope가 일치해야 한다. | LocalStorage는 조직 A지만 Workflow prop은 조직 B인 상태에서 Code/Prompt/Template 요청을 보낸다. | Body와 `X-Organization-Id`가 모두 조직 B이고 bootstrap/token scope도 B. | +| AUTH-TC-CS033 | Malformed bootstrap organization scope는 고정 CSRF 오류로 닫혀야 한다. | 129자 scope 또는 제어 문자를 포함한 scope로 bootstrap한다. | Token/cookie/DB effect 0, `organization_scope_invalid` 감사와 fixed 403. | +| AUTH-TC-CS034 | Bootstrap과 middleware 거부 감사는 같은 bounded 실행 경계를 사용해야 한다. | 동기 callback을 동시에 limiter 초과 실행하고 bootstrap request thread를 기록한다. | 동시 callback 최대 4, bootstrap 검증 thread와 audit worker thread 분리, 고정 오류 유지. | ## Component And Hook Tests From 5fcf524fb950caf715df4e0eefd3fb817f9cb324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Wed, 29 Jul 2026 21:13:09 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20CSRF=20=EB=8B=A4=EC=A4=91=20?= =?UTF-8?q?=ED=83=AD=20=ED=86=A0=ED=81=B0=20=EB=B0=8F=20=EA=B4=80=EC=B8=A1?= =?UTF-8?q?=20reason=20=EA=B2=BD=EA=B3=84=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/gateway/adapters/csrf/observability.py | 14 +--- apps/gateway/api/v1/endpoints/auth.py | 9 ++- apps/gateway/application/csrf/token.py | 36 ++++++++- .../tests/adapters/csrf/test_observability.py | 65 ++++++++++++++++ apps/gateway/tests/api/test_auth_csrf.py | 38 ++++++++++ .../application/csrf/test_token_service.py | 74 +++++++++++++++++++ docs/architecture.md | 4 +- ...-cookie-authenticated-api-csrf-boundary.md | 12 +-- docs/features/auth/api_spec.md | 2 +- docs/features/auth/component_spec.md | 3 +- docs/features/auth/requirements.md | 2 + docs/features/auth/test_cases.md | 3 + 12 files changed, 239 insertions(+), 23 deletions(-) create mode 100644 apps/gateway/tests/adapters/csrf/test_observability.py diff --git a/apps/gateway/adapters/csrf/observability.py b/apps/gateway/adapters/csrf/observability.py index 73cc0b600..4f3d53b32 100644 --- a/apps/gateway/adapters/csrf/observability.py +++ b/apps/gateway/adapters/csrf/observability.py @@ -4,6 +4,8 @@ from collections import Counter from typing import ClassVar +from apps.gateway.application.csrf.token import CsrfValidationReason + logger = logging.getLogger(__name__) try: @@ -26,17 +28,7 @@ class CsrfObservability: - _REASONS = frozenset( - { - "token_missing", - "token_mismatch", - "token_invalid", - "token_expired", - "origin_invalid", - "fetch_metadata_invalid", - "content_type_invalid", - } - ) + _REASONS = frozenset(reason.value for reason in CsrfValidationReason) _POLICIES = frozenset({"cookie_authenticated", "pre_auth_session"}) _METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) _local_counters: ClassVar[Counter[tuple[str, str, str]]] = Counter() diff --git a/apps/gateway/api/v1/endpoints/auth.py b/apps/gateway/api/v1/endpoints/auth.py index 1b1808327..d9f39ed03 100644 --- a/apps/gateway/api/v1/endpoints/auth.py +++ b/apps/gateway/api/v1/endpoints/auth.py @@ -316,11 +316,18 @@ async def bootstrap_csrf_token( binding_kind = CsrfBindingKind.PRE_AUTH binding_secret = anonymous_seed - issued = token_service.issue( + issued = token_service.reuse_if_valid( + token=request.cookies.get(CSRF_COOKIE_NAME), binding_kind=binding_kind, binding_secret=binding_secret, organization_scope=organization_scope, ) + if issued is None: + issued = token_service.issue( + binding_kind=binding_kind, + binding_secret=binding_secret, + organization_scope=organization_scope, + ) _set_csrf_cookie( response, diff --git a/apps/gateway/application/csrf/token.py b/apps/gateway/application/csrf/token.py index 9ef487506..7bb8ef43c 100644 --- a/apps/gateway/application/csrf/token.py +++ b/apps/gateway/application/csrf/token.py @@ -118,8 +118,10 @@ def issue( now: datetime | None = None, ) -> IssuedCsrfToken: issued_at = self._normalize_now(now) - expires_at = issued_at + timedelta(seconds=self._ttl_seconds) - expiry = int(expires_at.timestamp()) + expiry = int( + (issued_at + timedelta(seconds=self._ttl_seconds)).timestamp() + ) + expires_at = datetime.fromtimestamp(expiry, tz=timezone.utc) nonce = self._nonce_factory(_NONCE_BYTES) if len(nonce) != _NONCE_BYTES: raise ValueError("CSRF nonce factory returned an invalid length") @@ -140,6 +142,36 @@ def issue( ) return IssuedCsrfToken(token=token, expires_at=expires_at) + def reuse_if_valid( + self, + *, + token: str | None, + binding_kind: CsrfBindingKind, + binding_secret: str, + organization_scope: str | None, + now: datetime | None = None, + ) -> IssuedCsrfToken | None: + if ( + self.validate( + header_token=token, + cookie_token=token, + binding_kind=binding_kind, + binding_secret=binding_secret, + organization_scope=organization_scope, + now=now, + ) + is not None + ): + return None + + # validate() already proved the canonical token shape and expiry. + assert token is not None + _, raw_expiry, _, _ = token.split(".") + return IssuedCsrfToken( + token=token, + expires_at=datetime.fromtimestamp(int(raw_expiry), tz=timezone.utc), + ) + def validate( self, *, diff --git a/apps/gateway/tests/adapters/csrf/test_observability.py b/apps/gateway/tests/adapters/csrf/test_observability.py new file mode 100644 index 000000000..329f25c63 --- /dev/null +++ b/apps/gateway/tests/adapters/csrf/test_observability.py @@ -0,0 +1,65 @@ +import logging + +from apps.gateway.adapters.csrf import observability +from apps.gateway.adapters.csrf.observability import CsrfObservability + + +class _MetricSpy: + def __init__(self): + self.labels_seen: list[dict[str, str]] = [] + self.increment_count = 0 + + def labels(self, **labels): + self.labels_seen.append(labels) + return self + + def inc(self): + self.increment_count += 1 + + +def test_organization_scope_invalid_is_preserved_and_unknown_stays_bounded( + monkeypatch, + caplog, +): + metric = _MetricSpy() + monkeypatch.setattr(observability, "CSRF_DENIALS", metric) + CsrfObservability._local_counters.clear() + + with caplog.at_level(logging.INFO, logger=observability.__name__): + CsrfObservability.record( + reason="organization_scope_invalid", + policy="pre_auth_session", + method="GET", + ) + CsrfObservability.record( + reason="raw-scope-sentinel", + policy="pre_auth_session", + method="GET", + ) + + labels = ( + "organization_scope_invalid", + "pre_auth_session", + "GET", + ) + assert CsrfObservability._local_counters[labels] == 1 + assert CsrfObservability._local_counters[ + ("unknown", "pre_auth_session", "GET") + ] == 1 + assert metric.labels_seen == [ + { + "reason": "organization_scope_invalid", + "policy": "pre_auth_session", + "method": "GET", + }, + { + "reason": "unknown", + "policy": "pre_auth_session", + "method": "GET", + }, + ] + assert metric.increment_count == 2 + assert [record.reason for record in caplog.records[-2:]] == [ + "organization_scope_invalid", + "unknown", + ] diff --git a/apps/gateway/tests/api/test_auth_csrf.py b/apps/gateway/tests/api/test_auth_csrf.py index 1c0e524a9..735ffbda1 100644 --- a/apps/gateway/tests/api/test_auth_csrf.py +++ b/apps/gateway/tests/api/test_auth_csrf.py @@ -98,6 +98,44 @@ def test_authenticated_csrf_bootstrap_validates_cookie_and_clears_anon_seed( ) +def test_authenticated_bootstrap_reuses_same_session_and_scope_cookie(monkeypatch): + monkeypatch.setattr(AuthService, "get_user_from_token", lambda db, token: object()) + monkeypatch.setattr( + auth_endpoint, + "csrf_token_service", + lambda: CsrfTokenService.from_root_secret("csrf-endpoint-test-secret"), + ) + + with _client() as client: + client.cookies.set("auth_token", "valid-auth-token") + first = client.get( + "/auth/csrf", + headers={ + **_bootstrap_headers(), + "X-Organization-Id": "organization-a", + }, + ) + first_token = first.json()["token"] + + # TestClient의 축약 route와 production cookie path가 다르므로 + # 두 번째 탭이 공유 cookie를 보내는 상태를 명시적으로 구성한다. + client.cookies.clear() + client.cookies.set("auth_token", "valid-auth-token") + client.cookies.set(CSRF_COOKIE_NAME, first_token) + second = client.get( + "/auth/csrf", + headers={ + **_bootstrap_headers(), + "X-Organization-Id": "organization-a", + }, + ) + + assert first.status_code == 200 + assert second.status_code == 200 + assert second.json()["token"] == first_token + assert second.cookies[CSRF_COOKIE_NAME] == first_token + + def test_invalid_auth_cookie_cannot_fall_back_to_anonymous_bootstrap(monkeypatch): def reject_invalid_cookie(_db, _token): raise HTTPException(status_code=401, detail="invalid") diff --git a/apps/gateway/tests/application/csrf/test_token_service.py b/apps/gateway/tests/application/csrf/test_token_service.py index 1570985e2..6f02fada9 100644 --- a/apps/gateway/tests/application/csrf/test_token_service.py +++ b/apps/gateway/tests/application/csrf/test_token_service.py @@ -59,6 +59,80 @@ def test_valid_signed_double_submit_token_is_accepted( assert reason is None +def test_valid_existing_token_can_be_reused_for_the_same_binding_and_scope( + service: CsrfTokenService, +): + now = datetime(2026, 7, 29, microsecond=123456, tzinfo=timezone.utc) + issued = service.issue( + binding_kind=CsrfBindingKind.AUTHENTICATED, + binding_secret="session-a", + organization_scope="organization-a", + now=now, + ) + + reused = service.reuse_if_valid( + token=issued.token, + binding_kind=CsrfBindingKind.AUTHENTICATED, + binding_secret="session-a", + organization_scope="organization-a", + now=now + timedelta(seconds=1), + ) + + assert reused == issued + + +@pytest.mark.parametrize( + ( + "binding_kind", + "binding_secret", + "organization_scope", + "elapsed_seconds", + ), + [ + (CsrfBindingKind.AUTHENTICATED, "session-b", "organization-a", 1), + (CsrfBindingKind.AUTHENTICATED, "session-a", "organization-b", 1), + (CsrfBindingKind.PRE_AUTH, "session-a", "organization-a", 1), + (CsrfBindingKind.AUTHENTICATED, "session-a", "organization-a", 601), + ], +) +def test_existing_token_is_not_reused_across_binding_scope_or_expiry( + service: CsrfTokenService, + binding_kind: CsrfBindingKind, + binding_secret: str, + organization_scope: str, + elapsed_seconds: int, +): + now = datetime(2026, 7, 29, tzinfo=timezone.utc) + issued = service.issue( + binding_kind=CsrfBindingKind.AUTHENTICATED, + binding_secret="session-a", + organization_scope="organization-a", + now=now, + ) + + reused = service.reuse_if_valid( + token=issued.token, + binding_kind=binding_kind, + binding_secret=binding_secret, + organization_scope=organization_scope, + now=now + timedelta(seconds=elapsed_seconds), + ) + + assert reused is None + + +def test_malformed_existing_token_is_not_reused(service: CsrfTokenService): + reused = service.reuse_if_valid( + token="not-a-token", + binding_kind=CsrfBindingKind.PRE_AUTH, + binding_secret="anonymous-seed", + organization_scope=None, + now=datetime(2026, 7, 29, tzinfo=timezone.utc), + ) + + assert reused is None + + @pytest.mark.parametrize( ("header_token", "cookie_token", "expected"), [ diff --git a/docs/architecture.md b/docs/architecture.md index c227c013d..b9579d3ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -672,11 +672,11 @@ Canonical content revision/input hash ### 사용자 인증 - 사용자 세션은 `auth_token` HttpOnly cookie 기준이다. user session용 Bearer token dependency는 없다. -- Cookie-authenticated/pre-auth unsafe Gateway API는 [ADR-0073](decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md)의 signed double-submit 경계를 사용한다. Browser bootstrap header와 same-origin Fetch Metadata 또는 exact configured Origin을 증명한 `GET /api/v1/auth/csrf`만 10분 token을 body와 host-only HttpOnly cookie로 발급하며 token MAC은 auth cookie 또는 anonymous seed와 active organization/account scope에 결박된다. Ambient cross-site GET은 cookie를 회전시키지 않는다. +- Cookie-authenticated/pre-auth unsafe Gateway API는 [ADR-0073](decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md)의 signed double-submit 경계를 사용한다. Browser bootstrap header와 same-origin Fetch Metadata 또는 exact configured Origin을 증명한 `GET /api/v1/auth/csrf`만 10분 token을 body와 host-only HttpOnly cookie로 발급하며 token MAC은 auth cookie 또는 anonymous seed와 active organization/account scope에 결박된다. 현재 cookie가 같은 binding/scope에서 유효하면 원래 token/expiry를 재사용해 여러 탭의 memory header와 공유 cookie를 일치시키며, 다른 session/scope와 만료 token은 새로 발급한다. Ambient cross-site GET은 cookie를 회전시키지 않는다. - Gateway는 모든 unsafe route를 cookie, pre-auth, public anonymous 또는 server credential audience로 startup 시 분류한다. Cookie/pre-auth route는 body parsing과 DB·queue·storage·provider 이전에 exact configured Origin, Fetch Metadata, JSON/명시 multipart/bodyless와 token equality/signature/session/scope를 검증한다. 명시적 zero-length `BODY_OPTIONAL` 요청은 Axios media type과 무관하게 허용하고 non-empty form body는 거부한다. Public/server route는 login cookie를 principal로 해석하지 않는다. - Browser Client는 CSRF token을 실제 mutation origin과 organization/account scope별 module memory에만 보관하고 active organization/auth lifecycle에서 폐기한다. Token bootstrap과 host-only cookie는 mutation과 같은 origin에서 수행한다. Workflow organization을 알고 있는 Wizard/direct-fetch consumer는 그 authoritative ID를 body와 organization header에 함께 명시한다. 동일한 rejected token의 동시 실패는 current-token 비교로 generation을 한 번만 폐기하고 하나의 refresh를 공유하며, 안전하게 replay 가능한 request만 고정 CSRF 오류 뒤 최대 한 번 갱신·재시도한다. Workflow SSE Next proxy는 strict header token만 outbound host-only cookie로 복제하고 original Origin/Fetch Metadata를 전달하며 Gateway가 최종 검증한다. - Middleware 외곽 순서는 webhook query redaction, Public Conversation CORS boundary, credentialed CORS, CSRF, Session 순이다. 따라서 Public iframe 경계를 유지하면서 configured Client가 CSRF `401/403`을 읽을 수 있다. -- Gateway는 외부 `X-Request-ID` 중 canonical RFC 4122 UUID만 보존하고 다른 값은 새 UUID로 대체한다. CSRF token은 ASCII 형식을 equality 이전에 검증하고 malformed bootstrap organization scope는 발급 전에 고정 403으로 닫는다. Middleware와 bootstrap의 동기 audit/metric persistence는 하나의 process-shared 전용 bounded worker 경계에서 수행해 event loop, 공용 sync worker와 raw request ID를 보호한다. +- Gateway는 외부 `X-Request-ID` 중 canonical RFC 4122 UUID만 보존하고 다른 값은 새 UUID로 대체한다. CSRF token은 ASCII 형식을 equality 이전에 검증하고 malformed bootstrap organization scope는 발급 전에 고정 403으로 닫는다. `organization_scope_invalid`를 포함한 계약 reason은 bounded metric/log label로 보존하고 미등록 값만 `unknown`으로 축약한다. Middleware와 bootstrap의 동기 audit/metric persistence는 하나의 process-shared 전용 bounded worker 경계에서 수행해 event loop, 공용 sync worker와 raw request ID를 보호한다. - Google OAuth 로그인을 지원한다 (`/api/v1/auth/google/login` → callback). - 인증 내부 실행의 safe same-origin `next` 복귀는 현재 이메일/비밀번호 로그인에만 적용하며, unsafe URL은 `/dashboard`로 닫는다. Google OAuth callback은 기존 `/dashboard` 복귀를 유지한다. - Bearer secret은 public run/webhook endpoint의 app secret 인증에만 사용한다. Public webhook은 [ADR-0041](decisions/ADR-0041-public-webhook-ingress-security-boundary.md)에 따라 query `token`을 거부하고 정확히 하나의 Bearer 또는 `X-Webhook-Secret` header만 허용한다. [ADR-0056](decisions/ADR-0056-app-auth-secret-issuance-and-rotation.md)에 따라 일반 App·Deployment 응답은 원문을 반환하지 않고, 명시적 one-time rotation API만 신규 원문을 반환한다. Gateway는 App current/previous 비가역 verifier를 권위로 사용하며 row lock·version CAS·최대 5분 grace·즉시 폐기를 집행한다. diff --git a/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md b/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md index 8f45e2e12..735cd593f 100644 --- a/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md +++ b/docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md @@ -10,7 +10,7 @@ Gateway에는 공통 `get_current_user` dependency를 사용하는 route 외에 Safe GET도 응답의 `Set-Cookie` 부수효과를 가진다. Ambient cross-site image/navigation GET이 bootstrap token을 회전시키면 Client memory header와 host-only cookie가 달라져 non-replayable workflow mutation을 지속적으로 막을 수 있다. 또한 Client가 same-origin reverse proxy와 별도 공개 API origin을 함께 사용하면 token body와 host-only cookie가 서로 다른 host에 놓일 수 있다. -운영 검토에서 세 경계가 추가로 확인됐다. 동일한 만료 token을 사용한 동시 요청이 각각 cache generation을 올리면 먼저 시작한 정상 refresh가 무효화될 수 있다. Python의 문자열 `compare_digest`는 비ASCII 입력에서 exception을 내므로 형식 검증보다 먼저 호출할 수 없다. 또한 외부 request ID 원문을 CSRF audit에 복사하거나 동기 PostgreSQL audit commit을 async middleware에서 직접 실행하면 token/PII 보존과 event-loop 정체가 발생할 수 있다. Workflow 조직을 body에 전달하면서 CSRF scope header를 ambient active organization에 맡기면 실제 credential 소비 조직과 token binding이 달라질 수 있고, bootstrap endpoint가 malformed scope와 거부 감사를 middleware와 다른 오류·실행 경계로 처리하면 고정 응답과 가용성 계약을 우회한다. +운영 검토에서 추가 경계가 확인됐다. 동일한 만료 token을 사용한 동시 요청이 각각 cache generation을 올리면 먼저 시작한 정상 refresh가 무효화될 수 있다. 같은 session/scope의 여러 탭이 각각 bootstrap하면서 새 nonce를 발급하면 브라우저 공유 cookie가 바뀌어 먼저 열린 탭의 non-replayable mutation이 실패한다. Python의 문자열 `compare_digest`는 비ASCII 입력에서 exception을 내므로 형식 검증보다 먼저 호출할 수 없다. 또한 외부 request ID 원문을 CSRF audit에 복사하거나 동기 PostgreSQL audit commit을 async middleware에서 직접 실행하면 token/PII 보존과 event-loop 정체가 발생할 수 있다. Workflow 조직을 body에 전달하면서 CSRF scope header를 ambient active organization에 맡기면 실제 credential 소비 조직과 token binding이 달라질 수 있고, bootstrap endpoint가 malformed scope와 거부 감사를 middleware와 다른 오류·실행 경계로 처리하면 고정 응답과 가용성 계약을 우회한다. 새 scope reason이 bounded observability allowlist에 없으면 안전한 원문 제한은 유지하더라도 공격·오구성을 `unknown`과 구분할 수 없다. ## Options Considered @@ -31,7 +31,7 @@ Safe GET도 응답의 `Set-Cookie` 부수효과를 가진다. Ambient cross-site Bootstrap의 `Set-Cookie` 회전 방어에서는 exact Origin만 요구하는 방안, 기존 cookie가 있을 때 회전하지 않는 방안, custom header와 Origin/Fetch Metadata를 결합하는 방안을 비교했다. Exact Origin만 요구하면 same-origin safe GET에서 브라우저가 Origin을 생략하는 경우를 지원하지 못한다. 기존 cookie 재사용만으로는 첫 ambient 요청과 organization scope 전환을 막지 못한다. 따라서 custom header로 cross-origin 요청을 preflight에 묶고, exact allowlisted Origin 또는 same-origin Fetch Metadata를 추가 검증하는 방안을 선택했다. -후속 hardening에서는 모든 `403`이 무조건 generation을 올리는 방식과 rejected token이 current cache와 일치할 때만 compare-and-invalidate하는 방식을 비교해 후자를 선택했다. Request ID는 임의 printable 문자열 allowlist 대신 canonical RFC 4122 UUID만 보존하고 나머지는 서버 UUID로 대체한다. Audit persistence는 event loop 직접 호출, fire-and-forget queue, bounded thread 실행을 비교했다. 감사 유실을 허용하지 않으면서 event loop를 보호하기 위해 요청이 완료를 기다리는 전용 bounded thread 실행을 선택했다. Middleware와 bootstrap이 별도 limiter를 소유하는 방안은 합산 DB concurrency와 공용 sync worker 점유를 제한하지 못하므로 하나의 process-shared CSRF telemetry limiter를 사용한다. +후속 hardening에서는 모든 `403`이 무조건 generation을 올리는 방식과 rejected token이 current cache와 일치할 때만 compare-and-invalidate하는 방식을 비교해 후자를 선택했다. 탭 간 조정에서는 브라우저 저장소에 token을 공유하는 방안, 매 bootstrap마다 새 token을 발급하는 방안, 서버가 현재 cookie를 같은 binding/scope에서 검증해 재사용하는 방안을 비교했다. Memory-only Client 원칙과 lifecycle 격리를 유지하면서 공유 cookie를 안정화하기 위해 마지막 방안을 선택한다. Request ID는 임의 printable 문자열 allowlist 대신 canonical RFC 4122 UUID만 보존하고 나머지는 서버 UUID로 대체한다. Audit persistence는 event loop 직접 호출, fire-and-forget queue, bounded thread 실행을 비교했다. 감사 유실을 허용하지 않으면서 event loop를 보호하기 위해 요청이 완료를 기다리는 전용 bounded thread 실행을 선택했다. Middleware와 bootstrap이 별도 limiter를 소유하는 방안은 합산 DB concurrency와 공용 sync worker 점유를 제한하지 못하므로 하나의 process-shared CSRF telemetry limiter를 사용한다. ## Decision @@ -43,8 +43,9 @@ Option C를 채택한다. 2. Token payload에는 auth cookie, 사용자, organization 또는 그 fingerprint 원문을 넣지 않는다. MAC은 domain-separated key, binding 종류, auth cookie 또는 anonymous seed의 HMAC, active `X-Organization-Id` 또는 account sentinel을 포함한다. 3. 인증 cookie가 없으면 host-only HttpOnly random `csrf_anon_seed`에 결박한 pre-auth token을 발급한다. 유효하지 않거나 비활성 계정에 결박된 `auth_token`이 있으면 anonymous로 조용히 전환하지 않고 `401 auth.invalid`로 닫고 invalid auth/CSRF cookie를 삭제한다. Client는 cookie 삭제가 반영된 뒤 bootstrap을 한 번만 다시 시도할 수 있다. 4. Bootstrap은 `X-CSRF-Bootstrap: 1`을 필수로 요구한다. Origin이 있으면 credentialed CORS allowlist와 exact match해야 하고, Origin이 생략된 same-origin GET은 `Sec-Fetch-Site: same-origin`이어야 한다. 존재하는 Fetch Metadata의 cross-site 값은 거부한다. `X-Organization-Id`의 길이와 제어 문자는 token 발급 전에 검증하고 실패를 `organization_scope_invalid` 내부 reason의 고정 CSRF 거부로 처리한다. 이 검증은 token service 발급, DB와 Set-Cookie보다 먼저 수행한다. -5. Bootstrap 응답은 `Cache-Control: no-store`, `Pragma: no-cache`를 사용한다. Token과 seed cookie는 `/api/v1`, 600초, HttpOnly, host-only이며 non-local에서는 Secure와 SameSite=None, loopback에서는 SameSite=Lax를 사용한다. -6. Signup, password login, Google OAuth 성공과 logout은 이전 CSRF/anonymous cookie를 삭제한다. Client는 인증 전환과 active organization 변경 시 memory token을 폐기한다. +5. Bootstrap 요청의 기존 `csrf_token` cookie가 현재 binding kind, auth cookie 또는 anonymous seed, organization/account scope와 expiry에 유효하면 token body와 cookie에 같은 값을 재사용한다. 다른 session/scope, binding kind, 만료 또는 malformed token은 재사용하지 않고 새 token을 발급한다. +6. Bootstrap 응답은 `Cache-Control: no-store`, `Pragma: no-cache`를 사용한다. Token과 seed cookie는 `/api/v1`, 600초, HttpOnly, host-only이며 non-local에서는 Secure와 SameSite=None, loopback에서는 SameSite=Lax를 사용한다. +7. Signup, password login, Google OAuth 성공과 logout은 이전 CSRF/anonymous cookie를 삭제한다. Client는 인증 전환과 active organization 변경 시 memory token을 폐기한다. ### 중앙 route policy와 검증 순서 @@ -61,7 +62,7 @@ Option C를 채택한다. ### 오류, 관측과 Client -1. CSRF 실패는 항상 `403 auth.csrf_validation_failed`와 고정 message를 반환한다. 내부에서는 bounded reason, policy, method와 검증된 request ID만 metric/audit에 기록하며 token, cookie, Origin, session, organization과 path parameter 원문을 기록하지 않는다. 외부 request ID는 canonical RFC 4122 UUID만 보존하고 나머지는 새 UUID로 대체한다. +1. CSRF 실패는 항상 `403 auth.csrf_validation_failed`와 고정 message를 반환한다. 내부에서는 bounded reason, policy, method와 검증된 request ID만 metric/audit에 기록하며 token, cookie, Origin, session, organization과 path parameter 원문을 기록하지 않는다. `organization_scope_invalid`를 포함한 계약 reason은 metric/log allowlist에 같은 값으로 보존하고 미등록 값만 `unknown`으로 축약한다. 외부 request ID는 canonical RFC 4122 UUID만 보존하고 나머지는 새 UUID로 대체한다. 2. Client token은 module memory에만 저장하고 localStorage, sessionStorage, URL과 log에 남기지 않는다. Origin마다 현재 organization/account scope token 하나만 유지하고, 실제 mutation origin과 scope가 같은 동시 bootstrap만 하나로 합치며 host-only cookie와 bootstrap endpoint를 mutation origin에 맞춘다. Lifecycle generation 이전에 시작한 bootstrap은 cache를 되살리지 못하고, 같은 origin의 새 bootstrap은 이전 요청이 정리된 뒤 cookie를 갱신한다. 3. 공통 Axios client와 보호된 직접 fetch는 unsafe method에 token을 자동 첨부한다. Workflow나 다른 보호 리소스의 authoritative organization을 이미 알고 있는 consumer는 그 값을 body와 `X-Organization-Id`에 함께 명시하고 ambient active organization fallback에 맡기지 않는다. 동일한 rejected token의 동시 실패는 current origin/scope/token 비교로 generation을 한 번만 폐기하고 하나의 refresh를 공유한다. CSRF 실패 시 PUT/DELETE 또는 idempotency key가 있는 요청만 새 token으로 최대 한 번 재시도한다. 일반 POST/PATCH는 자동 replay하지 않는다. 4. Workflow SSE의 same-origin Next proxy는 API host-only CSRF cookie를 직접 받을 수 없다. 이 단일 proxy는 엄격한 token 문자·길이 검사를 거친 `X-CSRF-Token`을 outbound `csrf_token` cookie로 복제하고, 원래 Origin, Fetch Metadata, organization과 request context를 Gateway에 전달한다. Gateway는 동일한 HMAC/session/scope 검증을 수행한다. @@ -79,6 +80,7 @@ Option C를 채택한다. - Route audience를 먼저 분류하면 login cookie의 우연한 포함이 Public 또는 server credential route의 principal을 바꾸지 않는다. - Exact Origin, Fetch Metadata, content type와 token을 독립적으로 검증하면 어느 한 방어 계층의 오구성이 곧바로 mutation 허용으로 이어지지 않는다. - Custom bootstrap header는 ambient image/navigation GET을 차단하고 cross-origin script 요청을 CORS preflight에 묶는다. Same-origin Fetch Metadata fallback은 safe GET에서 Origin이 생략되는 브라우저 동작을 지원한다. +- 같은 binding/scope의 유효한 cookie token을 재사용하면 token을 browser storage에 복제하지 않고도 여러 탭의 memory header와 공유 cookie가 어긋나는 것을 막는다. - Host-only cookie는 origin 간 공유되지 않으므로 cache와 bootstrap도 실제 mutation origin별로 분리해야 header/cookie equality를 보장할 수 있다. - Non-idempotent 자동 replay를 금지하면 token expiry 복구가 중복 side effect로 바뀌지 않는다. - Rejected token과 current cache를 비교하면 늦은 동일 실패가 이미 진행 중인 정상 refresh를 취소하지 않으면서 실제 새 token 거부는 다시 폐기할 수 있다. diff --git a/docs/features/auth/api_spec.md b/docs/features/auth/api_spec.md index 1cfa3ec38..2a1d07dbd 100644 --- a/docs/features/auth/api_spec.md +++ b/docs/features/auth/api_spec.md @@ -41,7 +41,7 @@ Status: Draft } ``` -응답은 같은 token을 host-only HttpOnly `csrf_token` cookie로 설정한다. Anonymous bootstrap은 host-only HttpOnly `csrf_anon_seed`도 설정한다. `Cache-Control: no-store`, `Pragma: no-cache`가 필수다. Token은 ASCII `v1.expiry.nonce.mac` 형식이며 auth cookie, user와 organization 원문을 포함하지 않는다. 비ASCII token은 equality 비교 전에 `token_invalid`로 닫고, equality를 통과한 비정규 token은 canonical parsing에서 `token_invalid`로 닫는다. Header/Origin/Fetch Metadata 검증과 organization scope 형식 검증 실패는 cookie를 설정하거나 회전시키지 않고 `403 auth.csrf_validation_failed`를 반환한다. Scope 형식 실패의 내부 reason은 `organization_scope_invalid`이며 원문은 감사에 기록하지 않는다. +응답은 같은 token을 host-only HttpOnly `csrf_token` cookie로 설정한다. 요청 cookie의 token이 현재 auth/anonymous binding, organization/account scope와 expiry에 유효하면 새 nonce를 발급하지 않고 그 token과 원래 expiry를 재사용한다. 다른 session/scope, binding kind, 만료 또는 malformed token은 새로 발급한다. Anonymous bootstrap은 host-only HttpOnly `csrf_anon_seed`도 설정한다. `Cache-Control: no-store`, `Pragma: no-cache`가 필수다. Token은 ASCII `v1.expiry.nonce.mac` 형식이며 auth cookie, user와 organization 원문을 포함하지 않는다. 비ASCII token은 equality 비교 전에 `token_invalid`로 닫고, equality를 통과한 비정규 token은 canonical parsing에서 `token_invalid`로 닫는다. Header/Origin/Fetch Metadata 검증과 organization scope 형식 검증 실패는 cookie를 설정하거나 회전시키지 않고 `403 auth.csrf_validation_failed`를 반환한다. Scope 형식 실패의 내부 reason `organization_scope_invalid`는 metric/log에서도 같은 bounded label로 보존하며 원문은 감사에 기록하지 않는다. `X-Request-ID`는 canonical RFC 4122 UUID만 보존한다. 다른 값은 서버가 생성한 UUID로 대체하며 입력 원문을 응답이나 CSRF 감사 metadata에 복사하지 않는다. diff --git a/docs/features/auth/component_spec.md b/docs/features/auth/component_spec.md index 95d66a80e..75f06b349 100644 --- a/docs/features/auth/component_spec.md +++ b/docs/features/auth/component_spec.md @@ -232,7 +232,8 @@ Status: Draft - Guard는 endpoint보다 먼저 Origin, Fetch Metadata, content type, double-submit equality와 HMAC/session/scope를 검증한다. - Header/cookie token은 constant-time equality 전에 bounded ASCII 형식인지 확인해 비ASCII 입력을 exception 없는 `token_invalid`로 닫는다. -- 실패 body는 고정 `auth.csrf_validation_failed`만 노출한다. Bootstrap organization scope는 token 발급 전에 길이와 제어 문자를 검증하고 실패를 `organization_scope_invalid` bounded reason으로 변환한다. Bounded reason은 metric/audit adapter 내부에서만 사용한다. +- 실패 body는 고정 `auth.csrf_validation_failed`만 노출한다. Bootstrap organization scope는 token 발급 전에 길이와 제어 문자를 검증하고 실패를 `organization_scope_invalid` bounded reason으로 변환한다. 계약된 bounded reason은 metric/log allowlist에서 같은 label로 보존하고 미등록 값만 `unknown`으로 축약하며, 원문은 metric/audit adapter에 전달하지 않는다. +- Token service는 요청 cookie를 현재 binding kind, auth cookie 또는 anonymous seed, organization/account scope와 expiry로 재검증한다. 유효하면 원래 token과 expiry를 반환하고, 실패하면 endpoint가 새 token을 발급해 동일 session/scope의 여러 탭이 공유 cookie를 서로 무효화하지 않게 한다. - Gateway ingress와 CSRF guard는 같은 request ID helper를 사용한다. Canonical RFC 4122 UUID만 보존하고 그 밖의 header 원문은 새 UUID로 대체한다. - 동기 audit/metric callback은 middleware와 bootstrap endpoint가 공유하는 process-shared 전용 capacity limiter의 worker thread에서 실행한다. 요청 coroutine은 결과를 기다리되 DB commit으로 event loop와 공용 sync worker를 막지 않으며 callback exception은 고정 응답 뒤로 격리한다. - 인증 cookie가 없는 protected mutation은 token을 identity로 사용하지 않고 `401 auth.required`로 종료한다. diff --git a/docs/features/auth/requirements.md b/docs/features/auth/requirements.md index 5f11e5cb4..83905be24 100644 --- a/docs/features/auth/requirements.md +++ b/docs/features/auth/requirements.md @@ -119,6 +119,8 @@ Auth는 보호된 Gateway API가 `auth_token` 쿠키에서 현재 사용자를 - AUTH-REQ-093: Workflow 또는 다른 보호 리소스의 authoritative organization을 알고 있는 direct-fetch consumer는 같은 organization ID를 request body와 `X-Organization-Id`에 명시해야 한다. Ambient active organization과 리소스 organization이 다를 때 token scope가 ambient 값으로 대체되어서는 안 된다. - AUTH-REQ-094: Bootstrap의 `X-Organization-Id`가 128자를 넘거나 제어 문자를 포함하면 token 발급, DB 접근과 cookie 변경 전에 `organization_scope_invalid` bounded reason의 고정 `403 auth.csrf_validation_failed`로 닫아야 한다. - AUTH-REQ-095: Middleware mutation 거부, bootstrap proof/scope 거부와 invalid-session 감사 callback은 하나의 process-shared 전용 capacity limiter를 사용해야 한다. Bootstrap 거부 폭주가 공용 sync worker 또는 DB connection concurrency를 점유해서는 안 된다. +- AUTH-REQ-096: Bootstrap은 요청의 기존 `csrf_token` cookie가 현재 auth/anonymous binding, organization/account scope와 expiry에 유효하면 같은 token을 반환해야 한다. 다른 session/scope, binding kind, 만료 또는 malformed token은 재사용하지 않아야 한다. +- AUTH-REQ-097: `organization_scope_invalid`를 포함한 계약된 CSRF denial reason은 bounded observability allowlist에서 같은 metric/log label로 보존해야 한다. 미등록 입력만 `unknown`으로 축약하고 원문 scope는 기록하지 않아야 한다. ## Policies And Edge Cases diff --git a/docs/features/auth/test_cases.md b/docs/features/auth/test_cases.md index ff215fcf4..dd43dd330 100644 --- a/docs/features/auth/test_cases.md +++ b/docs/features/auth/test_cases.md @@ -155,6 +155,9 @@ Status: Draft | AUTH-TC-CS032 | Wizard mutation의 리소스 organization과 CSRF scope가 일치해야 한다. | LocalStorage는 조직 A지만 Workflow prop은 조직 B인 상태에서 Code/Prompt/Template 요청을 보낸다. | Body와 `X-Organization-Id`가 모두 조직 B이고 bootstrap/token scope도 B. | | AUTH-TC-CS033 | Malformed bootstrap organization scope는 고정 CSRF 오류로 닫혀야 한다. | 129자 scope 또는 제어 문자를 포함한 scope로 bootstrap한다. | Token/cookie/DB effect 0, `organization_scope_invalid` 감사와 fixed 403. | | AUTH-TC-CS034 | Bootstrap과 middleware 거부 감사는 같은 bounded 실행 경계를 사용해야 한다. | 동기 callback을 동시에 limiter 초과 실행하고 bootstrap request thread를 기록한다. | 동시 callback 최대 4, bootstrap 검증 thread와 audit worker thread 분리, 고정 오류 유지. | +| AUTH-TC-CS035 | 같은 session/scope의 여러 탭 bootstrap은 기존 유효 token을 공유해야 한다. | 탭 A의 token cookie가 있는 상태에서 탭 B가 같은 auth cookie와 organization으로 bootstrap한다. | 탭 B body/cookie token과 expiry가 탭 A와 동일하며 탭 A의 mutation이 계속 유효하다. | +| AUTH-TC-CS036 | 기존 token 재사용은 binding/scope/expiry를 우회하지 않아야 한다. | 다른 auth cookie, organization, binding kind와 만료 token으로 bootstrap한다. | 기존 token 미재사용, 새 binding/scope token 발급; 이전 token replay 실패. | +| AUTH-TC-CS037 | Malformed organization scope reason은 관측 경계에서도 보존해야 한다. | `organization_scope_invalid` denial과 미등록 sentinel reason을 각각 기록한다. | 전자는 metric/log에 같은 bounded label, sentinel은 `unknown`; scope 원문 미기록. | ## Component And Hook Tests From 1d7d2298700b296e6f96862e68ada8ef57e29943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Wed, 29 Jul 2026 21:18:59 +0900 Subject: [PATCH 8/8] =?UTF-8?q?test:=20CSRF=20=EA=B4=80=EC=B8=A1=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AA=A8=EB=93=88=EB=AA=85=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../csrf/{test_observability.py => test_csrf_observability.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename apps/gateway/tests/adapters/csrf/{test_observability.py => test_csrf_observability.py} (95%) diff --git a/apps/gateway/tests/adapters/csrf/test_observability.py b/apps/gateway/tests/adapters/csrf/test_csrf_observability.py similarity index 95% rename from apps/gateway/tests/adapters/csrf/test_observability.py rename to apps/gateway/tests/adapters/csrf/test_csrf_observability.py index 329f25c63..9a81016e8 100644 --- a/apps/gateway/tests/adapters/csrf/test_observability.py +++ b/apps/gateway/tests/adapters/csrf/test_csrf_observability.py @@ -17,7 +17,7 @@ def inc(self): self.increment_count += 1 -def test_organization_scope_invalid_is_preserved_and_unknown_stays_bounded( +def test_csrf_organization_scope_invalid_is_preserved_and_unknown_stays_bounded( monkeypatch, caplog, ):