Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 );
},
)
);
}
);
159 changes: 159 additions & 0 deletions tests/e2e/specs/ai-builder-auth.spec.js
Original file line number Diff line number Diff line change
@@ -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();
} );
} );
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();
} );
} );
60 changes: 60 additions & 0 deletions tests/e2e/specs/wizard-auth.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* 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 } ) => {
// Empty storage state: browser.newContext() otherwise inherits the
// project's admin cookies, so a login that silently failed would leave
// the request authenticated as admin and the assertion below moot.
const context = await browser.newContext( {
baseURL: test.info().project.use.baseURL,
storageState: { cookies: [], origins: [] },
} );
// 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).
// No `testcookie` field: WP only accepts it when the context already
// holds wordpress_test_cookie, which this one deliberately does not.
const loginResponse = await context.request.post( '/wp-login.php', {
form: {
log: CONTRIBUTOR.username,
pwd: CONTRIBUTOR.password,
'wp-submit': 'Log In',
},
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
Loading