Skip to content

feat: 쿠키 인증 API CSRF 경계 도입 - #651

Merged
HyungminYoon1 merged 8 commits into
devfrom
feature/mba-92
Jul 29, 2026
Merged

feat: 쿠키 인증 API CSRF 경계 도입#651
HyungminYoon1 merged 8 commits into
devfrom
feature/mba-92

Conversation

@HyungminYoon1

Copy link
Copy Markdown
Contributor

변경 사항

  • Cookie 인증을 사용하는 모든 unsafe Gateway route를 cookie_authenticated, pre_auth_session, public_anonymous, server_credential 정책으로 분류하고, 미분류·중복·stale route가 있으면 시작 또는 architecture test에서 실패하도록 했습니다.
  • GET /api/v1/auth/csrf signed double-submit bootstrap과 auth/anonymous binding, organization scope, 10분 expiry, 로그인·회원가입·OAuth·로그아웃 lifecycle을 구현했습니다.
  • Exact Origin, Fetch Metadata, JSON/multipart content type과 header/cookie/signature를 body parsing·DB·queue·provider보다 먼저 검증하는 중앙 CSRF middleware를 추가했습니다.
  • Client token을 module memory에만 보관하고 Axios/direct fetch에 자동 첨부하며, PUT/DELETE 또는 idempotency key가 있는 요청만 CSRF 실패 뒤 최대 한 번 재시도하도록 했습니다.
  • Workflow SSE Next proxy는 API host-only CSRF cookie를 직접 받지 못하므로 엄격히 검증한 header token만 outbound CSRF cookie로 복제하고, Origin·Fetch Metadata·organization context를 Gateway에 전달합니다.
  • Public Chatbot/public run과 Bearer·app-secret·webhook route는 login cookie가 포함돼도 authenticated audience로 승격하지 않는 기존 경계를 유지합니다.
  • ADR-0073과 Auth/Architecture/Chatbot/Memory 공식 문서를 구현 계약에 맞게 갱신했습니다.

도입 이유는 credentialed CORS와 SameSite=None cookie만으로는 cross-site mutation을 차단할 수 없고, 기존 route별 인증 dependency만으로는 login·legacy helper·multipart·Public/server credential 예외를 빠짐없이 통제하기 어렵기 때문입니다.

관련 이슈

변경 유형

  • 버그 수정
  • 새로운 기능
  • 리팩토링
  • 문서 수정
  • 기타

테스트

  • 로컬에서 테스트 완료
  • 기존 테스트 통과 확인

검증 결과:

  • Gateway 관련 pytest: 60개 통과
  • Client 관련 Vitest: 68개 통과
  • Ruff 변경 범위 검사: 통과
  • ESLint 변경 범위 검사: 오류 0, 경고 13
  • Client TypeScript tsc --noEmit: 통과
  • 전체 저장소 회귀와 Client production build는 실행하지 않았으며 원격 CI에 위임합니다.

보호 리소스·외부 실행 경계 (해당 시)

  • 적용 여부: [x] 적용 [ ] 비적용
  • 비적용 사유: 해당 없음
  • 완결성 매트릭스의 계약·구현·검증 증거:
    • 계약: docs/decisions/ADR-0073-cookie-authenticated-api-csrf-boundary.md, docs/features/auth/{requirements,api_spec,component_spec,test_cases}.md
    • Token/lifecycle: apps/gateway/application/csrf/token.py, apps/gateway/tests/application/csrf/test_token_service.py, apps/gateway/tests/api/test_auth_csrf.py
    • Admission/inventory: apps/gateway/middleware/csrf.py, apps/gateway/composition/csrf.py, apps/gateway/tests/middleware/test_csrf_*.py, apps/gateway/tests/architecture/test_csrf_route_inventory.py
    • Client/proxy: apps/client/lib/csrfToken.ts, apps/client/lib/csrfToken.test.ts, apps/client/app/stream-api/workflows/[workflowId]/route.test.ts
  • 저장·GraphMutation과 secret 비저장
  • 관리 API/UI와 user/team 권한 부여·회수
  • Deployment preflight와 runtime/background 재검증
    • CSRF는 browser admission credential이므로 deployment eligibility나 Worker credential이 아닙니다. Gateway에서 queue/provider 이전에 소비하고 raw token을 background payload로 전달하지 않습니다.
  • Lifecycle·TOCTOU·stable identity/deduplication·crash replay·capability/lease
    • Login/signup/OAuth/logout, invalid auth cookie, organization 전환과 expiry lifecycle을 검증했습니다. Stable identity, crash replay와 capability/lease는 이 browser admission 경계에 해당하지 않습니다.
  • Audit event 생성·canonical action/status·중복 방지·secret/PII redaction
  • 적용 동작 경계마다 구현 위치와 실행 가능한 테스트 증거 기록
  • 미완료 항목 또는 후속 이슈: 병합 필수 경계의 미완료 항목은 없습니다. 인증형 내부 Chatbot/Memory의 별도 RBAC·retention 구현은 MBA-387/MBA-388에서 이 공통 CSRF 경계를 상속합니다.

스크린샷 (UI 변경 시)

  • 사용자에게 보이는 화면 변경은 없어 스크린샷은 첨부하지 않았습니다.

@linear-code

linear-code Bot commented Jul 29, 2026

Copy link
Copy Markdown

MBA-92

@HyungminYoon1
HyungminYoon1 marked this pull request as ready for review July 29, 2026 07:55

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2af5bb0398

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +200 to +204
@router.get("/csrf", response_model=CsrfTokenResponse)
def bootstrap_csrf_token(
request: Request,
response: Response,
db: Session = Depends(get_db),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge CSRF bootstrap의 cross-site 쿠키 덮어쓰기를 차단하세요

사용자가 공격자 페이지나 침해된 same-site sibling을 방문한 상태에서는 <img> 같은 cross-origin GET으로 이 엔드포인트를 호출할 수 있고, 응답 본문을 읽지 못하더라도 새 csrf_token Set-Cookie는 적용됩니다. 그러면 클라이언트 메모리에 캐시된 헤더 토큰과 쿠키가 달라져 다음 workflow 저장·로그인 같은 POST/PATCH가 403으로 실패하며, 해당 메서드는 의도적으로 자동 재시도되지 않으므로 반복 호출로 핵심 흐름을 계속 막을 수 있습니다. Bootstrap에서도 Fetch Metadata/허용 Origin을 검사하거나 신뢰되지 않은 요청이 기존 쿠키를 회전시키지 못하게 해야 합니다.

AGENTS.md reference: AGENTS.md:L193-L194

Useful? React with 👍 / 👎.

Comment thread apps/client/lib/csrfToken.ts Outdated
Comment on lines +123 to +127
fetch(`${apiBaseUrl}/auth/csrf`, {
method: 'GET',
headers,
credentials: 'include',
cache: 'no-store',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge CSRF bootstrap과 실제 요청의 origin을 일치시키세요

NEXT_PUBLIC_API_URLhttps://api.example.com처럼 별도 공개 API origin으로 설정하면 token bootstrap은 항상 그 origin으로 전송되어 host-only csrf_token도 API 호스트에 설정되지만, workflowApi, webhookApi, Settings와 Wizard의 보호 요청은 계속 상대 /api/v1로 웹 호스트에 전송됩니다. 따라서 헤더 token은 첨부되어도 해당 호스트에는 CSRF cookie가 없어 모든 workflow 생성·저장·배포 등의 mutation이 403으로 실패합니다. Bootstrap을 실제 대상 요청 origin별로 수행하거나 모든 보호 consumer가 동일한 API base URL을 사용하도록 맞춰야 합니다.

AGENTS.md reference: apps/client/AGENTS.md:L7-L10

Useful? React with 👍 / 👎.

and boundary is not None
and _MULTIPART_BOUNDARY_PATTERN.fullmatch(boundary) is not None
)
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 빈 Axios POST의 content type을 bodyless route에서 허용하세요

Webhook capture 취소는 api.post(..., null, ...)을 사용하며 Axios 1.13은 이 빈 POST에 Content-Type: application/x-www-form-urlencoded를 설정합니다. Route inventory는 이 endpoint를 BODY_OPTIONAL로 분류하지만, 현재 검사는 Content-Type이 존재하면 JSON 또는 multipart만 허용하므로 유효한 CSRF token을 첨부해도 취소 요청이 항상 403으로 종료됩니다. Zero-length BODY_OPTIONAL 요청을 media type 검사 전에 허용하거나 해당 client가 JSON/undefined body를 보내도록 계약을 맞춰야 합니다.

AGENTS.md reference: apps/AGENTS.md:L7-L10

Useful? React with 👍 / 👎.

Comment on lines +212 to +214
except HTTPException as exc:
if exc.status_code != 401:
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 비활성 계정의 stale 인증 쿠키도 정리하세요

AuthService.get_user_from_token은 비활성화된 사용자에 대해 403을 반환하지만 여기서는 401만 invalid session으로 처리하고 403을 그대로 다시 던집니다. 따라서 계정이 비활성화된 뒤 기존 쿠키를 가진 브라우저는 CSRF bootstrap 단계에서 계속 실패하고, logout 요청조차 token을 얻지 못해 인증 쿠키를 지울 수 없으며 Client의 raw bootstrap fetch도 이 403을 로그인 리다이렉트나 anonymous 복구로 처리하지 않습니다. 비활성 상태에서도 실행은 fail-closed로 유지하되 stale auth/CSRF cookie를 삭제해 세션 lifecycle과 logout 경로를 복구해야 합니다.

AGENTS.md reference: AGENTS.md:L140-L144

Useful? React with 👍 / 👎.

@HyungminYoon1

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 04e4fe1d43

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/client/lib/csrfToken.ts Outdated
const error = rawError as AxiosError;
if (!isFixedCsrfFailure(error)) return Promise.reject(rawError);

invalidateCsrfToken();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 동시 CSRF 갱신이 서로를 무효화하지 않게 하세요

만료된 토큰을 사용한 여러 PUT/DELETE 요청이 비슷한 시점에 403을 받으면, 첫 요청이 재시도용 bootstrap을 시작한 뒤 두 번째 응답의 무조건적인 invalidateCsrfToken()이 generation을 다시 올릴 수 있습니다. 그러면 첫 bootstrap은 CSRF token bootstrap was invalidated로 실패하여 replay-safe 요청 중 하나가 복구되지 않습니다. 실패한 토큰이 아직 현재 cache일 때만 무효화하거나 동시 403들이 하나의 refresh generation을 공유하도록 해야 합니다.

AGENTS.md reference: apps/client/AGENTS.md:L11-L11

Useful? React with 👍 / 👎.

Comment thread apps/gateway/middleware/csrf.py Outdated
Comment on lines +182 to +188
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 감사용 request ID를 제한된 형식으로 검증하세요

CSRF 거부 요청의 X-Request-ID에 유효한 CSRF token이나 PII를 넣으면, 현재 검사는 길이 128 이하의 printable 문자열을 그대로 승인하고 이후 record_csrf_denial이 그 값을 audit metadata에 영구 저장합니다. 실제 v1 token도 이 길이 안에 들어오므로 이 변경이 보장하는 token 미기록 경계를 우회할 수 있습니다. UUID 등 제한된 request-ID 형식만 허용하고 나머지는 서버 생성 값으로 교체해야 합니다.

AGENTS.md reference: apps/AGENTS.md:L14-L14

Useful? React with 👍 / 👎.

Comment thread docs/features/auth/api_spec.md Outdated

| 필드 | 타입 | 필수 | 비고 |
| --- | --- | --- | --- |
| GET | `/auth/csrf` | Cookie-authenticated/pre-auth mutation용 10분 signed CSRF token과 host-only HttpOnly cookie를 발급한다. | Safe bootstrap; resource permission 없음 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge 요청 필드 표에서 CSRF 엔드포인트 행을 제거하세요

이 행은 엔드포인트 목록이 아니라 signup 요청 본문 표 안에 들어가 있어 GET이 필드명이고 /auth/csrf가 타입인 것처럼 잘못된 API 계약을 문서화합니다. 같은 행이 login 요청 필드 표와 Google OAuth next query 표에도 반복되어 있으므로 세 곳에서 제거해야 합니다.

AGENTS.md reference: docs/AGENTS.md:L47-L49

Useful? React with 👍 / 👎.

or len(cookie_token) > _MAX_TOKEN_LENGTH
):
return CsrfValidationReason.TOKEN_INVALID
if not hmac.compare_digest(header_token, cookie_token):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 비 ASCII CSRF 토큰을 고정 403으로 거부하세요

클라이언트가 header와 cookie에 같은 비 ASCII 문자열(예: é)을 보내면 Python의 hmac.compare_digest(str, str)TypeError: comparing strings with non-ASCII characters is not supported를 발생시킵니다. 이 예외는 여기서 처리되지 않아 고정 auth.csrf_validation_failed 응답 대신 500이 반환되므로, 비교 전에 토큰 문자 집합을 검증하거나 ASCII bytes로 안전하게 변환한 뒤 비교해야 합니다.

AGENTS.md reference: apps/AGENTS.md:L8-L8

Useful? React with 👍 / 👎.

Comment thread apps/gateway/middleware/csrf.py Outdated
Comment on lines +242 to +249
callback_result = self._on_denied(
reason,
policy,
request.method.upper(),
request_id,
)
if inspect.isawaitable(callback_result):
await callback_result

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge CSRF 거부 audit 저장을 이벤트 루프 밖으로 옮기세요

인터넷에서 인증 없이 호출 가능한 login/signup을 포함해 CSRF 검증에 실패할 때마다 이 async middleware가 동기 callback을 직접 실행하고, 실제 record_csrf_denialrecord_audit를 통해 새 SQLAlchemy session을 열어 PostgreSQL commit이 끝날 때까지 기다립니다. 공격자가 잘못된 Origin이나 누락 token 요청을 반복하면 각 요청의 DB I/O가 Gateway 이벤트 루프를 막아 정상 요청 전체의 처리도 지연될 수 있으므로, callback을 thread pool이나 비동기·bounded 관측 경로로 넘겨야 합니다.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 477d9ce4b5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 123 to 125
const res = await csrfFetch('/api/v1/code-wizard/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 위저드의 CSRF scope를 실제 workflow 조직에 맞추세요

organizationId prop이 localStorage의 active organization과 다른 경우(이 상태는 새 테스트에서도 명시적으로 다룹니다), 이 호출은 body에는 workflow 조직을 넣지만 X-Organization-Id를 생략하므로 csrfFetch가 stale localStorage 조직으로 bootstrap과 mutation header를 채웁니다. 그 결과 조직 B의 credential로 외부 LLM을 호출하면서 CSRF token은 조직 A에 결박되어 ADR의 organization replay 경계가 실제 소비 조직을 보호하지 못합니다. Code/Prompt/Template 위저드 모두 resolvedOrganizationIdX-Organization-Id에도 명시해 body, token scope와 Gateway 요청 문맥을 일치시켜야 합니다.

AGENTS.md reference: apps/client/AGENTS.md:L7-L10

Useful? React with 👍 / 👎.

Comment thread apps/gateway/api/v1/endpoints/auth.py Outdated
Comment on lines +294 to +298
except ValueError:
raise HTTPException(
status_code=400,
detail="Invalid CSRF request context",
) from None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 잘못된 scope도 고정 CSRF 오류로 닫으세요

X-Organization-Id가 128자를 넘거나 제어 문자를 포함하면 _normalize_scopeValueError를 내고 이 분기에서 400 {"detail":"Invalid CSRF request context"}가 반환됩니다. 그러나 같은 변경의 requirements/API 계약은 scope 검증을 포함한 모든 CSRF 거부를 403 auth.csrf_validation_failed 고정 envelope과 bounded denial reason으로 규정하므로, 이 입력만 Client 오류 판별과 CSRF 관측 경계를 우회합니다. 잘못된 scope를 token 발급 전에 CSRF denial reason으로 변환해 다른 검증 실패와 동일한 고정 응답·감사 경로로 처리해야 합니다.

AGENTS.md reference: docs/AGENTS.md:L47-L49

Useful? React with 👍 / 👎.

Comment thread apps/gateway/api/v1/endpoints/auth.py Outdated
Comment on lines +221 to +225
try:
record_csrf_bootstrap_denial(
bootstrap_denial,
request_id=getattr(request.state, "request_id", None),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bootstrap 거부 감사에도 bounded 경계를 적용하세요

Custom header가 없는 ambient cross-site GET /auth/csrf도 이 분기에 도달하며, 공격자는 응답을 읽지 못해도 cache-busting URL로 요청을 반복할 수 있습니다. 이때마다 record_csrf_bootstrap_denial이 독립 SQLAlchemy session을 열어 PostgreSQL outbox commit을 완료할 때까지 기다리는데, middleware 거부 경로와 달리 전용 CapacityLimiter를 거치지 않고 FastAPI의 공용 sync worker를 사용하므로 반복 GET이 DB connection과 worker를 점유해 정상 로그인·workflow 요청을 지연시킬 수 있습니다. Bootstrap 거부 감사도 같은 bounded telemetry 실행 경계로 보내야 합니다.

AGENTS.md reference: AGENTS.md:L193-L198

Useful? React with 👍 / 👎.

@HyungminYoon1

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 32dc308417

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +29 to +30
_REASONS = frozenset(
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge organization scope 거부 reason을 보존하세요

잘못된 X-Organization-Id로 CSRF bootstrap이 거부되는 경우 record_csrf_bootstrap_denial()organization_scope_invalid를 전달하지만, 이 allowlist에 해당 값이 없어 metric과 auth.csrf_denied 로그가 모두 unknown으로 기록됩니다. Accepted ADR과 Auth 계약이 이 값을 bounded reason으로 요구하므로, malformed scope 공격이나 클라이언트 오류를 다른 미등록 오류와 구분해 모니터링할 수 없습니다. _REASONSorganization_scope_invalid를 추가하고 실제 metric label을 검증해야 합니다.

AGENTS.md reference: AGENTS.md:L55-L64

Useful? React with 👍 / 👎.

Comment thread apps/gateway/api/v1/endpoints/auth.py Outdated
Comment on lines +319 to +323
issued = token_service.issue(
binding_kind=binding_kind,
binding_secret=binding_secret,
organization_scope=organization_scope,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 다른 탭의 bootstrap으로 기존 토큰을 무효화하지 마세요

같은 사용자·origin에서 탭 A가 토큰을 메모리에 보관한 뒤 탭 B가 최초 mutation이나 만료 갱신을 수행하면, 이 코드는 매번 새 nonce의 토큰을 발급해 브라우저 전체가 공유하는 csrf_token cookie를 덮어씁니다. 탭 A는 계속 이전 메모리 토큰을 header로 보내므로 header/cookie mismatch로 403을 받고, 일반 workflow 저장·실행 같은 POST/PATCH는 자동 재시도도 하지 않아 정상 작업이 실패합니다. 기존 cookie가 같은 session/scope에 유효하면 재사용하거나 탭 간 발급을 조정해 공유 cookie와 탭별 메모리가 어긋나지 않게 해야 합니다.

AGENTS.md reference: AGENTS.md:L191-L198

Useful? React with 👍 / 👎.

@HyungminYoon1

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 1d7d229870

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@HyungminYoon1
HyungminYoon1 merged commit 457c8a6 into dev Jul 29, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant