Skip to content

Commit 03c4ff6

Browse files
committed
Harden security audit findings
1 parent 3117a74 commit 03c4ff6

5 files changed

Lines changed: 393 additions & 43 deletions

File tree

query_intelligence/api/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def analyze(payload: AnalyzeRequest) -> dict:
3939

4040
@app.post("/retrieval/search")
4141
def retrieval(payload: RetrievalRequest) -> dict:
42-
return runtime.retrieve_evidence(payload.nlu_result, top_k=payload.top_k, debug=payload.debug)
42+
return runtime.retrieve_evidence(payload.nlu_result.model_dump(mode="json"), top_k=payload.top_k, debug=payload.debug)
4343

4444
@app.post("/query/intelligence", response_model=PipelineResponse)
4545
def pipeline(payload: PipelineRequest) -> dict:

query_intelligence/contracts.py

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
from __future__ import annotations
22

3-
from typing import Any, Literal
3+
from typing import Annotated, Any, Literal
44

55
from pydantic import BaseModel, Field
66

77

88
MIN_RETRIEVAL_TOP_K = 1
99
MAX_RETRIEVAL_TOP_K = 100
10+
MAX_QUERY_LENGTH = 2000
11+
MAX_NLU_LIST_ITEMS = 64
12+
MAX_NLU_ENTITIES = 32
13+
MAX_NLU_TEXT_LENGTH = 256
14+
MAX_USER_PROFILE_FIELDS = 64
15+
MAX_DIALOG_CONTEXT_ITEMS = 32
16+
17+
ShortText = Annotated[str, Field(max_length=MAX_NLU_TEXT_LENGTH)]
1018

1119
QuestionStyle = Literal["fact", "why", "compare", "advice", "forecast"]
1220
TimeScope = Literal[
@@ -22,7 +30,7 @@
2230

2331

2432
class LabelScore(BaseModel):
25-
label: str
33+
label: ShortText
2634
score: float = Field(ge=0.0, le=1.0)
2735

2836

@@ -31,40 +39,40 @@ class ProductPrediction(LabelScore):
3139

3240

3341
class EntityMatch(BaseModel):
34-
mention: str
35-
entity_type: str
42+
mention: ShortText
43+
entity_type: ShortText
3644
confidence: float = Field(ge=0.0, le=1.0)
37-
match_type: str
45+
match_type: ShortText
3846
entity_id: int | None = None
39-
canonical_name: str | None = None
40-
symbol: str | None = None
41-
exchange: str | None = None
47+
canonical_name: ShortText | None = None
48+
symbol: ShortText | None = None
49+
exchange: ShortText | None = None
4250

4351

4452
class Explainability(BaseModel):
45-
matched_rules: list[str] = Field(default_factory=list)
46-
top_features: list[str] = Field(default_factory=list)
53+
matched_rules: list[ShortText] = Field(default_factory=list, max_length=MAX_NLU_LIST_ITEMS)
54+
top_features: list[ShortText] = Field(default_factory=list, max_length=MAX_NLU_LIST_ITEMS)
4755

4856

4957
class NLUResult(BaseModel):
50-
query_id: str
51-
raw_query: str
52-
normalized_query: str
58+
query_id: ShortText
59+
raw_query: str = Field(max_length=MAX_QUERY_LENGTH)
60+
normalized_query: str = Field(max_length=MAX_QUERY_LENGTH)
5361
question_style: QuestionStyle
5462
product_type: ProductPrediction
55-
intent_labels: list[LabelScore]
56-
topic_labels: list[LabelScore]
57-
entities: list[EntityMatch]
58-
comparison_targets: list[str] = Field(default_factory=list)
59-
keywords: list[str] = Field(default_factory=list)
63+
intent_labels: list[LabelScore] = Field(max_length=MAX_NLU_LIST_ITEMS)
64+
topic_labels: list[LabelScore] = Field(max_length=MAX_NLU_LIST_ITEMS)
65+
entities: list[EntityMatch] = Field(max_length=MAX_NLU_ENTITIES)
66+
comparison_targets: list[ShortText] = Field(default_factory=list, max_length=MAX_NLU_LIST_ITEMS)
67+
keywords: list[ShortText] = Field(default_factory=list, max_length=MAX_NLU_LIST_ITEMS)
6068
time_scope: TimeScope
61-
forecast_horizon: str
62-
sentiment_of_user: str
69+
forecast_horizon: ShortText
70+
sentiment_of_user: ShortText
6371
operation_preference: ActionType
64-
required_evidence_types: list[str] = Field(default_factory=list)
65-
source_plan: list[str] = Field(default_factory=list)
66-
risk_flags: list[str] = Field(default_factory=list)
67-
missing_slots: list[str] = Field(default_factory=list)
72+
required_evidence_types: list[ShortText] = Field(default_factory=list, max_length=MAX_NLU_LIST_ITEMS)
73+
source_plan: list[ShortText] = Field(default_factory=list, max_length=MAX_NLU_LIST_ITEMS)
74+
risk_flags: list[ShortText] = Field(default_factory=list, max_length=MAX_NLU_LIST_ITEMS)
75+
missing_slots: list[ShortText] = Field(default_factory=list, max_length=MAX_NLU_LIST_ITEMS)
6876
confidence: float = Field(ge=0.0, le=1.0)
6977
explainability: Explainability
7078

@@ -134,22 +142,22 @@ class RetrievalResult(BaseModel):
134142

135143

136144
class AnalyzeRequest(BaseModel):
137-
query: str
138-
user_profile: dict[str, Any] = Field(default_factory=dict)
139-
dialog_context: list[dict[str, Any]] = Field(default_factory=list)
145+
query: str = Field(min_length=1, max_length=MAX_QUERY_LENGTH)
146+
user_profile: dict[str, Any] = Field(default_factory=dict, max_length=MAX_USER_PROFILE_FIELDS)
147+
dialog_context: list[dict[str, Any]] = Field(default_factory=list, max_length=MAX_DIALOG_CONTEXT_ITEMS)
140148
debug: bool = False
141149

142150

143151
class RetrievalRequest(BaseModel):
144-
nlu_result: dict[str, Any]
152+
nlu_result: NLUResult
145153
top_k: int = Field(default=20, ge=MIN_RETRIEVAL_TOP_K, le=MAX_RETRIEVAL_TOP_K)
146154
debug: bool = False
147155

148156

149157
class PipelineRequest(BaseModel):
150-
query: str
151-
user_profile: dict[str, Any] = Field(default_factory=dict)
152-
dialog_context: list[dict[str, Any]] = Field(default_factory=list)
158+
query: str = Field(min_length=1, max_length=MAX_QUERY_LENGTH)
159+
user_profile: dict[str, Any] = Field(default_factory=dict, max_length=MAX_USER_PROFILE_FIELDS)
160+
dialog_context: list[dict[str, Any]] = Field(default_factory=list, max_length=MAX_DIALOG_CONTEXT_ITEMS)
153161
top_k: int = Field(default=20, ge=MIN_RETRIEVAL_TOP_K, le=MAX_RETRIEVAL_TOP_K)
154162
debug: bool = False
155163

@@ -160,8 +168,8 @@ class PipelineResponse(BaseModel):
160168

161169

162170
class ArtifactRequest(PipelineRequest):
163-
session_id: str | None = None
164-
message_id: str | None = None
171+
session_id: ShortText | None = None
172+
message_id: ShortText | None = None
165173

166174

167175
class ArtifactPaths(BaseModel):

query_intelligence/external_data/sync.py

Lines changed: 137 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import shutil
4+
import stat
35
import subprocess
46
import zipfile
57
from pathlib import Path
@@ -14,6 +16,11 @@
1416

1517
from .models import DatasetSource, SyncResult
1618

19+
MAX_HTTP_DOWNLOAD_BYTES = 5 * 1024 * 1024 * 1024
20+
MAX_ARCHIVE_MEMBERS = 100_000
21+
MAX_ARCHIVE_UNCOMPRESSED_BYTES = 5 * 1024 * 1024 * 1024
22+
SUBPROCESS_TIMEOUT_SECONDS = 900
23+
1724
_SCRIPT_BACKED_DOWNLOADS: dict[str, tuple[str, ...]] = {
1825
"msra_ner": (
1926
"https://raw.githubusercontent.com/OYE93/Chinese-NLP-Corpus/master/NER/MSRA/msra_train_bio.txt",
@@ -100,31 +107,67 @@ def download_github_repo(repo_url: str, target_dir: Path, *, branch: str = "main
100107
subprocess.run(
101108
["git", "clone", "--depth", "1", "--branch", branch, repo_url, str(target_dir)],
102109
check=True,
110+
timeout=SUBPROCESS_TIMEOUT_SECONDS,
103111
)
104112
return target_dir
105113

106114

107-
def download_http_file(url: str, target_dir: Path, filename: str = "download.bin") -> Path:
115+
def download_http_file(
116+
url: str,
117+
target_dir: Path,
118+
filename: str = "download.bin",
119+
*,
120+
max_bytes: int = MAX_HTTP_DOWNLOAD_BYTES,
121+
) -> Path:
108122
target_dir.mkdir(parents=True, exist_ok=True)
109123
response = requests.get(url, timeout=120, stream=True)
110124
response.raise_for_status()
111125
if filename == "download.bin":
112126
candidate = Path(unquote(urlparse(url).path)).name
113127
if candidate:
114128
filename = candidate
115-
output_path = target_dir / filename
129+
output_path = _safe_child_path(target_dir, filename)
130+
declared_length = response.headers.get("Content-Length")
131+
if declared_length:
132+
try:
133+
content_length = int(declared_length)
134+
except ValueError as exc:
135+
raise ValueError(f"invalid Content-Length for {url}: {declared_length!r}") from exc
136+
if content_length > max_bytes:
137+
raise ValueError(f"download too large: {content_length} bytes exceeds limit {max_bytes}")
138+
tmp_path = output_path.with_name(f".{output_path.name}.part")
139+
bytes_written = 0
116140
try:
117-
with output_path.open("wb") as handle:
141+
with tmp_path.open("wb") as handle:
118142
for chunk in response.iter_content(chunk_size=1024 * 1024):
119143
if chunk:
144+
bytes_written += len(chunk)
145+
if bytes_written > max_bytes:
146+
raise ValueError(f"download too large: exceeded limit {max_bytes} bytes")
120147
handle.write(chunk)
148+
tmp_path.replace(output_path)
121149
finally:
150+
if tmp_path.exists():
151+
tmp_path.unlink()
122152
close = getattr(response, "close", None)
123153
if callable(close):
124154
close()
125155
return output_path
126156

127157

158+
def _safe_child_path(root: Path, filename: str) -> Path:
159+
if not filename:
160+
raise ValueError("download filename must not be empty")
161+
name = Path(filename).name
162+
if name != filename or name in {".", ".."}:
163+
raise ValueError(f"unsafe download filename: {filename!r}")
164+
resolved_root = root.resolve()
165+
output_path = (root / name).resolve()
166+
if not output_path.is_relative_to(resolved_root):
167+
raise ValueError(f"unsafe download filename: {filename!r}")
168+
return output_path
169+
170+
128171
def _materialize_script_backed_dataset(source_id: str, target_dir: Path) -> None:
129172
urls = _SCRIPT_BACKED_DOWNLOADS.get(source_id)
130173
if not urls:
@@ -168,21 +211,109 @@ def _allow_patterns_for_source(source_id: str) -> list[str] | None:
168211
return None
169212

170213

214+
def _safe_archive_target(extract_dir: Path, member_name: str) -> Path:
215+
normalized_name = member_name.replace("\\", "/")
216+
member_path = Path(normalized_name)
217+
if member_path.is_absolute() or ".." in member_path.parts:
218+
raise ValueError(f"Blocked unsafe archive member path: {member_name!r}")
219+
resolved_root = extract_dir.resolve()
220+
target = (extract_dir / member_path).resolve()
221+
if not target.is_relative_to(resolved_root):
222+
raise ValueError(f"Blocked unsafe archive member path: {member_name!r}")
223+
return target
224+
225+
226+
def _validate_archive_limits(file_count: int, total_size: int) -> None:
227+
if file_count > MAX_ARCHIVE_MEMBERS:
228+
raise ValueError(f"archive has too many members: {file_count} > {MAX_ARCHIVE_MEMBERS}")
229+
if total_size > MAX_ARCHIVE_UNCOMPRESSED_BYTES:
230+
raise ValueError(
231+
f"archive expands to too many bytes: {total_size} > {MAX_ARCHIVE_UNCOMPRESSED_BYTES}"
232+
)
233+
234+
235+
def _safe_zip_extractall(archive: zipfile.ZipFile, extract_dir: Path) -> None:
236+
"""Extract a zip archive after validating paths, symlinks, count, and size."""
237+
members = archive.infolist()
238+
total_size = 0
239+
safe_targets: list[tuple[zipfile.ZipInfo, Path]] = []
240+
for member in members:
241+
mode = member.external_attr >> 16
242+
if stat.S_ISLNK(mode):
243+
raise ValueError(f"Blocked unsafe zip symlink member: {member.filename!r}")
244+
total_size += member.file_size
245+
safe_targets.append((member, _safe_archive_target(extract_dir, member.filename)))
246+
_validate_archive_limits(len(members), total_size)
247+
248+
for member, target in safe_targets:
249+
if member.is_dir():
250+
target.mkdir(parents=True, exist_ok=True)
251+
continue
252+
target.parent.mkdir(parents=True, exist_ok=True)
253+
with archive.open(member) as source, target.open("wb") as destination:
254+
shutil.copyfileobj(source, destination)
255+
256+
257+
def _replace_with_validated_extract_dir(tmp_dir: Path, extract_dir: Path) -> None:
258+
_validate_extracted_tree(tmp_dir)
259+
tmp_dir.replace(extract_dir)
260+
261+
262+
def _validate_extracted_tree(extract_dir: Path) -> None:
263+
resolved_root = extract_dir.resolve()
264+
file_count = 0
265+
total_size = 0
266+
for path in extract_dir.rglob("*"):
267+
if path.is_symlink():
268+
raise ValueError(f"Blocked unsafe archive symlink: {path}")
269+
if not path.resolve().is_relative_to(resolved_root):
270+
raise ValueError(f"Blocked archive path escaping target directory: {path}")
271+
if path.is_file():
272+
file_count += 1
273+
total_size += path.stat().st_size
274+
_validate_archive_limits(file_count, total_size)
275+
276+
277+
def _fresh_extract_dir(path: Path) -> Path:
278+
tmp_dir = path.with_name(f".{path.stem}.extracting")
279+
if tmp_dir.exists():
280+
shutil.rmtree(tmp_dir)
281+
return tmp_dir
282+
283+
171284
def _extract_zip_files(target_dir: Path) -> None:
172285
for path in target_dir.rglob("*.zip"):
173286
extract_dir = path.with_suffix("")
174287
if extract_dir.exists():
175288
continue
176-
with zipfile.ZipFile(path) as archive:
177-
archive.extractall(extract_dir)
289+
tmp_dir = _fresh_extract_dir(path)
290+
tmp_dir.mkdir(parents=True)
291+
try:
292+
with zipfile.ZipFile(path) as archive:
293+
_safe_zip_extractall(archive, tmp_dir)
294+
_replace_with_validated_extract_dir(tmp_dir, extract_dir)
295+
except Exception:
296+
shutil.rmtree(tmp_dir, ignore_errors=True)
297+
raise
178298

179299

180300
def _extract_7z_files(target_dir: Path) -> None:
181301
for path in target_dir.rglob("*.7z"):
182302
extract_dir = path.with_suffix("")
183303
if extract_dir.exists():
184304
continue
185-
subprocess.run(["unar", "-quiet", "-output-directory", str(extract_dir), str(path)], check=True)
305+
tmp_dir = _fresh_extract_dir(path)
306+
tmp_dir.mkdir(parents=True)
307+
try:
308+
subprocess.run(
309+
["unar", "-quiet", "-output-directory", str(tmp_dir), str(path)],
310+
check=True,
311+
timeout=SUBPROCESS_TIMEOUT_SECONDS,
312+
)
313+
_replace_with_validated_extract_dir(tmp_dir, extract_dir)
314+
except Exception:
315+
shutil.rmtree(tmp_dir, ignore_errors=True)
316+
raise
186317

187318

188319
_download_huggingface = download_huggingface_dataset

0 commit comments

Comments
 (0)