PluginProbe ʕ •ᴥ•ʔ
FrontBlocks for Gutenberg/GeneratePress / trunk
FrontBlocks for Gutenberg/GeneratePress vtrunk
trunk 0.2.0 0.2.1 0.2.2 0.2.3 0.2.4 0.2.5 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.2.0 1.2.1 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 ci-artifacts
frontblocks / includes / Frontend / SvgUpload.php
frontblocks / includes / Frontend Last commit date
Animations.php 1 month ago BackButton.php 7 months ago BeforeAfter.php 1 month ago BlockPatterns.php 4 months ago Carousel.php 1 week ago ColumnsSameHeight.php 1 week ago ContainerEdgeAlignment.php 4 weeks ago Counter.php 1 month ago DownloadButton.php 1 week ago Events.php 4 weeks ago FaqSchema.php 1 week ago FluidTypography.php 4 weeks ago Gallery.php 8 months ago GravityFormsInline.php 1 month ago Headline.php 4 weeks ago InsertPost.php 1 month ago ProductCategories.php 4 weeks ago ReadingProgress.php 7 months ago ReadingTime.php 8 months ago ShapeAnimations.php 4 weeks ago StackedImages.php 4 months ago StickyColumn.php 4 weeks ago SvgUpload.php 4 weeks ago Testimonials.php 8 months ago TextAnimation.php 1 month ago UserText.php 4 weeks ago
SvgUpload.php
248 lines
1 <?php
2 /**
3 * SVG Upload
4 *
5 * Allows SVG files to be uploaded to the WordPress media library.
6 * Sanitizes SVG content on upload to prevent XSS attacks.
7 *
8 * @package FrontBlocks
9 * @author Closemarketing
10 * @copyright 2025 Closemarketing
11 * @version 1.0.0
12 */
13
14 namespace FrontBlocks\Frontend;
15
16 defined( 'ABSPATH' ) || exit;
17
18 /**
19 * SVG Upload class.
20 *
21 * Enables SVG uploads in the media library with server-side sanitization.
22 */
23 class SvgUpload {
24
25 /**
26 * Constructor.
27 */
28 public function __construct() {
29 $this->init_hooks();
30 }
31
32 /**
33 * Initialize hooks.
34 *
35 * @return void
36 */
37 private function init_hooks() {
38 add_filter( 'upload_mimes', array( $this, 'add_svg_mime' ) );
39 add_filter( 'wp_check_filetype_and_ext', array( $this, 'fix_svg_mime_check' ), 10, 3 );
40 add_filter( 'wp_handle_upload_prefilter', array( $this, 'sanitize_svg_upload' ) );
41 add_filter( 'wp_prepare_attachment_for_js', array( $this, 'fix_svg_in_media_library' ), 10, 2 );
42 }
43
44 /**
45 * Add SVG to allowed upload MIME types.
46 *
47 * @param array $mimes Allowed MIME types.
48 * @return array
49 */
50 public function add_svg_mime( $mimes ) {
51 $mimes['svg'] = 'image/svg+xml';
52 $mimes['svgz'] = 'image/svg+xml';
53 return $mimes;
54 }
55
56 /**
57 * Fix MIME type detection for SVG files.
58 *
59 * PHP's finfo often misidentifies SVGs; this corrects the check.
60 *
61 * @param array $data File data.
62 * @param string $file Full path to the file.
63 * @param string $filename Original filename.
64 * @return array
65 */
66 public function fix_svg_mime_check( $data, $file, $filename ) {
67 if ( ! empty( $data['ext'] ) && ! empty( $data['type'] ) ) {
68 return $data;
69 }
70
71 $ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
72
73 if ( 'svg' === $ext || 'svgz' === $ext ) {
74 $data['ext'] = $ext;
75 $data['type'] = 'image/svg+xml';
76 }
77
78 return $data;
79 }
80
81 /**
82 * Sanitize SVG file on upload.
83 *
84 * Strips dangerous elements and event-handler attributes before saving.
85 *
86 * @param array $file Upload data.
87 * @return array
88 */
89 public function sanitize_svg_upload( $file ) {
90 if ( 'image/svg+xml' !== $file['type'] ) {
91 return $file;
92 }
93
94 if ( ! current_user_can( 'manage_options' ) ) {
95 $file['error'] = __( 'Permission denied: only administrators can upload SVG files.', 'frontblocks' );
96 return $file;
97 }
98
99 $svg_content = file_get_contents( $file['tmp_name'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
100
101 if ( false === $svg_content ) {
102 $file['error'] = __( 'Could not read the SVG file.', 'frontblocks' );
103 return $file;
104 }
105
106 $sanitized = $this->sanitize_svg( $svg_content );
107
108 if ( false === $sanitized ) {
109 $file['error'] = __( 'Invalid SVG file: the file does not appear to be a valid SVG.', 'frontblocks' );
110 return $file;
111 }
112
113 file_put_contents( $file['tmp_name'], $sanitized ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
114
115 return $file;
116 }
117
118 /**
119 * Sanitize SVG content by removing dangerous elements and attributes.
120 *
121 * @param string $content Raw SVG content.
122 * @return string|false Sanitized SVG, or false if invalid.
123 */
124 private function sanitize_svg( $content ) {
125 if ( empty( trim( $content ) ) ) {
126 return false;
127 }
128
129 // Remove PHP processing instructions (not XML declarations).
130 $content = preg_replace( '/<\?(?!xml\s).*?\?>/si', '', $content );
131
132 $dom = new \DOMDocument();
133 libxml_use_internal_errors( true );
134 $dom->loadXML( $content, LIBXML_NONET );
135 libxml_clear_errors();
136 libxml_use_internal_errors( false );
137
138 // Verify it's actually an SVG.
139 $svg_elements = $dom->getElementsByTagName( 'svg' );
140 if ( 0 === $svg_elements->length ) {
141 return false;
142 }
143
144 // Tags that could execute code or load external content.
145 $dangerous_tags = array(
146 'script',
147 'iframe',
148 'object',
149 'embed',
150 'base',
151 'form',
152 'input',
153 'button',
154 'textarea',
155 'select',
156 'option',
157 'link',
158 'meta',
159 );
160
161 foreach ( $dangerous_tags as $tag ) {
162 $elements = $dom->getElementsByTagName( $tag );
163 $to_remove = array();
164 foreach ( $elements as $element ) {
165 $to_remove[] = $element;
166 }
167 foreach ( $to_remove as $element ) {
168 if ( $element->parentNode ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
169 $element->parentNode->removeChild( $element ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
170 }
171 }
172 }
173
174 // Collect all elements before iterating (live NodeList can skip nodes).
175 $all_elements = $dom->getElementsByTagName( '*' );
176 $to_process = array();
177 foreach ( $all_elements as $element ) {
178 $to_process[] = $element;
179 }
180
181 foreach ( $to_process as $element ) {
182 $attrs_to_remove = array();
183
184 foreach ( $element->attributes as $attr ) {
185 $attr_lower = strtolower( $attr->nodeName ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
186
187 // Remove all on* event handlers.
188 if ( 0 === strpos( $attr_lower, 'on' ) ) {
189 $attrs_to_remove[] = $attr->nodeName; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
190 continue;
191 }
192
193 // Remove javascript: and data: URIs from URL attributes.
194 if ( in_array( $attr_lower, array( 'href', 'xlink:href', 'src', 'action', 'formaction', 'data' ), true ) ) {
195 $value = ltrim( preg_replace( '/\s+/', '', $attr->value ) );
196 if ( 0 === stripos( $value, 'javascript:' ) || 0 === stripos( $value, 'data:text' ) ) {
197 $attrs_to_remove[] = $attr->nodeName; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
198 }
199 }
200 }
201
202 foreach ( $attrs_to_remove as $attr_name ) {
203 $element->removeAttribute( $attr_name );
204 }
205 }
206
207 return $dom->saveXML();
208 }
209
210 /**
211 * Fix SVG display in the media library JS modal.
212 *
213 * SVGs have no raster dimensions; provide fallback values so the
214 * media library can render a thumbnail without throwing JS errors.
215 *
216 * @param array $response Attachment data for JS.
217 * @param \WP_Post $attachment Attachment post object.
218 * @return array
219 */
220 public function fix_svg_in_media_library( $response, $attachment ) {
221 if ( 'image/svg+xml' !== $response['mime'] ) {
222 return $response;
223 }
224
225 if ( empty( $response['width'] ) ) {
226 $response['width'] = 100;
227 }
228
229 if ( empty( $response['height'] ) ) {
230 $response['height'] = 100;
231 }
232
233 if ( empty( $response['sizes'] ) ) {
234 $svg_url = wp_get_attachment_url( $attachment->ID );
235 $response['sizes'] = array(
236 'full' => array(
237 'url' => $svg_url,
238 'width' => $response['width'],
239 'height' => $response['height'],
240 'orientation' => 'landscape',
241 ),
242 );
243 }
244
245 return $response;
246 }
247 }
248