Skip to content

feat: 포트폴리오·블로그 URL 을 면접 자료로 등록 (US-09) - #174

Merged
i3months merged 5 commits into
devfrom
feature/us-09-web-resume-url
Aug 18, 2026
Merged

feat: 포트폴리오·블로그 URL 을 면접 자료로 등록 (US-09)#174
i3months merged 5 commits into
devfrom
feature/us-09-web-resume-url

Conversation

@i3months

Copy link
Copy Markdown
Member

변경 사항

감사 백로그 B-1. AI 서버에 웹 이력서 분석이 이미 완성돼 있었다WebResumeConsumer, WebResumeAnalyzer, WebSourceExtractor(trafilatura + Playwright 렌더 폴백), 큐·바인딩·DLQ(infra/rabbitmq/definitions.json), 페이로드 사양(docs/messaging.md §5.3) 전부. Core 가 analyze.web 을 발행하는 코드와 프론트 진입점만 없어서 US-09 가 반쪽으로 남아 있었다. 그 배선을 채웠다.

포트폴리오·블로그·노션 링크를 등록하면 본문을 읽어 이력서와 똑같이 요약·기술스택 추출·임베딩까지 타고, 면접 질문 풀에 반영된다.

데이터 모델 — resume 도메인 재사용

analyze.web 페이로드가 resumeId 를 쓰고 콜백이 targetType=WEB, targetId=resumeId 라, 새 테이블 없이 resumes 에 WEB 타입을 추가했다 (V24).

  • file_path nullable 전환 (WEB 은 S3 오브젝트가 없다) + source_url VARCHAR(2000) 추가
  • chk_resumes_file_type'WEB' 추가
  • chk_resumes_locator_by_type 신설 — PDF 는 file_path, WEB 은 source_url 이 반드시 있어야 함을 DB 에서 강제

흐름은 PDF 와 대칭이고, 콜백 경로는 한 줄도 안 고쳤다AnalysisCallbackServicecontext.documentId 로 문서를 찾아 resume/repo/coverLetter 중 붙어 있는 쪽에 상태를 반영하는 구조라 그대로 동작한다.

ResumeService.registerWeb
  → WebResumeRegisteredEvent
  → WebResumeAnalysisEventListener
  → AnalysisRequestService.requestWebResumeAnalysis   (AnalyzedDocument 생성, markAnalyzing)
  → AFTER_COMMIT: analyze.web 발행
  → [AI] 본문 추출 → 분석 → 임베딩 → callback.analysis (targetType=WEB)
  → AnalysisCallbackService (무변경)

⚠️ SSRF — 이 기능의 핵심 리스크

사용자가 준 URL 을 AI 서버가 그대로 fetch 한다. AI 컨테이너는 docker 네트워크에서 Core·PostgreSQL·RabbitMQ·MinIO 에 닿고, 배포 호스트에서는 클라우드 메타데이터(169.254.169.254)에도 닿는다. 기존 WebSourceExtractor스킴이 http(s) 인지만 확인했다. 즉 이 기능을 그냥 켜면 http://minio:9000/... 을 "포트폴리오"로 등록해 내부 응답을 요약문으로 돌려받을 수 있다.

두 겹으로 막았다.

1. Core (WebResumeUrlValidator) — 첫 관문

  • http·https 만, user:pass@ 거부(호스트 위장)
  • 호스트를 resolve 해 루프백·사설(RFC1918)·링크로컬·멀티캐스트·와일드카드·IPv6 unique-local(fc00::/7) 차단
  • 이름이 아니라 해석된 주소로 판단 → 사설 IP 로 해석되는 공개 도메인도 막힌다
  • 거부 메시지에 해석된 내부 주소는 노출하지 않는다(토폴로지 추측 방지)

2. AI (analyzer/sources/url_guard.py) — 실질 방어선

Core 검사만으로는 막히지 않는다. DNS rebinding 과 리다이렉트로 우회되기 때문이다. 실제 소켓을 여는 쪽에 같은 검사를 넣었다.

  • follow_redirects=True끄고 홉마다 직접 검증. 자동 추적은 공개 URL → 내부 주소 리다이렉트를 그대로 통과시킨다. 상대 Location 은 절대 URL 로 합친 뒤 검사, 홉 5개 제한
  • Playwright 렌더 폴백은 원본이 아니라 검증을 통과한 최종 URL 로 실행

그 외

  • POST /api/resumes/web { url } → 201. 같은 URL 재등록은 409(임베딩 중복으로 질문이 쏠리는 것 방지)
  • analyze.web 라우팅키 + ai.analyze.web 큐/바인딩/DLQ 를 Core 설정에 추가 — RabbitMQ 토폴로지 정의에는 이미 있었지만 Core 프로퍼티에만 빠져 있었다
  • 프론트: 워크스페이스 이력서 화면에 URL 폼. 파일과 링크는 서버에서도 같은 도메인이라 한 목록에 함께 보여주고, WEB 항목은 원문으로 가는 링크로 렌더

테스트

레이어 신규 내용
backend WebResumeUrlValidatorTest (16) 내부망 7종·비허용 스킴 7종·userInfo·과길이·미해석 호스트 + 사설 IP 로 해석되는 공개 도메인
backend WebResumeRegisterTest (5) URL 저장·S3 미사용·이벤트 발행, 표시명 생성, 중복 409, SSRF 는 DB 조회 전에 차단
ai test_web_url_guard.py (30) 차단 대역 10종·공개 3종·스킴·userinfo·내부주소 + 리다이렉트 4종(내부로 리다이렉트 차단, 공개 추적, 상대 Location, 홉 한도)
frontend WebResumeForm.test.tsx (5) 다듬은 값으로 요청, 빈 입력·비-http(s) 왕복 차단, 재입력 시 에러 해제
frontend ResumeList.test.tsx (3) WEB 링크 렌더 + noopener, PDF 는 파일 크기, 파일·링크 혼재 목록

DNS 해석기는 양쪽 모두 주입 가능하게 만들어 단위 테스트가 네트워크를 타지 않는다. 기존 test_web_extractor.py 도 fake 해석기로 바꿨다(이전엔 example.com 실조회).

로컬 전부 통과: backend cleanTest test (ArchUnit 포함) · ai flake8/black/pytest 351 · frontend eslint 0/tsc/vitest 76/build.

영향 범위

  • DB 마이그레이션: 있음 (V24 — resumes 컬럼 추가 + CHECK 교체. 기존 PDF 행은 무영향)
  • API contract 변경: 추가만 (POST /api/resumes/web, ResumeResponse.sourceUrl)
  • 환경변수: 없음
  • 신규 에러코드: RESUME_INVALID_URL(400), RESUME_URL_DUPLICATE(409)

리뷰어 체크포인트

  • SSRF 정책: 사설 대역 전면 차단이라, 사내망/로컬에 띄운 포트폴리오는 등록할 수 없다. 의도한 트레이드오프
  • resumes 재사용 vs 별도 테이블 — 메시지 사양(resumeId)이 이미 그렇게 정의돼 있어 따랐다
  • WEB 항목의 original_filename 에 host+path 를 표시명으로 넣는다(컬럼이 NOT NULL). 별도 title 컬럼이 낫다면 후속으로
  • 프론트에서 <form noValidate> 로 브라우저 기본 검증을 끈 이유 — type="url" 에 맡기면 제출이 막혀 우리 안내가 안 뜨고 브라우저별로 갈린다

AI 서버에는 웹 이력서 분석이 **이미 완성돼 있었다** — `WebResumeConsumer`,
`WebResumeAnalyzer`, `WebSourceExtractor`(trafilatura + Playwright 렌더 폴백), 큐·DLQ
(`infra/rabbitmq/definitions.json`), 페이로드 사양(`docs/messaging.md §5.3`) 전부.
Core 가 `analyze.web` 을 발행하는 코드와 프론트 진입점만 없어서 US-09 가 반쪽으로 남아 있었다.
이 커밋은 Core 쪽 배선이다.

## 데이터 모델 — resume 도메인 재사용

`analyze.web` 페이로드가 `resumeId` 를 쓰고 콜백이 `targetType=WEB, targetId=resumeId` 라
새 테이블을 만들지 않고 `resumes` 에 WEB 타입을 추가했다(V24).

- `file_path` nullable 로 전환 (WEB 은 S3 오브젝트가 없다)
- `source_url VARCHAR(2000)` 추가
- `chk_resumes_file_type` 에 'WEB' 추가
- `chk_resumes_locator_by_type` 신설 — PDF 는 file_path, WEB 은 source_url 이 반드시
  있어야 한다는 걸 DB 에서 강제

콜백 경로(`AnalysisCallbackService`)는 `context.documentId` 로 문서를 찾고 resume/repo/
coverLetter 중 붙어 있는 쪽에 상태를 반영하는 구조라 **수정 없이 그대로 동작**한다.

## SSRF 가드 (`WebResumeUrlValidator`)

사용자가 준 URL 을 AI 서버가 그대로 fetch 한다. AI 컨테이너는 docker 네트워크에서 Core·PG·
RabbitMQ·MinIO 에 닿고, 배포 호스트에서는 클라우드 메타데이터(169.254.169.254)에도 닿는다.
검증이 없으면 `http://minio:9000/...` 을 "포트폴리오"로 등록해 내부 응답을 요약문으로
돌려받을 수 있다.

- http·https 만 허용, `user:pass@` 거부 (호스트 위장)
- 호스트를 resolve 해 루프백·사설(RFC1918)·링크로컬·멀티캐스트·와일드카드·IPv6
  unique-local(fc00::/7) 전부 차단 — **이름이 아니라 해석된 주소로 판단**하므로
  사설 IP 로 해석되는 공개 도메인도 막힌다
- 거부 메시지에 해석된 내부 주소는 노출하지 않는다(토폴로지 추측 방지)

DNS rebinding 과 리다이렉트로는 우회 가능하다 — 실제 소켓을 여는 AI 서버에도 같은 검사가
있어야 완결되고, 그건 다음 커밋에서 한다.

## 그 외

- `POST /api/resumes/web { url }` → 201. 중복 URL 은 409(임베딩 중복 방지)
- `analyze.web` 라우팅키 + `ai.analyze.web` 큐/바인딩/DLQ 를 Core 설정에 추가
  (토폴로지 정의에는 이미 있었지만 Core 프로퍼티에만 빠져 있었다)
- `ResumeResult`/`ResumeResponse` 에 `sourceUrl` 노출

## 테스트

- `WebResumeUrlValidatorTest` (16) — 내부망 7종·비허용 스킴 7종·userInfo·과길이·미해석
  호스트 + 공개 도메인이 사설 IP 로 해석되는 케이스. DNS 는 주입한 fake 로 해석해
  단위 테스트가 네트워크에 의존하지 않는다
- `WebResumeRegisterTest` (5) — URL 저장·S3 미사용·이벤트 발행, 표시명 생성, 중복 409,
  SSRF 는 DB 조회 전에 차단, 사용자 부재

`./gradlew test` 통과 (ArchUnit 포함).
`WebSourceExtractor` 가 스킴이 http(s) 인지만 확인하고 사용자 URL 을 그대로 가져왔다.
AI 컨테이너는 docker 네트워크에서 Core·PostgreSQL·RabbitMQ·MinIO 에 닿고, 배포 호스트에서는
클라우드 메타데이터(169.254.169.254)에도 닿는다. 즉 `http://minio:9000/...` 을 포트폴리오로
등록하면 내부 응답이 요약문으로 돌아온다.

Core 에도 같은 검증을 넣었지만(`WebResumeUrlValidator`) **거기서 끝내면 막히지 않는다** —
DNS rebinding 과 리다이렉트로 우회되기 때문이다. 실제 소켓을 여는 건 이쪽이라 여기가
마지막 관문이다.

- `analyzer/sources/url_guard.py` 신설 — 스킴·userinfo 검사 + 호스트를 해석해
  루프백·사설·링크로컬·멀티캐스트·예약·와일드카드·IPv6 unique-local 차단.
  **이름이 아니라 해석된 주소로 판단**하므로 사설 IP 로 해석되는 공개 도메인도 막는다
- `follow_redirects=True` → **끄고 홉마다 직접 검증**. 자동 추적은 공개 URL 이 내부
  주소로 리다이렉트하는 경로를 그대로 통과시킨다. 상대 Location 은 절대 URL 로 합친 뒤
  검사하고, 홉 수는 5로 제한(`WEB_TOO_MANY_REDIRECTS`)
- Playwright 렌더 폴백은 원본이 아니라 **검증을 통과한 최종 URL** 로 실행 —
  브라우저가 내부에서 다시 리다이렉트를 타는 경로를 줄인다

DNS 해석기는 주입 가능하게 만들어 단위 테스트가 네트워크를 타지 않는다. 기존
`test_web_extractor.py` 도 fake 해석기를 쓰도록 바꿨다(이전엔 example.com 실조회).

테스트: `test_web_url_guard.py` 신설 (30) — 차단 대역 10종·공개 3종·스킴 5종·userinfo·
내부주소 4종·사설로 해석되는 공개 도메인·해석 실패, 그리고 리다이렉트 4종(내부 주소로
리다이렉트 차단, 공개 리다이렉트 추적, 상대 Location 해석, 루프 한도).
pytest 351건 통과.
워크스페이스 이력서 화면에 URL 등록 폼을 붙였다. 파일과 링크는 서버에서도 같은 resume
도메인이라 한 목록에 함께 보여준다.

- `WebResumeForm` — URL 입력 + 등록. 서버가 최종 판정하지만 빈 입력·비-http(s) 는
  왕복 없이 잡는다. `<form noValidate>` 로 브라우저 기본 검증 버블을 끄고 검증 주체를
  하나로 뒀다 — `type="url"` 에 맡기면 제출이 막혀 우리 한국어 안내가 뜨지 않고
  브라우저별로 문구·동작이 갈린다
- `ResumeList` — WEB 항목은 링크 아이콘 + 원문으로 가는 `<a>`(`rel="noreferrer noopener"`),
  파일 크기 자리에 '웹 링크'. 삭제 확인·aria-label 도 자료 종류에 맞게 분기
- `Resume` 타입: `fileType`에 `'WEB'`, `sourceUrl` 추가, `filePath`·`fileSize` 를
  nullable 로 정정(WEB 은 파일이 없다). `formatFileSize` 도 null 을 받는다

테스트: `WebResumeForm.test.tsx`(5) — 다듬은 값으로 요청, 빈 입력·비-http(s) 왕복 차단,
재입력 시 에러 해제. `ResumeList.test.tsx`(3) — WEB 링크 렌더 + noopener, PDF 는 파일
크기, 파일·링크 혼재 목록. vitest 76/76 · eslint 0 · tsc · build 통과.
- `docs/database.md` — resumes 테이블에 WEB 타입·source_url·타입별 locator CHECK 반영
- `backend/CLAUDE.md` — resume 도메인에 US-09 추가 + 흐름(registerWeb → 이벤트 →
  analyze.web)과 SSRF 가드 경계 기록
- `ai/CLAUDE.md` — analyze.web 의 SSRF 가드(url_guard, 홉별 재검증) 기록.
  Core 검사는 첫 관문이고 소켓을 여는 AI 쪽이 실질 방어선이라는 점을 명시
@i3months
i3months merged commit 366cdd2 into dev Aug 18, 2026
5 checks passed
@i3months
i3months deleted the feature/us-09-web-resume-url branch August 18, 2026 18:24
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