feat: 포트폴리오·블로그 URL 을 면접 자료로 등록 (US-09) - #174
Merged
Merged
Conversation
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 쪽이 실질 방어선이라는 점을 명시
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
변경 사항
감사 백로그 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_pathnullable 전환 (WEB 은 S3 오브젝트가 없다) +source_url VARCHAR(2000)추가chk_resumes_file_type에'WEB'추가chk_resumes_locator_by_type신설 — PDF 는file_path, WEB 은source_url이 반드시 있어야 함을 DB 에서 강제흐름은 PDF 와 대칭이고, 콜백 경로는 한 줄도 안 고쳤다 —
AnalysisCallbackService가context.documentId로 문서를 찾아 resume/repo/coverLetter 중 붙어 있는 쪽에 상태를 반영하는 구조라 그대로 동작한다.사용자가 준 URL 을 AI 서버가 그대로 fetch 한다. AI 컨테이너는 docker 네트워크에서 Core·PostgreSQL·RabbitMQ·MinIO 에 닿고, 배포 호스트에서는 클라우드 메타데이터(169.254.169.254)에도 닿는다. 기존
WebSourceExtractor는 스킴이 http(s) 인지만 확인했다. 즉 이 기능을 그냥 켜면http://minio:9000/...을 "포트폴리오"로 등록해 내부 응답을 요약문으로 돌려받을 수 있다.두 겹으로 막았다.
1. Core (
WebResumeUrlValidator) — 첫 관문user:pass@거부(호스트 위장)2. AI (
analyzer/sources/url_guard.py) — 실질 방어선Core 검사만으로는 막히지 않는다. DNS rebinding 과 리다이렉트로 우회되기 때문이다. 실제 소켓을 여는 쪽에 같은 검사를 넣었다.
follow_redirects=True→ 끄고 홉마다 직접 검증. 자동 추적은 공개 URL → 내부 주소 리다이렉트를 그대로 통과시킨다. 상대Location은 절대 URL 로 합친 뒤 검사, 홉 5개 제한그 외
POST /api/resumes/web { url }→ 201. 같은 URL 재등록은 409(임베딩 중복으로 질문이 쏠리는 것 방지)analyze.web라우팅키 +ai.analyze.web큐/바인딩/DLQ 를 Core 설정에 추가 — RabbitMQ 토폴로지 정의에는 이미 있었지만 Core 프로퍼티에만 빠져 있었다테스트
WebResumeUrlValidatorTest(16)WebResumeRegisterTest(5)test_web_url_guard.py(30)WebResumeForm.test.tsx(5)ResumeList.test.tsx(3)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.영향 범위
resumes컬럼 추가 + CHECK 교체. 기존 PDF 행은 무영향)POST /api/resumes/web,ResumeResponse.sourceUrl)RESUME_INVALID_URL(400),RESUME_URL_DUPLICATE(409)리뷰어 체크포인트
resumes재사용 vs 별도 테이블 — 메시지 사양(resumeId)이 이미 그렇게 정의돼 있어 따랐다original_filename에 host+path 를 표시명으로 넣는다(컬럼이 NOT NULL). 별도 title 컬럼이 낫다면 후속으로<form noValidate>로 브라우저 기본 검증을 끈 이유 —type="url"에 맡기면 제출이 막혀 우리 안내가 안 뜨고 브라우저별로 갈린다