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/``). # 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'{escape(str(conflict_translation.title))}' 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))