Skip to content

Commit 0518c36

Browse files
authored
Sanitize direct download (#13313)
The 'direct' option in 'spacy download' is supposed to only download from our model releases repository. However, users were able to pass in a relative path, allowing download from arbitrary repositories. This meant that a service that sourced strings from user input and which used the direct option would allow users to install arbitrary packages.
1 parent bff8725 commit 0518c36

File tree

3 files changed

+33
-2
lines changed

3 files changed

+33
-2
lines changed

spacy/cli/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from wasabi import msg
22

3+
# Needed for testing
4+
from . import download as download_module # noqa: F401
35
from ._util import app, setup_cli # noqa: F401
46
from .apply import apply # noqa: F401
57
from .assemble import assemble_cli # noqa: F401

spacy/cli/download.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import sys
22
from typing import Optional, Sequence
3+
from urllib.parse import urljoin
34

45
import requests
56
import typer
@@ -63,6 +64,13 @@ def download(
6364
)
6465
pip_args = pip_args + ("--no-deps",)
6566
if direct:
67+
# Reject model names with '/', in order to prevent shenanigans.
68+
if "/" in model:
69+
msg.fail(
70+
title="Model download rejected",
71+
text=f"Cannot download model '{model}'. Models are expected to be file names, not URLs or fragments",
72+
exits=True,
73+
)
6674
components = model.split("-")
6775
model_name = "".join(components[:-1])
6876
version = components[-1]
@@ -153,7 +161,16 @@ def get_latest_version(model: str) -> str:
153161
def download_model(
154162
filename: str, user_pip_args: Optional[Sequence[str]] = None
155163
) -> None:
156-
download_url = about.__download_url__ + "/" + filename
164+
# Construct the download URL carefully. We need to make sure we don't
165+
# allow relative paths or other shenanigans to trick us into download
166+
# from outside our own repo.
167+
base_url = about.__download_url__
168+
# urljoin requires that the path ends with /, or the last path part will be dropped
169+
if not base_url.endswith("/"):
170+
base_url = about.__download_url__ + "/"
171+
download_url = urljoin(base_url, filename)
172+
if not download_url.startswith(about.__download_url__):
173+
raise ValueError(f"Download from {filename} rejected. Was it a relative path?")
157174
pip_args = list(user_pip_args) if user_pip_args is not None else []
158175
cmd = [sys.executable, "-m", "pip", "install"] + pip_args + [download_url]
159176
run_command(cmd)

spacy/tests/test_cli.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import spacy
1414
from spacy import about
15-
from spacy.cli import info
15+
from spacy.cli import download_module, info
1616
from spacy.cli._util import parse_config_overrides, string_to_list, walk_directory
1717
from spacy.cli.apply import apply
1818
from spacy.cli.debug_data import (
@@ -1066,3 +1066,15 @@ def test_debug_data_trainable_lemmatizer_not_annotated():
10661066
def test_project_api_imports():
10671067
from spacy.cli import project_run
10681068
from spacy.cli.project.run import project_run # noqa: F401, F811
1069+
1070+
1071+
def test_download_rejects_relative_urls(monkeypatch):
1072+
"""Test that we can't tell spacy download to get an arbitrary model by using a
1073+
relative path in the filename"""
1074+
1075+
monkeypatch.setattr(download_module, "run_command", lambda cmd: None)
1076+
1077+
# Check that normal download works
1078+
download_module.download("en_core_web_sm-3.7.1", direct=True)
1079+
with pytest.raises(SystemExit):
1080+
download_module.download("../en_core_web_sm-3.7.1", direct=True)

0 commit comments

Comments
 (0)