Skip to content

Commit 57f27d5

Browse files
fix: prevent SSRF via open redirects in GCS model downloads
* 🛡️ Sentinel: [HIGH] Fix SSRF via open redirects in GCS downloads Co-authored-by: n24q02m <135627235+n24q02m@users.noreply.github.com> * Fix `ty` type checking errors introduced by missing type ignores Co-authored-by: n24q02m <135627235+n24q02m@users.noreply.github.com> --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent 1f31b51 commit 57f27d5

10 files changed

Lines changed: 61 additions & 32 deletions

.jules/sentinel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,8 @@
22
**Vulnerability:** The code `requests.get` implicitly relies on the default configuration for TLS verification. This can be maliciously or accidentally disabled via the `REQUESTS_CA_BUNDLE` environment variable, leading to Man-In-The-Middle (MITM) attacks when downloading models from untrusted or tampered sources.
33
**Learning:** Explicit configuration (such as `verify=True` in HTTP requests) provides defense-in-depth against environment manipulation.
44
**Prevention:** Always explicitly set `verify=True` when executing HTTP requests to external or potentially unauthenticated sources using the `requests` library.
5+
6+
## 2024-05-20 - [Fix SSRF via Open Redirects in GCS Downloads]
7+
**Vulnerability:** Server-Side Request Forgery (SSRF) was possible because HTTP requests followed redirects by default. A malicious or compromised server could redirect to internal resources (e.g., metadata endpoints, internal network IPs) circumventing initial URL validation.
8+
**Learning:** Initial URL validation is insufficient if the HTTP client automatically follows redirects to unvalidated destinations.
9+
**Prevention:** Always set `allow_redirects=False` in HTTP clients and explicitly validate or reject redirect status codes (301, 302, 303, 307, 308) to prevent SSRF via open redirects.

qwen3_embed/common/model_management.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,14 @@ def download_file_from_gcs(cls, url: str, output_path: str, show_progress: bool
177177
if os.path.exists(output_path):
178178
return output_path
179179
# SECURITY: Explicitly enforce TLS verification to prevent accidental or malicious bypass via environment variables (like REQUESTS_CA_BUNDLE).
180-
response = cls._get_session().get(url, stream=True, timeout=10, verify=True)
180+
response = cls._get_session().get(
181+
url, stream=True, timeout=10, verify=True, allow_redirects=False
182+
)
183+
184+
if response.status_code in (301, 302, 303, 307, 308):
185+
raise ValueError(
186+
f"SSRF Prevention: Redirects are not allowed. Status code: {response.status_code}"
187+
)
181188

182189
# Handle HTTP errors
183190
if response.status_code == 403:

tests/test_gguf_cross_encoder.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -370,22 +370,22 @@ def test_rerank_yields_scores_per_document(self):
370370
def test_rerank_uses_default_instruction(self):
371371
"""Test rerank uses DEFAULT_INSTRUCTION when no instruction kwarg provided."""
372372
model = _make_model()
373-
model._score_text = MagicMock(return_value=0.9) # type: ignore[method-assign]
373+
model._score_text = MagicMock(return_value=0.9) # type: ignore
374374

375375
list(model.rerank("q", ["doc"]))
376376

377-
call_arg = model._score_text.call_args[0][0] # type: ignore[unresolved-attribute]
377+
call_arg = model._score_text.call_args[0][0] # type: ignore
378378
assert DEFAULT_INSTRUCTION in call_arg
379379

380380
def test_rerank_custom_instruction(self):
381381
"""Test rerank passes custom instruction to _format_rerank_input."""
382382
model = _make_model()
383-
model._score_text = MagicMock(return_value=0.9) # type: ignore[method-assign]
383+
model._score_text = MagicMock(return_value=0.9) # type: ignore
384384
custom_instruction = "Custom scoring instruction."
385385

386386
list(model.rerank("q", ["doc"], instruction=custom_instruction))
387387

388-
call_arg = model._score_text.call_args[0][0] # type: ignore[unresolved-attribute]
388+
call_arg = model._score_text.call_args[0][0] # type: ignore
389389
assert custom_instruction in call_arg
390390

391391
def test_rerank_score_range(self):
@@ -422,22 +422,22 @@ def test_rerank_pairs_yields_scores(self):
422422
def test_rerank_pairs_uses_default_instruction(self):
423423
"""Test rerank_pairs uses DEFAULT_INSTRUCTION by default."""
424424
model = _make_model()
425-
model._score_text = MagicMock(return_value=0.7) # type: ignore[method-assign]
425+
model._score_text = MagicMock(return_value=0.7) # type: ignore
426426

427427
list(model.rerank_pairs([("q", "d")]))
428428

429-
call_arg = model._score_text.call_args[0][0] # type: ignore[unresolved-attribute]
429+
call_arg = model._score_text.call_args[0][0] # type: ignore
430430
assert DEFAULT_INSTRUCTION in call_arg
431431

432432
def test_rerank_pairs_custom_instruction(self):
433433
"""Test rerank_pairs passes custom instruction."""
434434
model = _make_model()
435-
model._score_text = MagicMock(return_value=0.7) # type: ignore[method-assign]
435+
model._score_text = MagicMock(return_value=0.7) # type: ignore
436436
custom_instruction = "Rate this pair."
437437

438438
list(model.rerank_pairs([("q", "d")], instruction=custom_instruction))
439439

440-
call_arg = model._score_text.call_args[0][0] # type: ignore[unresolved-attribute]
440+
call_arg = model._score_text.call_args[0][0] # type: ignore
441441
assert custom_instruction in call_arg
442442

443443
def test_rerank_pairs_score_range(self):
@@ -458,11 +458,11 @@ def test_rerank_pairs_empty(self):
458458
def test_rerank_pairs_formats_input_correctly(self):
459459
"""Test rerank_pairs formats each pair as a proper chat-template string."""
460460
model = _make_model()
461-
model._score_text = MagicMock(return_value=0.5) # type: ignore[method-assign]
461+
model._score_text = MagicMock(return_value=0.5) # type: ignore
462462

463463
list(model.rerank_pairs([("test query", "test document")]))
464464

465-
call_arg = model._score_text.call_args[0][0] # type: ignore[unresolved-attribute]
465+
call_arg = model._score_text.call_args[0][0] # type: ignore
466466
assert "test query" in call_arg
467467
assert "test document" in call_arg
468468
assert SYSTEM_PROMPT in call_arg

tests/test_model_management.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,23 @@ def test_ssrf_payloads_rejected(self, tmp_path):
215215
with pytest.raises(ValueError, match="Invalid URL"):
216216
ModelManagement.download_file_from_gcs(payload, str(output))
217217

218+
@patch("qwen3_embed.common.model_management.ModelManagement._get_session")
219+
def test_ssrf_redirects_rejected(self, mock_get_session, tmp_path):
220+
"""SSRF payloads attempting to bypass via open redirects must be rejected."""
221+
mock_session = MagicMock()
222+
mock_get = mock_session.get
223+
mock_get_session.return_value = mock_session
224+
response = Mock()
225+
response.status_code = 302
226+
mock_get.return_value = response
227+
228+
output = tmp_path / "model.onnx"
229+
with pytest.raises(ValueError, match="SSRF Prevention: Redirects are not allowed."):
230+
ModelManagement.download_file_from_gcs(f"{self.GCS_URL}/test.onnx", str(output))
231+
232+
args, kwargs = mock_get.call_args
233+
assert kwargs.get("allow_redirects") is False
234+
218235
def test_invalid_hostname_raises_value_error(self, tmp_path):
219236
"""Non-GCS hostnames must be rejected."""
220237
output = tmp_path / "model.onnx"
@@ -1162,7 +1179,7 @@ class TestDownloadFromGcs:
11621179
def test_download_from_gcs_returns_none_on_exception(self, mock_logger, tmp_path):
11631180
"""If retrieve_model_gcs raises an exception, return None and log error."""
11641181
with patch.object(
1165-
ModelManagement, "retrieve_model_gcs", side_effect=Exception("GCS Error")
1182+
ModelManagement, "retrieve_model_gcs", side_effect=ValueError("GCS Error")
11661183
):
11671184
result = ModelManagement._download_from_gcs(
11681185
model_name="test/model",
@@ -1181,7 +1198,7 @@ def test_download_from_gcs_returns_none_on_exception(self, mock_logger, tmp_path
11811198
def test_download_from_gcs_no_logger_on_local_files_only(self, mock_logger, tmp_path):
11821199
"""If local_files_only is True, do not log error on exception."""
11831200
with patch.object(
1184-
ModelManagement, "retrieve_model_gcs", side_effect=Exception("GCS Error")
1201+
ModelManagement, "retrieve_model_gcs", side_effect=ValueError("GCS Error")
11851202
):
11861203
result = ModelManagement._download_from_gcs(
11871204
model_name="test/model",

tests/test_onnx_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818

1919
# Concrete implementation for testing OnnxModel
2020
class ConcreteOnnxModel(OnnxModel[Any]):
21-
def _get_worker_class(cls) -> type["EmbeddingWorker[Any]"]:
22-
return MagicMock()
21+
def _get_worker_class(cls) -> Any:
22+
return MagicMock() # type: ignore[return-value]
2323

2424
def _post_process_onnx_output(self, output: OnnxOutputContext, **kwargs: Any) -> Iterable[Any]:
2525
return []

tests/test_parallel_processor.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -483,8 +483,8 @@ def test_process_stream_timeout_raises_empty():
483483
# So we mock output_queue.get to raise Empty.
484484
pool.output_queue.get.side_effect = Empty()
485485

486-
pool.join_or_terminate = MagicMock()
487-
pool.check_worker_health = MagicMock()
486+
pool.join_or_terminate = MagicMock() # type: ignore
487+
pool.check_worker_health = MagicMock() # type: ignore
488488

489489
# Processing [1, 2] will cause 2 iterations. First iteration will pass if get_nowait raises Empty
490490
# (handled gracefully). Second iteration will trigger the else branch and raise the mock Empty.
@@ -493,23 +493,23 @@ def test_process_stream_timeout_raises_empty():
493493
with pytest.raises(Empty):
494494
list(pool._process_stream([1, 2]))
495495

496-
pool.join_or_terminate.assert_called_once()
496+
pool.join_or_terminate.assert_called_once() # type: ignore
497497

498498

499499
def test_semi_ordered_map_emergency_shutdown_cancels_join_thread():
500500
"""Test that cancel_join_thread is called in semi_ordered_map finally block if emergency_shutdown is True."""
501501
pool = ParallelWorkerPool(num_workers=1, worker=SquareWorker)
502502

503503
# Mock necessary methods to avoid actual processing
504-
pool.start = MagicMock()
504+
pool.start = MagicMock() # type: ignore
505505

506506
# We want emergency_shutdown to be True when we hit the finally block
507-
def mock_process_stream(*args, **kwargs):
507+
def mock_process_stream(*args: Any, **kwargs: Any) -> Any:
508508
pool.emergency_shutdown = True
509509
yield from []
510510

511-
pool._process_stream = mock_process_stream
512-
pool.join = MagicMock()
511+
pool._process_stream = mock_process_stream # type: ignore
512+
pool.join = MagicMock() # type: ignore
513513

514514
# Setup mock queues before calling
515515
mock_input_queue = MagicMock()

tests/test_qwen3_embedding.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def test_post_process_last_token_pooling(self):
7070
output = OnnxOutputContext(model_output=model_output, attention_mask=attention_mask)
7171

7272
result = np.array(
73-
list(Qwen3TextEmbedding._post_process_onnx_output(None, output)) # type: ignore[arg-type]
73+
list(Qwen3TextEmbedding._post_process_onnx_output(None, output)) # type: ignore
7474
)
7575

7676
assert result.shape == (2, 4)
@@ -84,7 +84,7 @@ def test_post_process_mrl_truncation(self):
8484
output = OnnxOutputContext(model_output=model_output, attention_mask=attention_mask)
8585

8686
result = np.array(
87-
list(Qwen3TextEmbedding._post_process_onnx_output(None, output, dim=2)) # type: ignore[arg-type]
87+
list(Qwen3TextEmbedding._post_process_onnx_output(None, output, dim=2)) # type: ignore
8888
)
8989
assert result.shape == (1, 2)
9090
np.testing.assert_allclose(np.linalg.norm(result[0]), 1.0, atol=1e-6)
@@ -93,4 +93,4 @@ def test_post_process_raises_without_attention_mask(self):
9393
"""Should raise if attention_mask is None."""
9494
output = OnnxOutputContext(model_output=np.zeros((1, 3, 4)), attention_mask=None)
9595
with pytest.raises(ValueError, match="attention_mask"):
96-
list(Qwen3TextEmbedding._post_process_onnx_output(None, output)) # type: ignore[arg-type]
96+
list(Qwen3TextEmbedding._post_process_onnx_output(None, output)) # type: ignore

tests/test_security_limits.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ def test_iter_checked_texts():
1818
texts = ["a" * 10, "b" * 100, "c" * 5]
1919
assert list(qwen3_embed.common.utils.iter_checked_texts(texts)) == texts
2020
iterator = qwen3_embed.common.utils.iter_checked_texts(["a" * 10, "b" * 101])
21-
assert next(iterator) == "a" * 10
21+
assert next(iter(iterator)) == "a" * 10
2222
with pytest.raises(ValueError):
23-
next(iterator)
23+
next(iter(iterator))
2424

2525

2626
class MockModel:

tests/test_text_embedding.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,6 @@ def test_embedding_size_property(mocked_text_embedding):
126126
assert te.embedding_size == 123
127127
assert te._embedding_size == 123
128128

129-
TextEmbedding.get_embedding_size.reset_mock()
129+
TextEmbedding.get_embedding_size.reset_mock() # type: ignore[attr-defined]
130130
assert te.embedding_size == 123
131-
TextEmbedding.get_embedding_size.assert_not_called()
131+
TextEmbedding.get_embedding_size.assert_not_called() # type: ignore[attr-defined]

tests/test_text_onnx_text_model.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ def _fake_load() -> None:
302302
m.tokenizer = tok
303303
loaded.append(True)
304304

305-
m.load_onnx_model = _fake_load # type: ignore[invalid-assignment]
305+
m.load_onnx_model = _fake_load # type: ignore
306306
count = m._token_count(["hello"])
307307
assert loaded
308308
assert count == 2
@@ -362,7 +362,7 @@ def fake_load() -> None:
362362
m.tokenizer = _make_mock_tokenizer()
363363
loaded.append(True)
364364

365-
m.load_onnx_model = fake_load # type: ignore[invalid-assignment]
365+
m.load_onnx_model = fake_load # type: ignore
366366
list(m._embed_documents("t", "/tmp", documents=["hi"]))
367367
assert loaded
368368

@@ -563,7 +563,7 @@ def test_embed_yields_normalized_embeddings(self, onnx_emb: OnnxTextEmbedding) -
563563

564564
def test_token_count_sums_mask(self, onnx_emb: OnnxTextEmbedding) -> None:
565565
"""Line 169."""
566-
enc = onnx_emb.tokenizer.encode_batch.return_value[0] # type: ignore[unresolved-attribute]
566+
enc = onnx_emb.tokenizer.encode_batch.return_value[0] # type: ignore
567567
enc.attention_mask = [1, 1, 0, 0]
568568
assert onnx_emb.token_count("hello") == 2
569569

0 commit comments

Comments
 (0)