Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Sanitizer
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 5
462
0.00% covered (danger)
0.00%
0 / 1
 sanitizeText
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 resolveAttributes
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 sanitizeAttributeValue
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 sanitizeUrl
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 unparseUrl
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2declare( strict_types = 1 );
3
4/**
5 * Sanitizer.php
6 *
7 * This file is part of the Codex design system, the official design system
8 * for Wikimedia projects. It provides the `Sanitizer` class, which is responsible
9 * for sanitizing data before rendering. The Sanitizer ensures that all output is safe
10 * and helps prevent XSS and other security vulnerabilities.
11 *
12 * The Sanitizer class includes methods for sanitizing text, HTML content, and HTML attributes.
13 * By centralizing the sanitization logic, it adheres to the Single Responsibility Principle
14 * and enhances the maintainability and security of the codebase.
15 *
16 * @category Utility
17 * @package  Codex\Utility
18 * @since    0.1.0
19 * @author   Doğu Abaris <abaris@null.net>
20 * @license  https://www.gnu.org/copyleft/gpl.html GPL-2.0-or-later
21 * @link     https://doc.wikimedia.org/codex/main/ Codex Documentation
22 */
23
24namespace Wikimedia\Codex\Utility;
25
26use Wikimedia\Codex\Component\HtmlSnippet;
27
28/**
29 * Sanitizer is a class responsible for sanitizing data before rendering.
30 *
31 * This class provides methods to sanitize text, HTML content, and attributes.
32 * It ensures that all data outputted to the user is properly sanitized, preventing XSS
33 * and other injection attacks.
34 *
35 * @category Utility
36 * @package  Codex\Utility
37 * @since    0.1.0
38 * @author   Doğu Abaris <abaris@null.net>
39 * @license  https://www.gnu.org/copyleft/gpl.html GPL-2.0-or-later
40 * @link     https://doc.wikimedia.org/codex/main/ Codex Documentation
41 */
42class Sanitizer {
43
44    /**
45     * Sanitize a plain text string.
46     *
47     * This method escapes special HTML characters in a string to prevent XSS attacks.
48     * It should be used when the content does not contain any HTML markup and needs
49     * to be treated strictly as text.
50     *
51     * @since 0.1.0
52     * @param string|HtmlSnippet|null $textOrSnippet The plain text to sanitize.
53     * @return string The sanitized text.
54     */
55    public function sanitizeText( $textOrSnippet ): string {
56        if ( $textOrSnippet instanceof HtmlSnippet ) {
57            return $textOrSnippet->getContent();
58        }
59        return htmlspecialchars( $textOrSnippet ?? '', ENT_QUOTES, 'UTF-8' );
60    }
61
62    /**
63     * Resolves an associative array of HTML attributes into a string for an HTML tag.
64     * Boolean attributes (like `disabled`) are rendered without a value.
65     * Array-based attributes (like `class`) are concatenated into a single string, separated by
66     * spaces. This method also handles escaping.
67     *
68     * @since 0.1.0
69     * @param array $attributes Key-value pairs of HTML attributes.
70     * @return string The attributes as a string, ready to be included in an HTML tag.
71     */
72    public function resolveAttributes( array $attributes ): string {
73        $resolvedAttributes = [];
74
75        foreach ( $attributes as $key => $value ) {
76            $escKey = $this->sanitizeText( $key );
77
78            // If the value is true, include the key as an attribute without a value.
79            if ( $value === true ) {
80                $resolvedAttributes[] = $escKey;
81            } else {
82                $escValue = $this->sanitizeAttributeValue( $value );
83                $resolvedAttributes[] = "$escKey=\"$escValue\"";
84            }
85        }
86
87        return implode( ' ', $resolvedAttributes );
88    }
89
90    /**
91     * Sanitize a single attribute value. Most code should not use this, but should use
92     * resolveAttributes() instead.
93     * @param string|string[] $attrValue Plain text attribute value, or array of plain text values
94     * @return string Escaped attribute value, safe for use in an HTML attribute string.
95     *   This does NOT include the attribute name, or quotes.
96     */
97    public function sanitizeAttributeValue( string|array $attrValue ): string {
98        if ( is_array( $attrValue ) ) {
99            $attrValue = implode( ' ', $attrValue );
100        }
101        return $this->sanitizeText( $attrValue );
102    }
103
104    /**
105     * Sanitize a URL.
106     *
107     * This method ensures the URL is safe by validating it, removing illegal characters,
108     * and ensuring it uses an allowed scheme. This function does not escape it for HTML output,
109     * to do that either use sanitizeText() or use Mustache's built-in escaping with `{{ url }}`.
110     *
111     * @since 0.1.0
112     * @param string|null $url The URL to sanitize.
113     * @return string The sanitized URL.
114     */
115    public function sanitizeUrl( ?string $url ): string {
116        if ( $url === null || $url === '' ) {
117            return '';
118        }
119
120        $sanitizedUrl = filter_var( $url, FILTER_SANITIZE_URL );
121
122        if ( !filter_var( $sanitizedUrl, FILTER_VALIDATE_URL ) ) {
123            return '';
124        }
125
126        $parsedUrl = parse_url( $sanitizedUrl );
127
128        $allowedSchemes = [ 'http', 'https' ];
129        if (
130            !isset( $parsedUrl['scheme'] ) ||
131            !in_array( strtolower( $parsedUrl['scheme'] ), $allowedSchemes, true )
132        ) {
133            return '';
134        }
135
136        return $this->unparseUrl( $parsedUrl );
137    }
138
139    /**
140     * Helper function to rebuild a URL from its parsed components.
141     *
142     * @since 0.1.0
143     * @param array $parsedUrl The parsed URL components.
144     * @return string The reconstructed URL.
145     */
146    private function unparseUrl( array $parsedUrl ): string {
147        $scheme   = isset( $parsedUrl['scheme'] ) ? $parsedUrl['scheme'] . '://' : '';
148        $host     = $parsedUrl['host'] ?? '';
149        $port     = isset( $parsedUrl['port'] ) ? ':' . $parsedUrl['port'] : '';
150        $user     = $parsedUrl['user'] ?? '';
151        $pass     = isset( $parsedUrl['pass'] ) ? ':' . $parsedUrl['pass'] : '';
152        $pass     = ( $user || $pass ) ? "$pass@" : '';
153        $path     = $parsedUrl['path'] ?? '';
154        $query    = isset( $parsedUrl['query'] ) ? '?' . $parsedUrl['query'] : '';
155        $fragment = isset( $parsedUrl['fragment'] ) ? '#' . $parsedUrl['fragment'] : '';
156
157        return "$scheme$user$pass$host$port$path$query$fragment";
158    }
159}