Skip to content

Commit 106a6f2

Browse files
authored
fix(server): properly detect remote_server URL scheme instead of substring match (#217)
1 parent 746b41d commit 106a6f2

3 files changed

Lines changed: 172 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. `parse_url` now correctly recognises `scheme://` URLs, bare `scheme:` URLs (e.g. `http:example.com`, `ftp:example.com`), and protocol-relative URLs (`//host`). Only `http`/`https` are accepted, raising `ValueError` otherwise (unsupported scheme, empty URL, or missing host), and warning via `RuntimeWarning` when `http://` is 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: 57 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,70 @@
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 "scheme:" prefix that lacks the "//" authority marker
39+
# (RFC 3986's scheme:opaque form, e.g. "ftp:example.com" or "mailto:a@b.com").
40+
_EXPLICIT_SCHEME_NO_SLASH_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):(?!\d)")
41+
42+
43+
def _check_supported_scheme(scheme: str) -> None:
44+
"""Raise ValueError if ``scheme`` is not one of the supported URL schemes."""
45+
if scheme not in _SUPPORTED_URL_SCHEMES:
46+
err = (
47+
f"Unsupported URL scheme {scheme!r}. Only 'http' and 'https' are supported."
48+
)
49+
raise ValueError(err)
50+
3151

3252
def parse_url(url_str: str) -> str:
3353
"""Parse the given URL string and ensure it has a scheme.
3454
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-
3855
:param url_str: The URL string to be parsed.
3956
:type url_str: str
4057
:return: The parsed URL in its canonical form.
4158
:rtype: str
59+
:raises ValueError: If ``url_str`` is empty (or only whitespace), uses an
60+
unsupported scheme, or has no host.
4261
"""
43-
if "http" not in url_str:
44-
url_str = "http://" + url_str
62+
stripped = url_str.strip()
63+
if not stripped:
64+
err = "The URL must not be empty."
65+
raise ValueError(err)
66+
67+
scheme_match = _URL_SCHEME_RE.match(stripped)
68+
if scheme_match is not None:
69+
# scheme is present and followed by "//"
70+
scheme = cast("str", scheme_match.group(1)).lower()
71+
_check_supported_scheme(scheme)
72+
else:
73+
explicit_scheme_match = _EXPLICIT_SCHEME_NO_SLASH_RE.match(stripped)
74+
if explicit_scheme_match is not None:
75+
# scheme is present but not followed by "//"
76+
scheme = cast("str", explicit_scheme_match.group(1)).lower()
77+
_check_supported_scheme(scheme)
78+
stripped = scheme + stripped[explicit_scheme_match.end(1) :]
79+
else:
80+
# scheme is missing, so we default to "http://" and issue a warning
81+
prefix = "http:" if stripped.startswith("//") else "http://"
82+
stripped = prefix + stripped
83+
warn(
84+
"No scheme was specified in the URL, so the unsecure 'http://' "
85+
"scheme was used by default. It is recommended to explicitly set the "
86+
"protocol ('http://' or 'https://') yourself in the URL.",
87+
RuntimeWarning,
88+
stacklevel=2,
89+
)
90+
91+
parsed = urllib.parse.urlparse(stripped)
92+
if not parsed.hostname:
93+
err = f"The URL {stripped!r} must include a host."
94+
raise ValueError(err)
4595

46-
return urllib.parse.urlparse(url_str).geturl()
96+
return parsed.geturl()
4797

4898

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

‎tests/unit/test_internals_utils.py‎

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,125 @@ 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+
147+
def test_ftp_scheme_without_slashes_raises_unsupported(self) -> None:
148+
"""An unsupported scheme:opaque form (ftp:) raises Unsupported URL scheme."""
149+
with pytest.raises(ValueError, match="Unsupported URL scheme 'ftp'"):
150+
parse_url("ftp:example.com")
151+
152+
def test_mailto_scheme_without_slashes_raises_unsupported(self) -> None:
153+
"""An unsupported scheme:opaque form (mailto:) raises Unsupported URL scheme."""
154+
with pytest.raises(ValueError, match="Unsupported URL scheme 'mailto'"):
155+
parse_url("mailto:a@b.com")
156+
157+
def test_ws_scheme_without_slashes_raises_unsupported(self) -> None:
158+
"""An unsupported scheme:opaque form (ws:) raises Unsupported URL scheme."""
159+
with pytest.raises(ValueError, match="Unsupported URL scheme 'ws'"):
160+
parse_url("ws:example.com")
161+
52162

53163
class TestGetEnvInt:
54164
"""Tests for get_env_int() environment variable reader."""

0 commit comments

Comments
 (0)