dynamicweb/cms/forms/validators.py

117 lines
4.4 KiB
Python
Raw Normal View History

from typing import TYPE_CHECKING, Optional
from urllib.parse import urlsplit
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator, URLValidator
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext
from cms.utils.urlutils import admin_reverse, path_segment_regex, relative_url_regex
if TYPE_CHECKING:
# Only needed for type hinting - avoid circular import
from cms.models.pagemodel import Page
def validate_relative_url(value):
RegexValidator(regex=relative_url_regex)(value)
def validate_url(value):
if "<" in value or ">" in value:
# ``relative_url_regex`` excludes angle brackets, but the absolute fallback below
# is Django's ``URLValidator``, whose path pattern accepts them as long as the
# value carries no space (``https://example.com/<script>alert(1)</script>``).
# Reject them before either branch runs, so markup cannot enter through a scheme.
raise ValidationError(
gettext("Enter a valid relative or absolute URL."),
code="invalid",
)
try:
# Validate relative urls first
validate_relative_url(value)
except ValidationError:
# Fallback to absolute urls
URLValidator()(value)
else:
# ``relative_url_regex`` only constrains the *characters* of a relative
# path, not the part before the first slash. Values such as
# ``javascript:alert(1)/x``, ``data:text/html,...`` or ``vbscript:.../x``
# therefore match the relative branch while still carrying a scheme that
# executes script when the value is later rendered into an ``href``
# (CWE-79). A genuine relative URL has no scheme, so reject any value
# that does. (Off-site targets such as ``https://example.com`` or
# ``//example.com`` are intentionally permitted -- this field allows
# absolute redirects -- and navigate rather than execute script.)
if urlsplit(value).scheme:
raise ValidationError(
gettext("Enter a valid relative or absolute URL."),
code="invalid",
)
def validate_path_segment(value):
"""Validate a single path segment, i.e. a path that contains no "/"."""
RegexValidator(
regex=path_segment_regex,
message=gettext("Enter a valid relative or absolute URL."),
code="invalid",
)(value)
def validate_path(value):
"""Validate a page path.
Unlike :func:`validate_url` this also accepts a single path segment such as ``contact``,
which ``relative_url_regex`` -- and therefore :func:`validate_url` -- never matches.
"""
if "/" in value:
validate_url(value)
else:
validate_path_segment(value)
def validate_url_uniqueness(
site, path: str, language: str, user_language: str | None = None, exclude_page: Optional["Page"] = None
):
"""Checks for conflicting urls"""
from cms.models.pagemodel import PageUrl
# Single-segment paths used to skip validation entirely, so an "Overwrite URL" without a
# "/" could hold arbitrary characters.
validate_path(path)
path = path.strip("/")
page_urls = PageUrl.objects.get_for_site(site, language=language).filter(path=path)
if exclude_page:
page_urls = page_urls.exclude(page=exclude_page.pk)
try:
conflict_page = page_urls[0].page
except IndexError:
return True
conflict_translation = conflict_page.get_content_obj(language, fallback=False)
if conflict_translation: # No empty page content
change_url = admin_reverse("cms_pagecontent_change", args=[conflict_translation.pk])
else:
change_url = "" # Empty page has no slug
if user_language:
change_url += f"?language={user_language}"
conflict_url = f'<a href="{change_url}" target="_blank">{escape(str(conflict_translation.title))}</a>'
if exclude_page:
message = gettext("Page %(conflict_page)s has the same url '%(url)s' as current page \"%(instance)s\".")
else:
message = gettext("Page %(conflict_page)s has the same url '%(url)s' as current page.")
message = message % {
"conflict_page": conflict_url,
"url": escape(path),
"instance": escape(exclude_page.get_title(language)) if exclude_page else "",
}
raise ValidationError(mark_safe(message))