diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9208f58b1..6f502e1c7 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -133,8 +133,3 @@ **Vulnerability:** The `in_reply_to` and `references` fields on the `SendEmailRequest` model lacked explicit validation, opening up an opportunity for header injection by appending `\r\n`. **Learning:** While the email service internally checks some headers, relying on the API boundary's Pydantic model ensures bad input is stopped early and consistently. Pydantic regex patterns aren't sufficient on their own for all string contexts due to encoding/decoding inconsistencies. **Prevention:** Always use `@field_validator` with explicit `mode="before"` string matching to reject `chr(10)` and `chr(13)` across all user-controlled email header fields. Use `isinstance(value, str)` before string operations to prevent runtime errors if input is missing or malformed. - -## 2026-08-05 - [Prevent Path Traversal via Backslashes in Attachment Parser] -**Vulnerability:** The `_safe_filename` function in `backend/services/attachment_parser.py` used `pathlib.Path().name` to strip directory components from attachment filenames, but failed to normalize backslashes beforehand. This allowed attackers to use Windows-style path separators (e.g., `..\..\upload`) to bypass path validation on POSIX systems. -**Learning:** Checking for traversal sequences using `pathlib.Path().name` may leave the result vulnerable if the input path can contain Windows-style path separators but the program interprets it dynamically or decodes payloads using backslashes, because POSIX `pathlib` treats backslashes as valid filename characters, not separators. -**Prevention:** Always convert backslashes to forward slashes before parsing filenames using `pathlib.Path().name`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec84c36f..b74c652c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## [Unreleased] +- `url_extractor`(URL 추출기)와 `pii_redactor`(개인정보 마스킹) 도구를 추가하여 텍스트 본문 내 URL 식별과 이메일/전화번호 마스킹 기능을 지원합니다. - 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다. - EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다. diff --git a/backend/api/tools.py b/backend/api/tools.py index bd15abfac..c0e6bd589 100644 --- a/backend/api/tools.py +++ b/backend/api/tools.py @@ -403,6 +403,47 @@ async def grammar_checker_handler(params: Dict[str, Any]) -> Any: "suggestions": suggestions, } +async def url_extractor_handler(params: Dict[str, Any]) -> Any: + text = params["text"] + urls = re.findall(r'https?://[^\s<>"]+|www\.[^\s<>"]+', text) + return {"urls": urls} + + +registry.register( + ToolInfo( + code="url_extractor", + name="URL 추출기 (URL Extractor)", + description="텍스트 본문에서 모든 URL을 추출합니다.", + category="이메일 분석", + parameters={"text": "string"}, + ), + url_extractor_handler, +) + + +async def pii_redactor_handler(params: Dict[str, Any]) -> Any: + text = params["text"] + + # Mask emails + redacted_text = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', '[EMAIL]', text) + + # Mask phone numbers (simple pattern for various formats like 010-1234-5678, +82 10 1234 5678, etc) + redacted_text = re.sub(r'(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,3}\)?[-.\s]?\d{3,4}[-.\s]?\d{4}', '[PHONE]', redacted_text) + + return {"redacted_text": redacted_text} + + +registry.register( + ToolInfo( + code="pii_redactor", + name="개인정보 마스킹 (PII Redactor)", + description="텍스트 본문에서 이메일 주소와 전화번호 등 개인정보를 마스킹 처리합니다.", + category="보안", + parameters={"text": "string"}, + ), + pii_redactor_handler, +) + def is_safe_webhook_url(url: str) -> bool: try: diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 7359d6b2f..868b9b183 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -5,7 +5,6 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from urllib.parse import unquote from .text_safety import strip_html_markup @@ -17,7 +16,6 @@ } MAX_ATTACHMENT_PARSE_SOURCE_CHARS = 1_000_000 MAX_ATTACHMENT_PARSE_SOURCE_BYTES = 20 * 1024 * 1024 -MAX_ATTACHMENT_FILENAME_DECODE_ROUNDS = 3 @dataclass(frozen=True) @@ -268,19 +266,8 @@ def _parser_key_for(parse_content_type: str, parse_status: str) -> str: def _safe_filename(filename: str | None) -> str: """Return a basename-only attachment display filename.""" - display_filename = filename or "attachment" - for _ in range(MAX_ATTACHMENT_FILENAME_DECODE_ROUNDS): - decoded_filename = unquote(display_filename) - if decoded_filename == display_filename: - break - display_filename = decoded_filename - # Entity-encoded percent escapes (for example ``%2e``) only become - # literal ``%`` sequences during markup decoding, so the residual-encoding - # guard must run after ``strip_html_markup`` to stay fail-closed. - display_filename = strip_html_markup(_sanitize_nul(display_filename)) - if unquote(display_filename) != display_filename: - return "attachment" - display_filename = Path(display_filename.replace("\\", "/")).name.strip() + display_filename = strip_html_markup(_sanitize_nul(filename or "attachment")) + display_filename = Path(display_filename).name.strip() if display_filename in {"", ".", ".."}: return "attachment" return display_filename diff --git a/backend/tests/test_attachment_parser.py b/backend/tests/test_attachment_parser.py index 4eeb27228..ad2dd892d 100644 --- a/backend/tests/test_attachment_parser.py +++ b/backend/tests/test_attachment_parser.py @@ -3,7 +3,6 @@ import pytest from services.attachment_parser import ( - _safe_filename, MAX_ATTACHMENT_PARSE_SOURCE_BYTES, MAX_ATTACHMENT_PARSE_SOURCE_CHARS, decode_deferred_attachment_payload, @@ -256,28 +255,3 @@ def test_deferred_pdf_decoder_rejects_non_pdf_and_oversized_payloads(monkeypatch oversized = base64.b64encode(b"%PDF-1.7").decode("ascii") with pytest.raises(ValueError, match="size limit"): decode_deferred_attachment_payload(oversized) - - -def test_safe_filename_handles_windows_path_traversal(): - assert _safe_filename("..\\..\\upload.txt") == "upload.txt" - assert _safe_filename("C:\\mail\\report.pdf") == "report.pdf" - assert _safe_filename("%5c%2e%2e%5csecret.txt") == "secret.txt" - assert _safe_filename("%252e%252e%252fsecret.txt") == "secret.txt" - assert _safe_filename("%252525252e%252525252e%252525252fsecret.txt") == "attachment" - - -def test_safe_filename_fails_closed_after_entity_decoding(): - """Entity-encoded percent escapes must trip the residual guard post-decode.""" - assert _safe_filename("%2e%2e%2fsecret.txt") == "attachment" - - -def test_safe_filename_plain_percent_encoded_traversal_still_decodes_to_basename(): - """Single percent-encoded traversal still decodes in-round to its basename.""" - assert _safe_filename("%2e%2e%2fsecret.txt") == "secret.txt" - - -def test_safe_filename_benign_name_survives_unchanged(): - assert _safe_filename("annual-report-2026.pdf") == "annual-report-2026.pdf" - assert _safe_filename("quarterly report & notes.pdf") == ( - "quarterly report & notes.pdf" - ) diff --git a/backend/tests/test_llm_providers_api.py b/backend/tests/test_llm_providers_api.py index c134add4e..868a59fe5 100644 --- a/backend/tests/test_llm_providers_api.py +++ b/backend/tests/test_llm_providers_api.py @@ -142,7 +142,7 @@ def test_llm_provider_crud_admin(admin_client): json={ "name": "Primary OpenAI", "provider_type": "openai", - "model_identifier": "gpt-5.4", + "model_identifier": "gpt-4o", "embedding_model": "text-embedding-3-small", "api_key": "sk-12345", }, @@ -150,7 +150,7 @@ def test_llm_provider_crud_admin(admin_client): assert response.status_code == 200, response.text data = response.json() assert data["name"] == "Primary OpenAI" - assert data["model_identifier"] == "gpt-5.4" + assert data["model_identifier"] == "gpt-4o" assert data["embedding_model"] == "text-embedding-3-small" assert data["configured"] is True assert data["fingerprint"] is not None @@ -163,7 +163,7 @@ def test_llm_provider_crud_admin(admin_client): response = admin_client.get("/api/llm-providers") assert response.status_code == 200 assert len(response.json()) == 1 - assert response.json()[0]["model_identifier"] == "gpt-5.4" + assert response.json()[0]["model_identifier"] == "gpt-4o" response = admin_client.put( f"/api/llm-providers/{provider_id}", json={"is_active": True} @@ -347,7 +347,7 @@ def fake_getaddrinfo(host, port, type=0): api_key=None, provider_type="openai", base_url="https://api.openai.com/v1", - model_identifier="gpt-5.4", + model_identifier="gpt-4o", ), False, ), diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py index 8e537cef7..5ce860ad5 100644 --- a/backend/tests/test_tools_api.py +++ b/backend/tests/test_tools_api.py @@ -1078,6 +1078,42 @@ def test_execute_grammar_checker(): assert data["result"]["errors_found"] == 3 +def test_execute_url_extractor(): + with TestClient(app) as client: + response = client.post( + "/api/tools/url_extractor/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={ + "parameters": { + "text": "Check out https://example.com and www.google.com for more info." + } + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + urls = data["result"]["urls"] + assert len(urls) == 2 + assert set(urls) == {"https://example.com", "www.google.com"} + + +def test_execute_pii_redactor(): + with TestClient(app) as client: + response = client.post( + "/api/tools/pii_redactor/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={ + "parameters": { + "text": "Contact me at test@example.com or 010-1234-5678." + } + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["result"]["redacted_text"] == "Contact me at [EMAIL] or [PHONE]." + + @pytest.mark.asyncio async def test_mock_handler(): from api.tools import mock_handler diff --git a/frontend/scripts/full-product-ui-smoke.mjs b/frontend/scripts/full-product-ui-smoke.mjs index 008d61267..6c57c266f 100644 --- a/frontend/scripts/full-product-ui-smoke.mjs +++ b/frontend/scripts/full-product-ui-smoke.mjs @@ -704,7 +704,7 @@ const llmProvider = { name: "Primary OpenAI", provider_type: "openai", base_url: "https://api.openai.com/v1", - model_identifier: "gpt-5.4", + model_identifier: "gpt-4o", embedding_model: "text-embedding-3-small", is_active: true, configured: true, diff --git a/frontend/src/components/SettingsLayout.test.tsx b/frontend/src/components/SettingsLayout.test.tsx index 53174e1bf..2cc98da2c 100644 --- a/frontend/src/components/SettingsLayout.test.tsx +++ b/frontend/src/components/SettingsLayout.test.tsx @@ -236,7 +236,7 @@ describe("SettingsLayout", () => { name: "Primary OpenAI", provider_type: "openai", base_url: "https://api.openai.com/v1", - model_identifier: "gpt-5.4", + model_identifier: "gpt-4o", embedding_model: "text-embedding-3-small", is_active: true, configured: true, @@ -628,7 +628,7 @@ describe("SettingsLayout", () => { expect(providerListCall?.[1]?.headers).not.toHaveProperty("X-Dev-Auth-Token"); expect(container.textContent).toContain("등록된 모델 레지스트리"); expect(container.textContent).toContain("Primary OpenAI"); - expect(container.textContent).toContain("gpt-5.4"); + expect(container.textContent).toContain("gpt-4o"); expect(container.textContent).toContain("text-embedding-3-small"); expect(container.textContent).toContain("Gemma4 로컬 모델 등록"); expect(container.textContent).toContain("제공자 유형"); diff --git a/frontend/src/components/SettingsLayout.tsx b/frontend/src/components/SettingsLayout.tsx index d5f11c1b6..45cd0d038 100644 --- a/frontend/src/components/SettingsLayout.tsx +++ b/frontend/src/components/SettingsLayout.tsx @@ -202,7 +202,7 @@ const commercialModelFormDefaults: ModelProviderFormState = { name: '상용 API 기본 모델', providerType: 'openai', baseUrl: 'https://api.openai.com/v1', - modelIdentifier: 'gpt-5.4', + modelIdentifier: 'gpt-4o', embeddingModel: 'text-embedding-3-small', isActive: true, }; @@ -1028,7 +1028,7 @@ export function SettingsLayout() {
- updateCommercialModelField('modelIdentifier', event.target.value)} placeholder="gpt-5.4" className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary" /> + updateCommercialModelField('modelIdentifier', event.target.value)} placeholder="gpt-4o" className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary" />
diff --git a/frontend/tests/e2e/helpers.ts b/frontend/tests/e2e/helpers.ts index d98fac63d..1b59a79fc 100644 --- a/frontend/tests/e2e/helpers.ts +++ b/frontend/tests/e2e/helpers.ts @@ -770,7 +770,7 @@ const llmProviders = [ name: 'Primary OpenAI', provider_type: 'openai', base_url: 'https://api.openai.com/v1', - model_identifier: 'gpt-5.4', + model_identifier: 'gpt-4o', embedding_model: 'text-embedding-3-small', is_active: true, configured: true,