diff --git a/classes/Visualizer/Module.php b/classes/Visualizer/Module.php
index 13d7cbfc1..ae7993917 100644
--- a/classes/Visualizer/Module.php
+++ b/classes/Visualizer/Module.php
@@ -650,7 +650,7 @@ protected function get_inline_custom_css( $id, $settings ) {
$class_name = $id . $name;
$properties = implode( ' !important; ', array_filter( $attributes ) );
if ( ! empty( $properties ) ) {
- $css .= '.' . $class_name . ' {' . $properties . ' !important;}';
+ $css .= wp_strip_all_tags( '.' . $class_name . ' {' . $properties . ' !important;}' );
$classes[ $name ] = $class_name;
}
}
diff --git a/classes/Visualizer/Module/AIBuilder.php b/classes/Visualizer/Module/AIBuilder.php
index 41c24a19b..c97be184f 100644
--- a/classes/Visualizer/Module/AIBuilder.php
+++ b/classes/Visualizer/Module/AIBuilder.php
@@ -100,7 +100,7 @@ private function _verify_create_nonce(): void {
* @param int $chart_id Chart ID.
*/
private function _verify_chart_access( $chart_id ): void {
- if ( ! current_user_can( 'edit_post', $chart_id ) ) {
+ if ( ! self::can_edit_chart( $chart_id ) ) {
wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'visualizer' ) ), 403 );
}
}
@@ -502,6 +502,10 @@ public function generateChart(): void {
}
}
+ if ( ! empty( $workflow_id ) ) {
+ set_transient( 'viz_ai_wf_' . $workflow_id, get_current_user_id(), 6 * HOUR_IN_SECONDS );
+ }
+
wp_send_json_success(
array(
'workflow_id' => $workflow_id,
@@ -524,6 +528,10 @@ public function chartStatus(): void {
wp_send_json_error( array( 'message' => __( 'Missing workflow ID.', 'visualizer' ) ) );
}
+ if ( (int) get_transient( 'viz_ai_wf_' . $workflow_id ) !== get_current_user_id() ) {
+ wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'visualizer' ) ), 403 );
+ }
+
$agents_url = VISUALIZER_AGENTS_URL;
$workflow_slug = $this->_get_workflow_slug();
$headers = $this->_get_agents_headers();
diff --git a/classes/Visualizer/Module/Wizard.php b/classes/Visualizer/Module/Wizard.php
index 3a1007521..c3256e61d 100644
--- a/classes/Visualizer/Module/Wizard.php
+++ b/classes/Visualizer/Module/Wizard.php
@@ -153,6 +153,12 @@ public function visualizer_enqueue_setup_wizard_scripts() {
* @return bool|void
*/
public function dismissWizard( $redirect_to_dashboard = true ) {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die( esc_html__( 'You do not have permission to perform this action.', 'visualizer' ), '', array( 'response' => 403 ) );
+ }
+ if ( false !== $redirect_to_dashboard ) {
+ check_admin_referer( 'visualizer_dismiss_wizard' );
+ }
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$status = isset( $_REQUEST['status'] ) ? (int) $_REQUEST['status'] : 0;
update_option( 'visualizer_fresh_install', $status );
@@ -169,6 +175,9 @@ public function dismissWizard( $redirect_to_dashboard = true ) {
*/
public function visualizer_wizard_step_process() {
check_ajax_referer( VISUALIZER_ABSPATH, 'security' );
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json( array( 'status' => 0 ), 403 );
+ }
$step = ! empty( $_POST['step'] ) ? sanitize_text_field( wp_unslash( $_POST['step'] ) ) : 1;
switch ( $step ) {
case 'step_2':
diff --git a/templates/setup-wizard.php b/templates/setup-wizard.php
index e78178027..eccd04163 100644
--- a/templates/setup-wizard.php
+++ b/templates/setup-wizard.php
@@ -6,12 +6,15 @@
* @package Templates
*/
-$dashboard_url = add_query_arg(
- array(
- 'action' => 'visualizer_dismiss_wizard',
- 'status' => 0,
+$dashboard_url = wp_nonce_url(
+ add_query_arg(
+ array(
+ 'action' => 'visualizer_dismiss_wizard',
+ 'status' => 0,
+ ),
+ admin_url( 'admin.php' )
),
- admin_url( 'admin.php' )
+ 'visualizer_dismiss_wizard'
);
$chart_id = ! empty( $this->wizard_data['chart_id'] ) ? (int) $this->wizard_data['chart_id'] : '';
diff --git a/tests/e2e/config/mu-plugins/plant-chart-settings.php b/tests/e2e/config/mu-plugins/plant-chart-settings.php
new file mode 100644
index 000000000..5222e9de8
--- /dev/null
+++ b/tests/e2e/config/mu-plugins/plant-chart-settings.php
@@ -0,0 +1,42 @@
+\d+)',
+ array(
+ 'methods' => 'POST',
+ 'permission_callback' => function () {
+ return current_user_can( 'manage_options' );
+ },
+ 'callback' => function ( WP_REST_Request $request ) {
+ $chart_id = (int) $request['id'];
+ $body = $request->get_json_params();
+ if ( isset( $body['settings'] ) ) {
+ update_post_meta( $chart_id, 'visualizer-settings', $body['settings'] );
+ }
+ if ( isset( $body['series'] ) ) {
+ update_post_meta( $chart_id, 'visualizer-series', $body['series'] );
+ }
+ if ( isset( $body['content'] ) ) {
+ wp_update_post(
+ array(
+ 'ID' => $chart_id,
+ 'post_content' => maybe_serialize( $body['content'] ),
+ )
+ );
+ }
+ return array( 'ok' => true );
+ },
+ )
+ );
+ }
+);
diff --git a/tests/e2e/specs/ai-builder-auth.spec.js b/tests/e2e/specs/ai-builder-auth.spec.js
new file mode 100644
index 000000000..2c1082cde
--- /dev/null
+++ b/tests/e2e/specs/ai-builder-auth.spec.js
@@ -0,0 +1,159 @@
+/**
+ * WordPress dependencies
+ */
+const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+
+const CONTRIBUTOR = { username: 'viz_contributor', password: 'viz-contributor-pass', email: 'viz-contributor@example.com' };
+const EDITOR = { username: 'viz_editor', password: 'viz-editor-pass', email: 'viz-editor@example.com' };
+
+let adminChartId;
+let contributorChartId;
+let contributorId;
+let editorId;
+
+/**
+ * Log in via wp-login in a fresh context and return the page.
+ *
+ * Uses a raw POST with redirects off: the auth cookie is set by the 302
+ * response, so we never have to load the wp-admin dashboard (which can
+ * stall on external feed widgets in CI).
+ *
+ * The context starts with an empty storage state: browser.newContext()
+ * otherwise inherits the project's admin cookies, so a login that silently
+ * failed would leave these requests running as admin. That also means no
+ * wordpress_test_cookie, hence no `testcookie` field in the form.
+ */
+async function loginAs( browser, baseURL, credentials ) {
+ const context = await browser.newContext( {
+ baseURL,
+ storageState: { cookies: [], origins: [] },
+ } );
+ const response = await context.request.post( '/wp-login.php', {
+ form: {
+ log: credentials.username,
+ pwd: credentials.password,
+ 'wp-submit': 'Log In',
+ },
+ maxRedirects: 0,
+ } );
+ if ( response.status() !== 302 ) {
+ throw new Error( `Login as ${ credentials.username } failed with status ${ response.status() }` );
+ }
+ const page = await context.newPage();
+ return { context, page };
+}
+
+/**
+ * Read the AI Builder nonce localized on the Visualizer library page.
+ */
+async function getAiNonce( page ) {
+ await page.goto( '/wp-admin/admin.php?page=visualizer' );
+ return page.evaluate( () => {
+ if ( window.vizAIBuilder && window.vizAIBuilder.nonce ) {
+ return window.vizAIBuilder.nonce;
+ }
+ const match = document.documentElement.innerHTML.match( /"nonce":"([a-f0-9]+)"/ );
+ return match ? match[ 1 ] : null;
+ } );
+}
+
+/**
+ * Call an admin-ajax action using the page's session cookies.
+ */
+async function aiAjax( page, action, data ) {
+ const response = await page.request.post( '/wp-admin/admin-ajax.php', {
+ form: { action, ...data },
+ } );
+ return { status: response.status(), body: await response.json() };
+}
+
+test.describe( 'AI Builder authorization', () => {
+ test.beforeAll( async ( { requestUtils } ) => {
+ const contributor = await requestUtils.rest( {
+ method: 'POST',
+ path: '/wp/v2/users',
+ data: { ...CONTRIBUTOR, roles: [ 'contributor' ] },
+ } );
+ contributorId = contributor.id;
+
+ const editor = await requestUtils.rest( {
+ method: 'POST',
+ path: '/wp/v2/users',
+ data: { ...EDITOR, roles: [ 'editor' ] },
+ } );
+ editorId = editor.id;
+
+ const chart = await requestUtils.rest( {
+ method: 'POST',
+ path: '/wp/v2/visualizer',
+ data: { title: 'Admin chart', status: 'publish' },
+ } );
+ adminChartId = chart.id;
+ } );
+
+ test.afterAll( async ( { requestUtils } ) => {
+ for ( const id of [ adminChartId, contributorChartId ] ) {
+ if ( id ) {
+ await requestUtils.rest( { method: 'DELETE', path: `/wp/v2/visualizer/${ id }`, params: { force: true } } );
+ }
+ }
+ for ( const [ id, user ] of [ [ contributorId, CONTRIBUTOR ], [ editorId, EDITOR ] ] ) {
+ if ( id ) {
+ await requestUtils.rest( { method: 'DELETE', path: `/wp/v2/users/${ id }`, params: { force: true, reassign: 1 } } );
+ }
+ }
+ } );
+
+ test( 'denies read/write/nonce endpoints on another user\'s chart', async ( { browser } ) => {
+ const baseURL = test.info().project.use.baseURL;
+ const { context, page } = await loginAs( browser, baseURL, CONTRIBUTOR );
+ const nonce = await getAiNonce( page );
+ expect( nonce ).toBeTruthy();
+
+ for ( const action of [ 'visualizer-ai-fetch', 'visualizer-ai-chart-nonce', 'visualizer-ai-save' ] ) {
+ const { status, body } = await aiAjax( page, action, { nonce, chart_id: adminChartId, code: 'x' } );
+ expect( status, action ).toBe( 403 );
+ expect( body.data.message, action ).toBe( 'Unauthorized.' );
+ }
+
+ await context.close();
+ } );
+
+ test( 'denies polling a workflow owned by someone else', async ( { browser } ) => {
+ const baseURL = test.info().project.use.baseURL;
+ const { context, page } = await loginAs( browser, baseURL, CONTRIBUTOR );
+ const nonce = await getAiNonce( page );
+
+ const { status, body } = await aiAjax( page, 'visualizer-ai-status', { nonce, workflow_id: 'foreign-workflow-id' } );
+ expect( status ).toBe( 403 );
+ expect( body.data.message ).toBe( 'Unauthorized.' );
+
+ await context.close();
+ } );
+
+ test( 'allows a contributor to use their own chart', async ( { browser } ) => {
+ const baseURL = test.info().project.use.baseURL;
+ const { context, page } = await loginAs( browser, baseURL, CONTRIBUTOR );
+ const nonce = await getAiNonce( page );
+
+ const created = await aiAjax( page, 'visualizer-ai-create', { nonce } );
+ expect( created.body.success ).toBe( true );
+ contributorChartId = created.body.data.chart_id;
+
+ const fetched = await aiAjax( page, 'visualizer-ai-fetch', { nonce, chart_id: contributorChartId } );
+ expect( fetched.body.success ).toBe( true );
+
+ await context.close();
+ } );
+
+ test( 'allows an editor to read another user\'s chart', async ( { browser } ) => {
+ const baseURL = test.info().project.use.baseURL;
+ const { context, page } = await loginAs( browser, baseURL, EDITOR );
+ const nonce = await getAiNonce( page );
+
+ const { body } = await aiAjax( page, 'visualizer-ai-fetch', { nonce, chart_id: adminChartId } );
+ expect( body.success ).toBe( true );
+
+ await context.close();
+ } );
+} );
diff --git a/tests/e2e/specs/custom-css.spec.js b/tests/e2e/specs/custom-css.spec.js
new file mode 100644
index 000000000..1b2fb22f5
--- /dev/null
+++ b/tests/e2e/specs/custom-css.spec.js
@@ -0,0 +1,51 @@
+/**
+ * WordPress dependencies
+ */
+const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+
+let chartId;
+
+test.describe( 'Custom CSS sanitization', () => {
+ test.beforeAll( async ( { requestUtils } ) => {
+ const chart = await requestUtils.rest( {
+ method: 'POST',
+ path: '/wp/v2/visualizer',
+ data: { title: 'Custom CSS payload chart', status: 'publish' },
+ } );
+ chartId = chart.id;
+
+ await requestUtils.rest( {
+ method: 'POST',
+ path: `/visualizer-e2e/v1/chart-settings/${ chartId }`,
+ data: {
+ settings: {
+ customcss: {
+ title: {
+ color: 'red