Easy Image Collage <= 1.13.6 - Authenticated (Author+) Stored Cross-Site Scripting via 'grid[properties][borderColor]' and 'grid[images][N][attachment_url]' Parameters
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:NTechnical Details
<=1.13.6What Changed in the Fix
Changes introduced in v2.0.0
Source Code
WordPress.org SVNI 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 likeborderColor).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 (likestyleorclass).esc_url(): Used when outputting URLs insrcorhrefattributes.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.
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 @@ -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.