Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion classes/Visualizer/Module.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
10 changes: 9 additions & 1 deletion classes/Visualizer/Module/AIBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
}
Expand Down Expand Up @@ -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 );
Comment on lines +505 to +506
}

wp_send_json_success(
array(
'workflow_id' => $workflow_id,
Expand All @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions classes/Visualizer/Module/Wizard.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand All @@ -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':
Expand Down
13 changes: 8 additions & 5 deletions templates/setup-wizard.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] : '';
Expand Down
42 changes: 42 additions & 0 deletions tests/e2e/config/mu-plugins/plant-chart-settings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php
/**
* E2E test helper (loaded as an mu-plugin via .wp-env.json).
*
* Lets specs plant chart data/settings meta directly, e.g. a stored-XSS
* payload in `customcss` that UI save flows strip on submission, so
* render-time sanitization can be verified.
*/
add_action(
'rest_api_init',
function () {
register_rest_route(
'visualizer-e2e/v1',
'/chart-settings/(?P<id>\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 );
},
)
);
}
);
149 changes: 149 additions & 0 deletions tests/e2e/specs/ai-builder-auth.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* 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.
*
* 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();
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();
} );
} );
51 changes: 51 additions & 0 deletions tests/e2e/specs/custom-css.spec.js
Original file line number Diff line number Diff line change
@@ -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</style><script>window.vizXss=1</script><style>',
'font-size': '12px',
},
},
},
},
} );
} );

test.afterAll( async ( { requestUtils } ) => {
if ( chartId ) {
await requestUtils.rest( { method: 'DELETE', path: `/wp/v2/visualizer/${ chartId }`, params: { force: true } } );
}
} );

test( 'strips tags from chart custom CSS on the library page', async ( { admin, page } ) => {
await admin.visitAdminPage( 'admin.php?page=visualizer' );

const styleBlock = page.locator( `#customcss-visualizer-${ chartId }` );
await expect( styleBlock ).toHaveCount( 1 );
// Legitimate rules survive sanitization. <style> has no innerText, so read textContent.
const css = await styleBlock.textContent();
expect( css ).toContain( 'font-size: 12px' );
expect( css ).not.toContain( '<script' );
// The injected script must not have executed.
expect( await page.evaluate( () => window.vizXss ) ).toBeUndefined();
} );
} );
53 changes: 53 additions & 0 deletions tests/e2e/specs/wizard-auth.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* 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 } );
// 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 );

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 );
} );
} );
Loading