Skip to content

Commit 13ba349

Browse files
committed
fix(server): properly detect remote_server URL scheme instead of substring match
1 parent 746b41d commit 13ba349

3 files changed

Lines changed: 155 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
3838
- Fixed a bug in `LanguageTool._query_server` where `RateLimitError` was only raised when the rate-limit response body was invalid JSON, a valid JSON body with status 426 was silently returned as data instead (for now, the body from LanguageTool for rate-limiting responses is "Upgrade Required", which is not valid JSON, but this may change in the future).
3939
- Fixed a bug in `LanguageTool._terminate_server` where `_RUNNING_SERVER_PROCESSES.remove()` could raise `ValueError` if the server process was not yet in the list or was no longer in it.
4040
- Fixed a missing warning when downloading a LanguageTool zip file (with `LanguageTool` or `LocalLanguageTool`) without an available SHA-256 checksum. Now a `RuntimeWarning` is emitted in this case.
41+
- Fixed `remote_server` URL scheme detection in `language_tool_python.server.LanguageTool`, which used a fragile substring check (`"http" in remote_server`) and could misidentify a host containing "http" in its name (e.g. `myhttpserver.example.com`) as already having a scheme. It now uses a proper `scheme://` prefix check, only accepts `http` and `https` as valid schemes (raising `ValueError` for any other explicit scheme, an empty/whitespace-only URL, or a missing host), and emits a `RuntimeWarning` whenever the unencrypted `http://` scheme is silently added by default.
4142

4243
### Removed
4344
- **Breaking:** Removed all functions and classes previously deprecated in v3.3.0:

‎src/language_tool_python/_internals/utils.py‎

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import logging
44
import math
55
import os
6+
import re
67
import urllib.parse
78
from pathlib import Path
8-
from typing import Protocol, runtime_checkable
9+
from typing import Protocol, cast, runtime_checkable
10+
from warnings import warn
911

1012
import psutil
1113

@@ -28,22 +30,68 @@
2830

2931
LTP_PATH_ENV_VAR = "LTP_PATH" # LanguageTool download path
3032

33+
_SUPPORTED_URL_SCHEMES = frozenset({"http", "https"})
34+
35+
# Matches an explicit "scheme://" prefix, capturing the scheme name.
36+
_URL_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*)://")
37+
38+
# Matches an explicit "http:" or "https:" prefix that lacks the "//" authority
39+
# marker (RFC 3986's scheme:opaque form, e.g. "http:example.com").
40+
_EXPLICIT_SCHEME_NO_SLASH_RE = re.compile(r"^(https?):", re.IGNORECASE)
41+
3142

3243
def parse_url(url_str: str) -> str:
3344
"""Parse the given URL string and ensure it has a scheme.
3445
35-
If the input URL string does not contain 'http', 'http://' is prepended to it. The
36-
function then parses the URL and returns its canonical form.
37-
3846
:param url_str: The URL string to be parsed.
3947
:type url_str: str
4048
:return: The parsed URL in its canonical form.
4149
:rtype: str
50+
:raises ValueError: If ``url_str`` is empty (or only whitespace), uses an
51+
unsupported scheme, or has no host.
4252
"""
43-
if "http" not in url_str:
44-
url_str = "http://" + url_str
53+
stripped = url_str.strip()
54+
if not stripped:
55+
err = "The URL must not be empty."
56+
raise ValueError(err)
57+
58+
scheme_match = _URL_SCHEME_RE.match(stripped)
59+
if scheme_match is not None:
60+
scheme = cast("str", scheme_match.group(1)).lower()
61+
if scheme not in _SUPPORTED_URL_SCHEMES:
62+
err = (
63+
f"Unsupported URL scheme {scheme!r}. Only 'http' and 'https' are "
64+
f"supported."
65+
)
66+
raise ValueError(err)
67+
else:
68+
explicit_scheme_match = _EXPLICIT_SCHEME_NO_SLASH_RE.match(stripped)
69+
if explicit_scheme_match is not None:
70+
# Explicit scheme without "//" (e.g. "http:example.com"): normalise
71+
# the scheme's case but otherwise leave the rest of the URL as-is.
72+
# Will raise a ValueError later because no host is present
73+
scheme = cast("str", explicit_scheme_match.group(1)).lower()
74+
stripped = scheme + stripped[explicit_scheme_match.end(1) :]
75+
else:
76+
# Either fully schemeless ("localhost:8081") or protocol-relative
77+
# ("//example.com:8081"), the latter already has the "//" authority
78+
# marker, so only "http:" (not "http://") needs to be prepended.
79+
prefix = "http:" if stripped.startswith("//") else "http://"
80+
stripped = prefix + stripped
81+
warn(
82+
"No scheme was specified in the URL, so the unsecure 'http://' "
83+
"scheme was used by default. It is recommended to explicitly set the "
84+
"protocol ('http://' or 'https://') yourself in the URL.",
85+
RuntimeWarning,
86+
stacklevel=2,
87+
)
88+
89+
parsed = urllib.parse.urlparse(stripped)
90+
if not parsed.hostname:
91+
err = f"The URL {stripped!r} must include a host."
92+
raise ValueError(err)
4593

46-
return urllib.parse.urlparse(url_str).geturl()
94+
return parsed.geturl()
4795

4896

4997
def get_env_int(env_var: str, default: int) -> int:

‎tests/unit/test_internals_utils.py‎

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,110 @@ def test_https_url_unchanged(self) -> None:
4040
assert parse_url("https://example.com") == "https://example.com"
4141

4242
def test_adds_http_scheme(self) -> None:
43-
"""A host:port string without a scheme gets http:// prepended."""
44-
result = parse_url("localhost:8081")
45-
assert result.startswith("http://")
46-
assert "localhost" in result
43+
"""A host:port string without a scheme gets http:// prepended and warns."""
44+
with pytest.warns(RuntimeWarning, match="No scheme was specified"):
45+
result = parse_url("localhost:8081")
46+
assert result == "http://localhost:8081"
4747

4848
def test_canonical_form(self) -> None:
4949
"""An already-complete URL with trailing slash is returned unchanged."""
5050
assert parse_url("http://localhost:8081/") == "http://localhost:8081/"
5151

52+
def test_host_containing_http_substring_is_prefixed(self) -> None:
53+
"""A schemeless host containing 'http' in its name still gets prefixed."""
54+
with pytest.warns(RuntimeWarning, match="No scheme was specified"):
55+
result = parse_url("myhttpserver.example.com")
56+
assert result == "http://myhttpserver.example.com"
57+
58+
def test_host_with_port_and_path(self) -> None:
59+
"""A schemeless host:port/path string is prefixed and preserved."""
60+
with pytest.warns(RuntimeWarning, match="No scheme was specified"):
61+
result = parse_url("example.com:8081/api")
62+
assert result == "http://example.com:8081/api"
63+
64+
def test_uppercase_scheme_is_normalised(self) -> None:
65+
"""An uppercase scheme is recognised and normalised to lowercase."""
66+
assert parse_url("HTTP://example.com") == "http://example.com"
67+
68+
def test_mixed_case_https_scheme_is_normalised(self) -> None:
69+
"""A mixed-case https scheme is recognised and normalised to lowercase."""
70+
assert parse_url("HTTPS://example.com") == "https://example.com"
71+
72+
def test_strips_surrounding_whitespace(self) -> None:
73+
"""Leading and trailing whitespace around the URL is ignored."""
74+
assert parse_url(" http://example.com ") == "http://example.com"
75+
76+
def test_empty_string_raises(self) -> None:
77+
"""An empty URL string raises ValueError."""
78+
with pytest.raises(ValueError, match="must not be empty"):
79+
parse_url("")
80+
81+
def test_whitespace_only_raises(self) -> None:
82+
"""A whitespace-only URL string raises ValueError."""
83+
with pytest.raises(ValueError, match="must not be empty"):
84+
parse_url(" ")
85+
86+
def test_unsupported_scheme_raises(self) -> None:
87+
"""An explicit, unsupported scheme (e.g. ftp) raises ValueError."""
88+
with pytest.raises(ValueError, match="Unsupported URL scheme 'ftp'"):
89+
parse_url("ftp://example.com")
90+
91+
def test_file_scheme_raises(self) -> None:
92+
"""An explicit file:// scheme raises ValueError instead of passing through."""
93+
with pytest.raises(ValueError, match="Unsupported URL scheme 'file'"):
94+
parse_url("file:///etc/passwd")
95+
96+
def test_scheme_without_host_raises(self) -> None:
97+
"""A scheme with no host raises ValueError."""
98+
with pytest.raises(ValueError, match="must include a host"):
99+
parse_url("http://")
100+
101+
def test_scheme_with_empty_host_and_port_raises(self) -> None:
102+
"""A scheme with a port but no host raises ValueError."""
103+
with pytest.raises(ValueError, match="must include a host"):
104+
parse_url("http://:8081")
105+
106+
def test_error_message_strips_whitespace(self) -> None:
107+
"""The host-missing error message does not leak surrounding whitespace."""
108+
with pytest.raises(
109+
ValueError, match=r"^The URL 'http://:8081' must include a host\.$"
110+
):
111+
parse_url(" http://:8081 ")
112+
113+
def test_protocol_relative_url_gets_http_scheme(self) -> None:
114+
"""A protocol-relative URL (//host) is given an http:// scheme, not mangled."""
115+
with pytest.warns(RuntimeWarning, match="No scheme was specified"):
116+
result = parse_url("//example.com:8081")
117+
assert result == "http://example.com:8081"
118+
119+
def test_explicit_scheme_without_slashes_raises_clean_message(self) -> None:
120+
"""An RFC 3986 scheme:opaque form (no host) raises with a clean message."""
121+
with pytest.raises(
122+
ValueError, match=r"^The URL 'http:example\.com' must include a host\.$"
123+
):
124+
parse_url("http:example.com")
125+
126+
def test_uppercase_explicit_scheme_without_slashes_raises(self) -> None:
127+
"""An uppercase scheme:opaque form is treated the same as lowercase."""
128+
with pytest.raises(ValueError, match="must include a host"):
129+
parse_url("HTTPS:example.com")
130+
131+
def test_schemeless_host_port_unaffected_by_scheme_without_slash_detection(
132+
self,
133+
) -> None:
134+
"""A schemeless host:port input is still treated as schemeless."""
135+
with pytest.warns(RuntimeWarning, match="No scheme was specified"):
136+
result = parse_url("localhost:8081")
137+
assert result == "http://localhost:8081"
138+
139+
def test_schemeless_host_port_path_unaffected_by_scheme_without_slash_detection(
140+
self,
141+
) -> None:
142+
"""A schemeless host:port/path input is still treated as schemeless."""
143+
with pytest.warns(RuntimeWarning, match="No scheme was specified"):
144+
result = parse_url("example.com:8081/api")
145+
assert result == "http://example.com:8081/api"
146+
52147

53148
class TestGetEnvInt:
54149
"""Tests for get_env_int() environment variable reader."""

0 commit comments

Comments
 (0)