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:
Maybe an additional flag `absolute` that would default to `True`?

Would you like to work on this?
@lafrech Thank you for commenting. 

My thinking would be that flavors could be selected individually, as if flags are used, so they could be combined. Something along these lines:

```
args = {
    "ref": fields.URL(kind=fields.URL.absolute | fields.URL.relative)
    # OR
    "ref": fields.URL(kind=["absolute", "relative"])
```

This also would allow to retain backward compatibility for existing `relative=True|False`, which would be translated to these flags combined or just absolute flag being used.

An extra Boolean would work fine as well.  It would be similar to how Joi handles it:

https://joi.dev/api/?v=17.9.1#stringurioptions

As for me fixing it, I'm not sure - the evaluation of the attribute for relative URLs is integrated in a pretty intricate regex construction with those Booleans used as keys. This change sounds bigger than I could commit to at this point. My apologies. Please feel free to close this issue if there's no one else interested in it.
I'm not very proficient with regexes, although the change involved here should be marginal (the regex is already conditional due to the absolute flag).

I won't take the time to do it but I don't mind keeping this open for now.

Patch:
diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py
--- a/src/marshmallow/fields.py
+++ b/src/marshmallow/fields.py
@@ -1710,6 +1710,7 @@ def __init__(
         self,
         *,
         relative: bool = False,
+        absolute: bool = True,
         schemes: types.StrSequenceOrSet | None = None,
         require_tld: bool = True,
         **kwargs,
@@ -1717,10 +1718,12 @@ def __init__(
         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
--- 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 RegexMemoizer:
         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 @@ def __init__(
         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 @@ def __call__(self, value: str) -> str:
             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
--- 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(