| name | review-topic-input-boundary-security |
| description | Load when a PR touches SSRF/URL-fetch guards (`_ensure_url_input_allowed`, `_ensure_resource_fqdn_allowed`, `WHITELISTED_DESTINATIONS_FOR_URL_INPUT`/`BLACKLISTED_DESTINATIONS_FOR_URL_INPUT`), local-file image loads (`ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM`), model-cache paths from a `model_id` (`cache_path_is_within_root`, `MODEL_CACHE_DIR`), unsafe deserialization (`pickle.loads`, `torch.load`, `weights_only`, `allow_untrusted_packages`), or archive extraction (`tarfile.extractall`, `ZipFile`). |
Review topic: Input-boundary security (SSRF, path traversal, unsafe deserialization, decompression)
When this applies
Trigger on CONTENT/behaviour, not just directory. Load when the diff:
- Fetches a resource from a user/client-supplied URL, or edits any of the URL guards in
inference/core/utils/image_utils.py — load_image_from_url, _ensure_url_input_allowed, _ensure_resource_schema_allowed, _ensure_resource_fqdn_allowed, _concatenate_chunks_of_network_location, _ensure_location_matches_destination_whitelist/_blacklist.
- Changes URL-input policy env in
inference/core/env.py — ALLOW_URL_INPUT, ALLOW_NON_HTTPS_URL_INPUT, ALLOW_URL_INPUT_WITHOUT_FQDN, WHITELISTED_DESTINATIONS_FOR_URL_INPUT, BLACKLISTED_DESTINATIONS_FOR_URL_INPUT, ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM, ALLOW_NUMPY_INPUT.
- Builds a filesystem path from a
model_id, filename, or other client string — get_cache_dir, get_model_id_cache_path, slugify_model_id_to_cache_key, cache_path_is_within_root, get_cache_file_path, or any os.path.join(MODEL_CACHE_DIR, <user string>).
- Deserializes untrusted bytes —
pickle.loads, torch.load, np.load, joblib.load, or toggles weights_only / allow_untrusted_packages / ALLOW_INFERENCE_MODELS_UNTRUSTED_PACKAGES.
- Extracts an archive —
tarfile.extractall, zipfile.ZipFile, shutil.unpack_archive — or decodes an image/embedding whose size is attacker-controlled.
Review checklist
Severity-tag every finding. Reference the numbered Standards below.
- BLOCK — A user-supplied URL reaches
requests.get (or any egress) without passing through the full load_image_from_url guard chain (_ensure_url_input_allowed → schema → FQDN → whitelist → blacklist). [S1]
- BLOCK — The URL that is validated is not the URL that is fetched: validation runs on the raw string while the request follows redirects, or
requests.get(...) is given a different URL than the one whose host was checked. The prepared/normalized URL must be both validated and fetched, and redirects to unvalidated hosts must not be silently followed (GHSA-hjmm-hr52-vrp2 / PR #2501). [S1][S2]
- BLOCK — Host parsing that a
\, @, userinfo, or bracketed-IPv6 authority can bypass so the allow/deny check sees a different host than requests connects to (GHSA-rv25-cq3f-x5f2 / PR #2500). [S2]
- BLOCK —
pickle.loads / torch.load(weights_only=False) / np.load(allow_pickle=True) newly applied to bytes an unauthenticated client can supply, without an explicit trust gate. [S5]
- BLOCK —
tarfile.extractall / ZipFile.extractall on an archive whose member paths are not confirmed to stay within the destination (Zip-Slip / ../ traversal), or with no total-size / member-count bound (decompression bomb). [S6]
- BLOCK — A
model_id or filename is joined into a path and used to read/write without the cache_path_is_within_root containment check, letting ../absolute segments escape MODEL_CACHE_DIR. [S4]
- FLAG — A new client-string-derived path skips
slugify_model_id_to_cache_key and relies on the raw value staying benign. [S4]
- FLAG — Weakening a URL guard default: flipping
ALLOW_NON_HTTPS_URL_INPUT / ALLOW_URL_INPUT_WITHOUT_FQDN / ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM to a more-permissive default, or bypassing the whitelist/blacklist check for a "trusted" branch. [S3][S7]
- FLAG —
os.path.isfile(user_value) / cv2.imread(user_value) reachable when ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM is disabled — local-FS reads must be gated by that flag on every path (PR #957). [S7]
- FLAG —
torch.load left at weights_only=False for a NEW weights path that could load safetensors/weights_only=True instead, or a new allow_untrusted_packages=True call site. [S5]
- FLAG — Image/embedding decode with no upper bound on decoded dimensions/bytes where the input is client-supplied (decompression bomb via
cv2.imdecode / np.load). [S6]
- NIT — A guard whose failure message leaks the internal target it tried to reach, or a swallowed decode exception that hides which guard rejected the input.
Not blocking
- Do NOT flag
torch.load(weights_only=False) on the OWLv2 embeddings cache or transformers/qwen tar extraction when the bytes originate from MODEL_CACHE_DIR populated by the Roboflow weights API, not a request body — that is a first-party artifact, not attacker input. Call it out only if a new codepath lets a client supply those bytes.
- Do NOT demand a whitelist/blacklist when
WHITELISTED_DESTINATIONS_FOR_URL_INPUT / BLACKLISTED_DESTINATIONS_FOR_URL_INPUT are unset — they are opt-in operator controls (None ⇒ skip), and the schema/FQDN guards still apply.
- Do NOT treat
pickle.loads in load_image_from_numpy_str as a new hole — it is gated behind ALLOW_NUMPY_INPUT (default False) and is pre-existing; flag only if the gate is removed or the default flips.
- Auth / api-key / tenant-boundary concerns are owned by
review-topic-auth-and-tenant-security — cross-ref, do not re-review here. review-core-infra keeps the image_utils SSRF chain as its surface instance and cross-refs this topic for the deep rules.
What to check / Standards
S1 — All user URLs run the full guard chain. In load_image_from_url, a client URL must pass _ensure_url_input_allowed() (respects ALLOW_URL_INPUT), then _ensure_resource_schema_allowed, _ensure_resource_fqdn_allowed, _ensure_location_matches_destination_whitelist, and _ensure_location_matches_destination_blacklist BEFORE any egress. Any new URL-fetching entry point must route through the same guards, not re-implement a subset. The default posture is https-only (ALLOW_NON_HTTPS_URL_INPUT=False) and FQDN-required (ALLOW_URL_INPUT_WITHOUT_FQDN=False) (PR #497 introduced the guard chain and these env controls).
S2 — Validate-then-fetch the same, normalized URL; parse the real authority. The host that the guards check must be the host requests connects to. load_image_from_url rejects a \ in the raw netloc, prepare()s the request, then validates and fetches prepared_url — do not validate the raw string and fetch something else, and do not let the parsed host diverge from the connected host via userinfo (@), backslash, or IPv6-bracket tricks (GHSA-rv25-cq3f-x5f2 / PR #2500). Host extraction uses parsed_url.hostname (bracketing IPv6) fed to tldextract, not raw netloc. Redirects must not smuggle the request to an internal target after a benign first host passes validation (GHSA-hjmm-hr52-vrp2 / PR #2501).
S3 — Whitelist/blacklist are exact-destination allow/deny lists. _ensure_location_matches_destination_whitelist rejects unless the concatenated network location is in WHITELISTED_DESTINATIONS_FOR_URL_INPUT; _ensure_location_matches_destination_blacklist rejects if it is in BLACKLISTED_DESTINATIONS_FOR_URL_INPUT. Both are None-means-skip. The destination is _concatenate_chunks_of_network_location(...) over the tldextract result (subdomain.domain.suffix, IPv6 brackets stripped) — do not compare against raw user text or a substring, which would let evil.com/../roboflow.com or a subdomain trick slip through.
S4 — model_id / filename paths must stay inside MODEL_CACHE_DIR. get_model_id_cache_path only trusts the raw model_id as a path segment when cache_path_is_within_root (an os.path.commonpath containment check against the abspath of cache_dir_root) AND path_fits_os_limits both pass; otherwise it falls back to slugify_model_id_to_cache_key (regex to [A-Za-z0-9_-], collapse, truncate, blake2s suffix). Any new path built from a client string must apply the same containment check or slugify — never os.path.join(MODEL_CACHE_DIR, raw_model_id) and read/write directly, which a ../ or absolute model_id would escape.
S5 — Deserialize untrusted bytes safely. Never pickle.loads, torch.load(weights_only=False), or np.load(allow_pickle=True) on bytes a client can supply without an explicit trust gate. Prefer weights_only=True (as doctr_model.py and perception_encoder/pe.py do) and safetensors. allow_untrusted_packages must stay bound to ALLOW_INFERENCE_MODELS_UNTRUSTED_PACKAGES (env, default False) — a literal True at a call site is a finding. The one client-facing pickle (load_image_from_numpy_str) stays behind ALLOW_NUMPY_INPUT (default False); keep it that way.
S6 — Bound decompression and extraction. Archive extraction (tarfile.extractall, ZipFile) must confirm every member resolves within the destination (no ../, no absolute, no symlink escape) and bound total uncompressed size / member count. Image and embedding decodes (cv2.imdecode, np.load(BytesIO(...))) on client bytes must have an upper bound on decoded dimensions/bytes so a small payload cannot expand into an OOM. Only exempt archives sourced from the first-party weights cache, not request bodies.
S7 — Do not weaken input-boundary defaults. ALLOW_URL_INPUT=True but ALLOW_NON_HTTPS_URL_INPUT=False, ALLOW_URL_INPUT_WITHOUT_FQDN=False, ALLOW_NUMPY_INPUT=False are the safe defaults; ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM gates every local-FS read (both load_image_with_known_type for ImageType.FILE and the inferred os.path.isfile branch — PR #957). Flipping any of these to more-permissive defaults, or adding a "trusted"/internal bypass around a guard, needs explicit justification and is at least a FLAG.
Key files & reference PRs
inference/core/utils/image_utils.py — SSRF guard chain: load_image_from_url (validate-then-fetch on the prepared URL), _ensure_url_input_allowed, _ensure_resource_schema_allowed, _ensure_resource_fqdn_allowed, _concatenate_chunks_of_network_location, _ensure_location_matches_destination_whitelist/_blacklist; local-FS gating in load_image_with_known_type / load_image_with_inferred_type; the ALLOW_NUMPY_INPUT-gated pickle.loads in load_image_from_numpy_str.
inference/core/env.py — URL/input policy: ALLOW_URL_INPUT, ALLOW_NON_HTTPS_URL_INPUT, ALLOW_URL_INPUT_WITHOUT_FQDN, WHITELISTED_DESTINATIONS_FOR_URL_INPUT, BLACKLISTED_DESTINATIONS_FOR_URL_INPUT, ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM, ALLOW_NUMPY_INPUT, ALLOW_INFERENCE_MODELS_UNTRUSTED_PACKAGES, MODEL_CACHE_DIR.
inference/core/cache/model_artifacts.py — path-traversal containment: get_cache_dir, get_model_id_cache_path, cache_path_is_within_root, slugify_model_id_to_cache_key, path_fits_os_limits, get_cache_file_path.
inference/core/models/inference_models_adapters.py — allow_untrusted_packages=ALLOW_INFERENCE_MODELS_UNTRUSTED_PACKAGES call sites (the pattern every model adapter must follow).
inference/models/** — torch.load sites: weights_only=True in doctr/doctr_model.py, perception_encoder/vision_encoder/pe.py; weights_only=False on the first-party OWLv2 embeddings cache in owlv2/owlv2.py; tarfile.extractall in transformers/transformers.py, qwen25vl/qwen25vl.py, qwen3vl/qwen3vl.py; np.load(BytesIO(...)) embedding/mask decode in sam/.
- Reference PRs / advisories: #2500 (GHSA-rv25-cq3f-x5f2 — backslash/userinfo authority-parsing allowlist bypass), #2501 (GHSA-hjmm-hr52-vrp2 — validate-first-FQDN-then-follow-redirect-to-internal-target), #497 (original SSRF guard chain + URL-input env controls), #957 (gate local-filesystem image reads behind
ALLOW_LOADING_IMAGES_FROM_LOCAL_FILESYSTEM, stop passing user value to os.path.isfile).