From 229a0a4e79e8ec2d7ff4388c4484cfb47dc6522e Mon Sep 17 00:00:00 2001 From: lucadobrescu <252785083+lucadobrescu@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:32:26 +0300 Subject: [PATCH 1/6] fix: enforce per-chart authorization in AI Builder endpoints Co-authored-by: Cursor --- classes/Visualizer/Module/AIBuilder.php | 10 +- tests/e2e/specs/ai-builder-auth.spec.js | 138 ++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/specs/ai-builder-auth.spec.js diff --git a/classes/Visualizer/Module/AIBuilder.php b/classes/Visualizer/Module/AIBuilder.php index 41c24a19..c97be184 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/tests/e2e/specs/ai-builder-auth.spec.js b/tests/e2e/specs/ai-builder-auth.spec.js new file mode 100644 index 00000000..93f040c0 --- /dev/null +++ b/tests/e2e/specs/ai-builder-auth.spec.js @@ -0,0 +1,138 @@ +/** + * 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 contributorId; +let editorId; + +/** + * Log in via wp-login in a fresh context and return the page. + */ +async function loginAs( browser, baseURL, credentials ) { + const context = await browser.newContext( { baseURL } ); + const page = await context.newPage(); + await page.goto( '/wp-login.php' ); + await page.fill( '#user_login', credentials.username ); + await page.fill( '#user_pass', credentials.password ); + await page.click( '#wp-submit' ); + await page.waitForURL( '**/wp-admin/**' ); + 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 } ) => { + if ( adminChartId ) { + await requestUtils.rest( { method: 'DELETE', path: `/wp/v2/visualizer/${ adminChartId }`, 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 ); + const ownChartId = created.body.data.chart_id; + + const fetched = await aiAjax( page, 'visualizer-ai-fetch', { nonce, chart_id: ownChartId } ); + 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(); + } ); +} ); From 09e61d001f8057367623f3aee86e3aea029c7ee8 Mon Sep 17 00:00:00 2001 From: lucadobrescu <252785083+lucadobrescu@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:49:03 +0300 Subject: [PATCH 2/6] test: log in via wp-login POST instead of dashboard navigation Co-authored-by: Cursor --- tests/e2e/specs/ai-builder-auth.spec.js | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/e2e/specs/ai-builder-auth.spec.js b/tests/e2e/specs/ai-builder-auth.spec.js index 93f040c0..c991080c 100644 --- a/tests/e2e/specs/ai-builder-auth.spec.js +++ b/tests/e2e/specs/ai-builder-auth.spec.js @@ -12,15 +12,26 @@ 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). */ async function loginAs( browser, baseURL, credentials ) { const context = await browser.newContext( { baseURL } ); + const response = await context.request.post( '/wp-login.php', { + form: { + log: credentials.username, + pwd: credentials.password, + 'wp-submit': 'Log In', + testcookie: '1', + }, + maxRedirects: 0, + } ); + if ( response.status() !== 302 ) { + throw new Error( `Login as ${ credentials.username } failed with status ${ response.status() }` ); + } const page = await context.newPage(); - await page.goto( '/wp-login.php' ); - await page.fill( '#user_login', credentials.username ); - await page.fill( '#user_pass', credentials.password ); - await page.click( '#wp-submit' ); - await page.waitForURL( '**/wp-admin/**' ); return { context, page }; } From f3a4d5b62ffe13e519d02d420537e4236adbdf7c Mon Sep 17 00:00:00 2001 From: lucadobrescu <252785083+lucadobrescu@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:36:48 +0300 Subject: [PATCH 3/6] fix: gate setup wizard endpoints behind manage_options and nonces Co-authored-by: Cursor --- classes/Visualizer/Module/Wizard.php | 9 ++++++ templates/setup-wizard.php | 13 +++++--- tests/e2e/specs/wizard-auth.spec.js | 46 ++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/specs/wizard-auth.spec.js diff --git a/classes/Visualizer/Module/Wizard.php b/classes/Visualizer/Module/Wizard.php index 3a100752..c3256e61 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 e7817802..eccd0416 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/specs/wizard-auth.spec.js b/tests/e2e/specs/wizard-auth.spec.js new file mode 100644 index 00000000..4ca3599f --- /dev/null +++ b/tests/e2e/specs/wizard-auth.spec.js @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const CONTRIBUTOR = { username: 'viz_wiz_contributor', password: 'viz-wiz-contributor-pass', email: 'viz-wiz-contributor@example.com' }; + +let contributorId; + +test.describe( 'Setup wizard authorization', () => { + test.beforeAll( async ( { requestUtils } ) => { + const contributor = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/users', + data: { ...CONTRIBUTOR, roles: [ 'contributor' ] }, + } ); + contributorId = contributor.id; + } ); + + test.afterAll( async ( { requestUtils } ) => { + if ( contributorId ) { + await requestUtils.rest( { method: 'DELETE', path: `/wp/v2/users/${ contributorId }`, params: { force: true, reassign: 1 } } ); + } + } ); + + test( 'denies wizard dismissal for non-admin users', async ( { browser } ) => { + const context = await browser.newContext( { baseURL: test.info().project.use.baseURL } ); + const page = await context.newPage(); + await page.goto( '/wp-login.php' ); + await page.fill( '#user_login', CONTRIBUTOR.username ); + await page.fill( '#user_pass', CONTRIBUTOR.password ); + await page.click( '#wp-submit' ); + await page.waitForURL( '**/wp-admin/**' ); + + const response = await page.goto( '/wp-admin/admin.php?action=visualizer_dismiss_wizard&status=1' ); + expect( response.status() ).toBe( 403 ); + + await context.close(); + } ); + + test( 'requires a nonce for wizard dismissal', async ( { page } ) => { + // Logged in as admin (default storage state) but without a nonce. + const response = await page.goto( '/wp-admin/admin.php?action=visualizer_dismiss_wizard&status=1' ); + expect( response.status() ).toBe( 403 ); + } ); +} ); From 7b3005428bc69b52ad5ee595ca34ccf530107d05 Mon Sep 17 00:00:00 2001 From: lucadobrescu <252785083+lucadobrescu@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:53:01 +0300 Subject: [PATCH 4/6] test: log in via wp-login POST instead of dashboard navigation Co-authored-by: Cursor --- tests/e2e/specs/wizard-auth.spec.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/e2e/specs/wizard-auth.spec.js b/tests/e2e/specs/wizard-auth.spec.js index 4ca3599f..d232675a 100644 --- a/tests/e2e/specs/wizard-auth.spec.js +++ b/tests/e2e/specs/wizard-auth.spec.js @@ -25,13 +25,20 @@ test.describe( 'Setup wizard authorization', () => { test( 'denies wizard dismissal for non-admin users', async ( { browser } ) => { const context = await browser.newContext( { baseURL: test.info().project.use.baseURL } ); - const page = await context.newPage(); - await page.goto( '/wp-login.php' ); - await page.fill( '#user_login', CONTRIBUTOR.username ); - await page.fill( '#user_pass', CONTRIBUTOR.password ); - await page.click( '#wp-submit' ); - await page.waitForURL( '**/wp-admin/**' ); + // POST wp-login with redirects off: the auth cookie is set by the 302 + // response, so we never load the wp-admin dashboard (can stall in CI). + const loginResponse = await context.request.post( '/wp-login.php', { + form: { + log: CONTRIBUTOR.username, + pwd: CONTRIBUTOR.password, + 'wp-submit': 'Log In', + testcookie: '1', + }, + maxRedirects: 0, + } ); + expect( loginResponse.status() ).toBe( 302 ); + const page = await context.newPage(); const response = await page.goto( '/wp-admin/admin.php?action=visualizer_dismiss_wizard&status=1' ); expect( response.status() ).toBe( 403 ); From 889afa90cc0bafd364b38c6eb671dd06e28c6353 Mon Sep 17 00:00:00 2001 From: lucadobrescu Date: Thu, 30 Jul 2026 11:15:25 +0300 Subject: [PATCH 5/6] fix: strip tags from chart custom CSS at render time (#1355) --- classes/Visualizer/Module.php | 2 +- .../mu-plugins/plant-chart-settings.php | 42 +++++++++++++++ tests/e2e/specs/custom-css.spec.js | 51 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/config/mu-plugins/plant-chart-settings.php create mode 100644 tests/e2e/specs/custom-css.spec.js diff --git a/classes/Visualizer/Module.php b/classes/Visualizer/Module.php index 13d7cbfc..ae799391 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/tests/e2e/config/mu-plugins/plant-chart-settings.php b/tests/e2e/config/mu-plugins/plant-chart-settings.php new file mode 100644 index 00000000..5222e9de --- /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/custom-css.spec.js b/tests/e2e/specs/custom-css.spec.js new file mode 100644 index 00000000..1b2fb22f --- /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