Repository: marshmallow-code/marshmallow

Issue:
fields.URL should allow relative-only validation
Relative URLs may be used to redirect the user within the site, such as to sign in, and allowing absolute URLs without extra validation opens up a possibility of nefarious redirects.

Current `fields.URL(relative = True)` allows relative URLs _in addition_ to absolute URLs, so one must set up extra validation to catch either all absolute URLs or just those that don't have a valid domain names.

It would be helpful if there was a way to set up URL validation to allow only relative URLs. 

~One quick and dirty way to do this would be if there was a `validate.Not` operator, then at the expense of matching the value twice, it would be possible to use something like this:~

~`fields.URL(relative = True, validate=validate.Not(validate.URL()))`~

EDIT: Never mind the crossed out thought above - failed validations are handled only via exceptions and while failing the inner validator works in general, it requires suppressing exception handlers and is just not a good way to go about it. 

Discussion:

Patch:
diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py
index 386aa7c3..445d1c73 100644
--- a/src/marshmallow/fields.py
+++ b/src/marshmallow/fields.py
@@ -1710,6 +1710,7 @@ class Url(String):
         self,
         *,
         relative: bool = False,
+        absolute: bool = True,
         schemes: types.StrSequenceOrSet | None = None,
         require_tld: bool = True,
         **kwargs,
@@ -1717,10 +1718,12 @@ class Url(String):
         super().__init__(**kwargs)
 
         self.relative = relative
+        self.absolute = absolute
         self.require_tld = require_tld
         # Insert validation into self.validators so that multiple errors can be stored.
         validator = validate.URL(
             relative=self.relative,
+            absolute=self.absolute,
             schemes=schemes,
             require_tld=self.require_tld,
             error=self.error_messages["invalid"],
diff --git a/src/marshmallow/validate.py b/src/marshmallow/validate.py
index 35b4b064..3588a422 100644
--- a/src/marshmallow/validate.py
+++ b/src/marshmallow/validate.py
@@ -94,6 +94,7 @@ class URL(Validator):
     """Validate a URL.
 
     :param relative: Whether to allow relative URLs.
+    :param absolute: Whether to allow absolute URLs.
     :param error: Error message to raise in case of a validation error.
         Can be interpolated with `{input}`.
     :param schemes: Valid schemes. By default, ``http``, ``https``,
@@ -105,38 +106,68 @@ class URL(Validator):
         def __init__(self):
             self._memoized = {}
 
-        def _regex_generator(self, relative: bool, require_tld: bool) -> typing.Pattern:
-            return re.compile(
-                r"".join(
-                    (
-                        r"^",
-                        r"(" if relative else r"",
-                        r"(?:[a-z0-9\.\-\+]*)://",  # scheme is validated separately
-                        r"(?:[^:@]+?(:[^:@]*?)?@|)",  # basic auth
-                        r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+",
-                        r"(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|",  # domain...
-                        r"localhost|",  # localhost...
-                        (
-                            r"(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.?)|"
-                            if not require_tld
-                            else r""
-                        ),  # allow dotless hostnames
-                        r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|",  # ...or ipv4
-                        r"\[[A-F0-9]*:[A-F0-9:]+\])",  # ...or ipv6
-                        r"(?::\d+)?",  # optional port
-                        r")?"
-                        if relative
-                        else r"",  # host is optional, allow for relative URLs
-                        r"(?:/?|[/?]\S+)\Z",
-                    )
+        def _regex_generator(
+            self, relative: bool, absolute: bool, require_tld: bool
+        ) -> typing.Pattern:
+            hostname_variants = [
+                # a normal domain name, expressed in [A-Z0-9] chars with hyphens allowed only in the middle
+                # note that the regex will be compiled with IGNORECASE, so these are upper and lowercase chars
+                (
+                    r"(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+"
+                    r"(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)"
                 ),
-                re.IGNORECASE,
+                # or the special string 'localhost'
+                r"localhost",
+                # or IPv4
+                r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}",
+                # or IPv6
+                r"\[[A-F0-9]*:[A-F0-9:]+\]",
+            ]
+            if not require_tld:
+                # allow dotless hostnames
+                hostname_variants.append(r"(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.?)")
+
+            absolute_part = "".join(
+                (
+                    # scheme (e.g. 'https://', 'ftp://', etc)
+                    # this is validated separately against allowed schemes, so in the regex
+                    # we simply want to capture its existence
+                    r"(?:[a-z0-9\.\-\+]*)://",
+                    # basic_auth, for URLs encoding a username:password
+                    # e.g. 'ftp://foo:bar@ftp.example.org/'
+                    r"(?:[^:@]+?(:[^:@]*?)?@|)",
+                    # netloc, the hostname/domain part of the URL plus the optional port
+                    r"(?:",
+                    "|".join(hostname_variants),
+                    r")",
+                    r"(?::\d+)?",
+                )
             )
+            relative_part = r"(?:/?|[/?]\S+)\Z"
+
+            if relative:
+                if absolute:
+                    parts: tuple[str, ...] = (
+                        r"^(",
+                        absolute_part,
+                        r")?",
+                        relative_part,
+                    )
+                else:
+                    parts = (r"^", relative_part)
+            else:
+                parts = (r"^", absolute_part, relative_part)
 
-        def __call__(self, relative: bool, require_tld: bool) -> typing.Pattern:
-            key = (relative, require_tld)
+            return re.compile("".join(parts), re.IGNORECASE)
+
+        def __call__(
+            self, relative: bool, absolute: bool, require_tld: bool
+        ) -> typing.Pattern:
+            key = (relative, absolute, require_tld)
             if key not in self._memoized:
-                self._memoized[key] = self._regex_generator(relative, require_tld)
+                self._memoized[key] = self._regex_generator(
+                    relative, absolute, require_tld
+                )
 
             return self._memoized[key]
 
@@ -149,17 +180,23 @@ class URL(Validator):
         self,
         *,
         relative: bool = False,
+        absolute: bool = True,
         schemes: types.StrSequenceOrSet | None = None,
         require_tld: bool = True,
         error: str | None = None,
     ):
+        if not relative and not absolute:
+            raise ValueError(
+                "URL validation cannot set both relative and absolute to False."
+            )
         self.relative = relative
+        self.absolute = absolute
         self.error = error or self.default_message  # type: str
         self.schemes = schemes or self.default_schemes
         self.require_tld = require_tld
 
     def _repr_args(self) -> str:
-        return f"relative={self.relative!r}"
+        return f"relative={self.relative!r}, absolute={self.absolute!r}"
 
     def _format_error(self, value) -> str:
         return self.error.format(input=value)
@@ -175,7 +212,7 @@ class URL(Validator):
             if scheme not in self.schemes:
                 raise ValidationError(message)
 
-        regex = self._regex(self.relative, self.require_tld)
+        regex = self._regex(self.relative, self.absolute, self.require_tld)
 
         if not regex.search(value):
             raise ValidationError(message)


Test patch:
diff --git a/tests/test_validate.py b/tests/test_validate.py
index 0dc70c1e..9f6ae25d 100644
--- a/tests/test_validate.py
+++ b/tests/test_validate.py
@@ -75,6 +75,9 @@ def test_url_absolute_invalid(invalid_url):
         "http://example.com/./icons/logo.gif",
         "ftp://example.com/../../../../g",
         "http://example.com/g?y/./x",
+        "/foo/bar",
+        "/foo?bar",
+        "/foo?bar#baz",
     ],
 )
 def test_url_relative_valid(valid_url):
@@ -104,6 +107,48 @@ def test_url_relative_invalid(invalid_url):
         validator(invalid_url)
 
 
+@pytest.mark.parametrize(
+    "valid_url",
+    [
+        "/foo/bar",
+        "/foo?bar",
+        "?bar",
+        "/foo?bar#baz",
+    ],
+)
+def test_url_relative_only_valid(valid_url):
+    validator = validate.URL(relative=True, absolute=False)
+    assert validator(valid_url) == valid_url
+
+
+@pytest.mark.parametrize(
+    "invalid_url",
+    [
+        "http//example.org",
+        "http://example.org\n",
+        "suppliers.html",
+        "../icons/logo.gif",
+        "icons/logo.gif",
+        "../.../g",
+        "...",
+        "\\",
+        " ",
+        "",
+        "http://example.org",
+        "http://123.45.67.8/",
+        "http://example.com/foo/bar/../baz",
+        "https://example.com/../icons/logo.gif",
+        "http://example.com/./icons/logo.gif",
+        "ftp://example.com/../../../../g",
+        "http://example.com/g?y/./x",
+    ],
+)
+def test_url_relative_only_invalid(invalid_url):
+    validator = validate.URL(relative=True, absolute=False)
+    with pytest.raises(ValidationError):
+        validator(invalid_url)
+
+
 @pytest.mark.parametrize(
     "valid_url",
     [
@@ -170,10 +215,21 @@ def test_url_custom_message():
 def test_url_repr():
     assert repr(
         validate.URL(relative=False, error=None)
-    ) == "<URL(relative=False, error={!r})>".format("Not a valid URL.")
+    ) == "<URL(relative=False, absolute=True, error={!r})>".format("Not a valid URL.")
     assert repr(
         validate.URL(relative=True, error="foo")
-    ) == "<URL(relative=True, error={!r})>".format("foo")
+    ) == "<URL(relative=True, absolute=True, error={!r})>".format("foo")
+    assert repr(
+        validate.URL(relative=True, absolute=False, error="foo")
+    ) == "<URL(relative=True, absolute=False, error={!r})>".format("foo")
+
+
+def test_url_rejects_invalid_relative_usage():
+    with pytest.raises(
+        ValueError,
+        match="URL validation cannot set both relative and absolute to False",
+    ):
+        validate.URL(relative=False, absolute=False)
 
 
 @pytest.mark.parametrize(