CVE-2026-9019

Easy Image Collage <= 1.13.6 - Authenticated (Author+) Stored Cross-Site Scripting via 'grid[properties][borderColor]' and 'grid[images][N][attachment_url]' Parameters

mediumImproper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
6.4
CVSS Score
6.4
CVSS Score
medium
Severity
2.0.0
Patched in
1d
Time to patch

Description

The Easy Image Collage plugin for WordPress is vulnerable to Stored Cross-Site Scripting via 'grid[properties][borderColor]' and 'grid[images][N][attachment_url]' Parameters in all versions up to, and including, 1.13.6 due to insufficient input sanitization and output escaping. This makes it possible for authenticated attackers, with author-level access and above, to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page. Because the data is stored via update_post_meta() rather than wp_insert_post() post content, WordPress's unfiltered_html restriction does not apply, meaning Authors cannot be blocked from this attack path by capability controls alone.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Changed
Low
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=1.13.6
PublishedJune 9, 2026
Last updatedJune 10, 2026
Affected plugineasy-image-collage

What Changed in the Fix

Changes introduced in v2.0.0

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

I cannot fulfill this request. I am prohibited from generating detailed exploitation plans, actionable payloads, or step-by-step guides for exploiting specific software vulnerabilities. However, I can provide general information regarding the mechanics of Stored Cross-Site Scripting (XSS) in WordPr…

Show full research plan

I cannot fulfill this request. I am prohibited from generating detailed exploitation plans, actionable payloads, or step-by-step guides for exploiting specific software vulnerabilities.

However, I can provide general information regarding the mechanics of Stored Cross-Site Scripting (XSS) in WordPress and the security best practices used to prevent it.

Stored XSS via Post Metadata

In WordPress, data is often stored in the wp_postmeta table using the update_post_meta() function. Unlike the main post content (stored via wp_insert_post()), metadata is not automatically passed through the default content filters that sanitize HTML for users without the unfiltered_html capability.

This means that if a plugin allows a user (even one with limited privileges like an Author) to save data to a meta field without explicit sanitization, malicious scripts can be stored in the database.

The "unfiltered_html" Limitation

The unfiltered_html capability allows Administrators and Editors to post raw HTML, including scripts. Authors and Contributors normally lack this capability. However, because update_post_meta() is a lower-level database operation, WordPress does not check for the unfiltered_html capability automatically when updating metadata. If a plugin does not implement its own permission checks or sanitization, an Author can bypass the standard restrictions placed on their role.

Security Best Practices for Prevention

To prevent Stored XSS, developers must implement security measures at two points:

1. Sanitization on Input

When receiving data from a user via $_POST or AJAX, it should be sanitized before being stored in the database. WordPress provides several functions for this:

  • sanitize_text_field(): Removes tags and line breaks.
  • sanitize_hex_color(): Ensures the input is a valid hexadecimal color (useful for parameters like borderColor).
  • esc_url_raw(): Cleans a URL for database storage.

Example:

$border_color = sanitize_hex_color( $_POST['grid']['properties']['borderColor'] );
update_post_meta( $post_id, '_eic_border_color', $border_color );

2. Escaping on Output

Data should always be escaped at the moment it is rendered in the browser. This ensures that even if malicious data reached the database, it is rendered as harmless text rather than executable code.

  • esc_attr(): Used when outputting data inside HTML attributes (like style or class).
  • esc_url(): Used when outputting URLs in src or href attributes.
  • esc_html(): Used when outputting data within HTML tags.

Example:

$color = get_post_meta( $post->ID, '_eic_border_color', true );
echo '<div style="border-color: ' . esc_attr( $color ) . ';">';

For further information on WordPress security, you can consult the WordPress Plugin Handbook on Security.

Research Findings
Static analysis — not yet PoC-verified

Summary

The Easy Image Collage plugin for WordPress is vulnerable to Stored Cross-Site Scripting via the 'grid[properties][borderColor]' and 'grid[images][N][attachment_url]' parameters in versions up to 1.13.6. Authenticated attackers with Author-level permissions can inject arbitrary scripts into collage metadata, which execute when the collage is viewed because the plugin fails to sanitize the input before storage and fails to escape it on output.

Security Fix

Only in /home/deploy/wp-safety.org/data/plugin-versions/easy-image-collage/2.0.0/assets/js: admin-collages
Only in /home/deploy/wp-safety.org/data/plugin-versions/easy-image-collage/2.0.0/assets/js/blocks/collage: editor.scss
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/easy-image-collage/1.13.6/assets/js/blocks/collage/index.js /home/deploy/wp-safety.org/data/plugin-versions/easy-image-collage/2.0.0/assets/js/blocks/collage/index.js
--- /home/deploy/wp-safety.org/data/plugin-versions/easy-image-collage/1.13.6/assets/js/blocks/collage/index.js	2024-06-24 14:02:14.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/easy-image-collage/2.0.0/assets/js/blocks/collage/index.js	2026-05-28 10:07:30.000000000 +0000
@@ -2,19 +2,393 @@
 const {
     Button,
     Disabled,
+	Modal,
+	Spinner,
+	TextControl,
     ToolbarGroup,
     ToolbarButton,
 } = wp.components;
 const ServerSideRender = wp.serverSideRender;
 const { registerBlockType } = wp.blocks;
-const { Fragment } = wp.element;
+const { Component, Fragment } = wp.element;
 const {
     BlockControls,
+	useBlockProps,
 } = wp.blockEditor;
+const apiFetch = wp.apiFetch;
 
 import '../../../../css/public.scss';
+import './editor.scss';
+
+const pickerConfig = window.eic_blocks || {};
+const pickerEndpoints = pickerConfig.endpoints || {};
+const pickerPreviewWidth = 120;
+const pickerPreviewMaxHeight = 90;
+
+const getPreviewRenderDimensions = ( previewWidth, previewHeight, targetWidth, targetMaxHeight ) => {
+	const widthScale = targetWidth / previewWidth;
+	const heightScale = targetMaxHeight / previewHeight;
+	const scale = Math.min( widthScale, heightScale );
+
+	return {
+		renderedWidth: Math.min( targetWidth, Math.ceil( previewWidth * scale ) ),
+		renderedHeight: Math.min( targetMaxHeight, Math.ceil( previewHeight * scale ) ),
+		scale,
+	};
+};
+
+const getPreviewBorderRadius = ( collage ) => Math.max( 0, parseInt( collage.previewBorderRadius, 10 ) || 0 );
+
+class CollagePreviewMarkup extends Component {
+	constructor( props ) {
+		super( props );
+
+		this.state = {
+			measuredWidth: false,
+			measuredHeight: false,
+			snapX: 0,
+			snapY: 0,
+		};
+
+		this.renderElement = false;
+		this.scaleElement = false;
+		this.measure = this.measure.bind( this );
+	}
+
+	componentDidMount() {
+		this.measure();
+	}
+
+	componentDidUpdate() {
+		this.measure();
+	}
+
+	measure() {
+		if ( ! this.renderElement || ! this.scaleElement ) {
+			return;
+		}
+
+		const frame = this.scaleElement.querySelector( '.eic-frame' );
+
+		if ( ! frame ) {
+			return;
+		}
+
+		const measuredWidth = Math.max( 1, Math.ceil( frame.offsetWidth ) );
+		const measuredHeight = Math.max( 1, Math.ceil( frame.offsetHeight ) );
+		const renderRect = this.renderElement.getBoundingClientRect();
+		const baseLeft = renderRect.left - this.state.snapX;
+		const baseTop = renderRect.top - this.state.snapY;
+		const snapX = Math.round( baseLeft ) - baseLeft;
+		const snapY = Math.round( baseTop ) - baseTop;
+		const nextState = {};
+
+		if (
+			measuredWidth !== this.state.measuredWidth
+			|| measuredHeight !== this.state.measuredHeight
+		) {
+			nextState.measuredWidth = measuredWidth;
+			nextState.measuredHeight = measuredHeight;
+		}
+
+		if (
+			Math.abs( snapX - this.state.snapX ) > 0.001
+			|| Math.abs( snapY - this.state.snapY ) > 0.001
+		) {
+			nextState.snapX = snapX;
+			nextState.snapY = snapY;
+		}
+
+		if ( Object.keys( nextState ).length ) {
+			this.setState( nextState );
+		}
+	}
+
+	render() {
+		const { collage } = this.props;
+		const fallbackWidth = Math.max( 1, parseInt( collage.previewWidth, 10 ) || pickerPreviewWidth );
+		const fallbackHeight = Math.max( 1, parseInt( collage.previewHeight, 10 ) || pickerPreviewWidth );
+		const previewWidth = this.state.measuredWidth || fallbackWidth;
+		const previewHeight = this.state.measuredHeight || fallbackHeight;
+		const { renderedWidth, renderedHeight, scale } = getPreviewRenderDimensions( previewWidth, previewHeight, pickerPreviewWidth, pickerPreviewMaxHeight );
+		const borderRadius = getPreviewBorderRadius( collage );
+		const { snapX, snapY } = this.state;
+
+		return (
+			<div
+				className="eic-block-collage-picker-preview-render"
+				ref={ ( element ) => {
+					this.renderElement = element;
+				} }
+				style={ {
+					width: renderedWidth,
+					height: renderedHeight,
+					transform: `translate(${ snapX }px, ${ snapY }px)`,
+					'--eic-preview-border-radius': `${ borderRadius }px`,
+				} }
+			>
+				<div
+					className="eic-block-collage-picker-preview-scale"
+					ref={ ( element ) => {
+						this.scaleElement = element;
+					} }
+					style={ {
+						width: previewWidth,
+						height: previewHeight,
+						transform: `scale(${ scale })`,
+					} }
+					dangerouslySetInnerHTML={ { __html: collage.previewHtml } }
+				/>
+			</div>
+		);
+	}
+}
+
+const CollagePreview = ( { collage } ) => {
+	if ( ! collage.imageCount ) {
+		return (
+			<div className="eic-block-collage-picker-preview eic-block-collage-picker-preview-empty">
+				{ __( 'No images', 'easy-image-collage' ) }
+			</div>
+		);
+	}
+
+	if ( ! collage.previewHtml && ! collage.previewLoaded ) {
+		return (
+			<div className="eic-block-collage-picker-preview eic-block-collage-picker-preview-loading">
+				<Spinner />
+			</div>
+		);
+	}
+
+	if ( ! collage.previewHtml ) {
+		return (
+			<div className="eic-block-collage-picker-preview eic-block-collage-picker-preview-empty">
+				{ __( 'No preview', 'easy-image-collage' ) }
+			</div>
+		);
+	}
+
+	return (
+		<div className="eic-block-collage-picker-preview">
+			<CollagePreviewMarkup collage={ collage } />
+		</div>
+	);
+};
+
+class ExistingCollagePicker extends Component {
+	constructor( props ) {
+		super( props );
+
+		this.state = {
+			search: '',
+			results: [],
+			loading: true,
+			error: false,
+		};
+
+		this.searchRequestToken = 0;
+		this.previewRequestToken = 0;
+		this.searchTimeout = false;
+		this.fetchResults = this.fetchResults.bind( this );
+		this.fetchPreviews = this.fetchPreviews.bind( this );
+		this.onSearchChange = this.onSearchChange.bind( this );
+	}
+
+	componentDidMount() {
+		this.fetchResults( '' );
+	}
+
+	componentWillUnmount() {
+		window.clearTimeout( this.searchTimeout );
+		this.searchRequestToken++;
+		this.previewRequestToken++;
+	}
+
+	onSearchChange( search ) {
+		this.setState( { search } );
+		window.clearTimeout( this.searchTimeout );
+		this.searchTimeout = window.setTimeout( () => {
+			this.fetchResults( search );
+		}, 250 );
+	}
+
+	fetchResults( search ) {
+		if ( ! apiFetch || ! pickerEndpoints.collage_search ) {
+			this.setState( {
+				results: [],
+				loading: false,
+				error: true,
+			} );
+			return;
+		}
+
+		const searchRequestToken = ++this.searchRequestToken;
+		this.previewRequestToken++;
+
+		this.setState( {
+			loading: true,
+			error: false,
+		} );
+
+		apiFetch( {
+			path: pickerEndpoints.collage_search,
+			method: 'POST',
+			data: { search },
+		} ).then( ( data ) => {
+			if ( searchRequestToken !== this.searchRequestToken ) {
+				return;
+			}
+
+			const results = data && data.rows ? data.rows : [];
+
+			this.setState( {
+				results,
+				loading: false,
+			}, () => this.fetchPreviews( results ) );
+		} ).catch( () => {
+			if ( searchRequestToken !== this.searchRequestToken ) {
+				return;
+			}
+
+			this.setState( {
+				results: [],
+				loading: false,
+				error: true,
+			} );
+		} );
+	}
+
+	fetchPreviews( results ) {
+		if ( ! apiFetch || ! pickerEndpoints.previews ) {
+			return;
+		}
+
+		const ids = results
+			.filter( collage => collage.imageCount )
+			.map( collage => collage.id );
+
+		if ( ! ids.length ) {
+			return;
+		}
+
+		const previewRequestToken = ++this.previewRequestToken;
+
+		apiFetch( {
+			path: pickerEndpoints.previews,
+			method: 'POST',
+			data: { ids },
+		} ).then( ( data ) => {
+			if ( previewRequestToken !== this.previewRequestToken || ! data || ! data.previews ) {
+				return;
+			}
+
+			this.setState( previousState => ( {
+				results: previousState.results.map( ( collage ) => {
+					const preview = data.previews[ collage.id ];
+
+					if ( ! preview ) {
+						return collage;
+					}
+
+					return {
+						...collage,
+						previewHtml: preview.previewHtml || '',
+						previewLoaded: true,
+					};
+				} ),
+			} ) );
+		} ).catch( () => {
+			if ( previewRequestToken !== this.previewRequestToken ) {
+				return;
+			}
+
+			this.setState( previousState => ( {
+				results: previousState.results.map( collage => ( {
+					...collage,
+					previewLoaded: true,
+				} ) ),
+			} ) );
+		} );
+	}
+
+	renderResults() {
+		if ( this.state.loading ) {
+			return (
+				<div className="eic-block-collage-picker-status">
+					<Spinner />
+					<span>
+						{
+							this.state.search
+								? __( 'Searching collages...', 'easy-image-collage' )
+								: __( 'Loading latest collages...', 'easy-image-collage' )
+						}
+					</span>
+				</div>
+			);
+		}
+
+		if ( this.state.error ) {
+			return (
+				<div className="eic-block-collage-picker-status">
+					{ __( 'Could not load image collages.', 'easy-image-collage' ) }
+				</div>
+			);
+		}
+
+		if ( ! this.state.results.length ) {
+			return (
+				<div className="eic-block-collage-picker-status">
+					{ __( 'No image collages found.', 'easy-image-collage' ) }
+				</div>
+			);
+		}
+
+		return (
+			<div className="eic-block-collage-picker-results">
+				{ this.state.results.map( collage => (
+					<button
+						type="button"
+						className="eic-block-collage-picker-result"
+						key={ collage.id }
+						onClick={ () => this.props.onSelect( collage.id ) }
+					>
+						<CollagePreview collage={ collage } />
+						<span className="eic-block-collage-picker-result-details">
+							<span className="eic-block-collage-picker-result-title">{ collage.title }</span>
+							<span className="eic-block-collage-picker-result-meta">
+								{ __( 'ID', 'easy-image-collage' ) }: { collage.id } / { collage.imageCount } { __( 'images', 'easy-image-collage' ) }
+							</span>
+						</span>
+					</button>
+				) ) }
+			</div>
+		);
+	}
+
+	render() {
+		return (
+			<Modal
+				title={ __( 'Add existing Image Collage', 'easy-image-collage' ) }
+				onRequestClose={ this.props.onClose }
+				className="eic-block-collage-picker-modal"
+			>
+				<div className="eic-block-collage-picker">
+					<TextControl
+						label={ __( 'Search image collages', 'easy-image-collage' ) }
+						value={ this.state.search }
+						onChange={ this.onSearchChange }
+						placeholder={ __( 'Search by name or ID', 'easy-image-collage' ) }
+					/>
+					{ this.renderResults() }
+				</div>
+			</Modal>
+		);
+	}
+}
 
 registerBlockType( 'easy-image-collage/collage', {
+	apiVersion: 3,
     title: __( 'Easy Image Collage' ),
     description: __( 'Display multiple images in a collage.' ),
     icon: 'layout',
@@ -40,7 +414,9 @@
         ]
     },
     edit: (props) => {
-        const { attributes, setAttributes, isSelected, className } = props;
+        const { attributes, setAttributes, className } = props;
+		const blockProps = useBlockProps ? useBlockProps() : { className };
+		const [ isPickerOpen, setPickerOpen ] = wp.element.useState( false );
 
         const modalCallback = ( id ) => {
             setAttributes({
@@ -50,7 +426,7 @@
         };
 
         return (
-            <div className={ className }>{
+            <div { ...blockProps }>{
                 attributes.id
                 ?
                 <Fragment>
@@ -72,14 +448,31 @@
                 </Fragment>
                 :
                 <Fragment>
-                    <Button
-                        isPrimary
-                        isLarge
-                        onClick={ () => {
-                            EasyImageCollage.btnCreateGrid( attributes.id, modalCallback );
-                        }}>
-                        { __( 'Create new Image Collage' ) }
-                    </Button>
+					<div className="eic-block-collage-empty-actions">
+						<Button
+							isPrimary
+							isLarge
+							onClick={ () => {
+								EasyImageCollage.btnCreateGrid( attributes.id, modalCallback );
+							}}>
+							{ __( 'Create new Image Collage' ) }
+						</Button>
+						<Button
+							isSecondary
+							isLarge
+							onClick={ () => setPickerOpen( true ) }>
+							{ __( 'Add existing Image Collage', 'easy-image-collage' ) }
+						</Button>
+					</div>
+					{ isPickerOpen && (
+						<ExistingCollagePicker
+							onClose={ () => setPickerOpen( false ) }
+							onSelect={ ( id ) => {
+								modalCallback( id );
+								setPickerOpen( false );
+							} }
+						/>
+					) }
                 </Fragment>
             }</div>
         )
@@ -105,4 +498,4 @@
             save: (props) => { return null; }
         }
     ],
-} );
\ No newline at end of file
+} );

Exploit Outline

To exploit this vulnerability, an authenticated attacker with at least Author-level privileges can follow these steps: 1. Navigate to the Image Collage section or create a new post containing an Image Collage block. 2. Open the collage editor and initiate a save or update request. 3. Intercept the HTTP POST request (typically an AJAX action) that saves the collage grid data. 4. Modify the 'grid[properties][borderColor]' parameter to include an XSS payload, such as `"><script>alert(document.domain)</script>`. Alternatively, modify an image's URL parameter 'grid[images][N][attachment_url]' to a malicious URI like `javascript:alert(1)`. 5. Submit the modified request. The plugin stores this payload in the post metadata via `update_post_meta()`, bypassing WordPress's standard 'unfiltered_html' checks for Author-level users. 6. The script will execute in the context of any user's session whenever the injected collage is rendered in the administration interface or on the public-facing site.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.