Skip to content
Open
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
5 changes: 4 additions & 1 deletion includes/Abilities/Image/Alt_Text_Generation.php
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,10 @@ protected function maybe_map_url_to_local_path( string $url ): ?string {
$normalized_url = $this->normalize_upload_url( $url );
$normalized_baseurl = $this->normalize_upload_url( $uploads['baseurl'] );

if ( ! str_contains( $normalized_url, $normalized_baseurl ) ) {
if (
$normalized_url !== $normalized_baseurl &&
! str_starts_with( $normalized_url, $normalized_baseurl . '/' )
) {
Comment on lines +336 to +339
Copy link
Copy Markdown
Contributor Author

@1d0u 1d0u May 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked this path and the current code already rejects the exact uploads base URL before any filesystem return: after normalization the relative path is empty, and the existing empty-relative-path guard returns null. Even beyond that, the return path is guarded by is_file(), so the uploads directory would not be returned as a valid local image file. I reran the targeted integration test successfully (33 tests, 84 assertions, 1 skipped).

return null;
}

Expand Down
55 changes: 55 additions & 0 deletions tests/Integration/Includes/Abilities/Alt_Text_GenerationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,45 @@ public function test_execute_callback_returns_not_an_image() {
$this->assertEquals( 'not_an_image', $result->get_error_code(), 'Error code should be not_an_image' );
}

/**
* Test that lookalike upload URLs do not map to local files.
*
* @since x.x.x
*/
public function test_maybe_map_url_to_local_path_rejects_lookalike_upload_url() {
$uploads = wp_get_upload_dir();
if ( empty( $uploads['baseurl'] ) || empty( $uploads['basedir'] ) ) {
$this->markTestSkipped( 'Uploads directory is not available.' );
return;
}

$normalized_baseurl = $this->invoke_normalize_upload_url( $uploads['baseurl'] );
$relative_path = substr( $normalized_baseurl, -3 ) . '/ai-test-image.jpg';
$file_path = trailingslashit( $uploads['basedir'] ) . $relative_path;

wp_mkdir_p( dirname( $file_path ) );
$bytes_written = file_put_contents( $file_path, 'test image' ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents
if ( false === $bytes_written ) {
$this->markTestSkipped( 'Could not create upload fixture.' );
return;
}

$reflection = new \ReflectionClass( $this->ability );
$method = $reflection->getMethod( 'maybe_map_url_to_local_path' );
$method->setAccessible( true );

$lookalike_url = 'https://bad' . $normalized_baseurl . '/ai-test-image.jpg';

Comment on lines +295 to +296
Copy link
Copy Markdown
Contributor Author

@1d0u 1d0u May 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I verified the constructed URL against the current normalizer: normalize_upload_url() strips the scheme before this value is used, so the test URL becomes a valid different-host URL such as https://badlocalhost:8889/wp-content/uploads/ai-test-image.jpg, not https://badhttps://.... The substr( $normalized_baseurl, -3 ) fixture path is intentional because it proves the previous str_contains() behavior would have sliced the shifted URL into an existing local file path. I also reran the targeted integration test successfully (33 tests, 84 assertions, 1 skipped).

try {
$this->assertNull(
$method->invoke( $this->ability, $lookalike_url ),
'Lookalike upload URLs must not resolve to local upload files.'
);
} finally {
wp_delete_file( $file_path );
}
}

/**
* Test that permission_callback() returns true for user with upload_files when using image_url only.
*
Expand All @@ -283,6 +322,22 @@ public function test_permission_callback_with_image_url_and_upload_files() {
$this->assertTrue( $result, 'Permission should be granted for user with upload_files when using image_url' );
}

/**
* Invokes normalize_upload_url() for test setup.
*
* @since x.x.x
*
* @param string $url URL to normalize.
* @return string Normalized URL.
*/
private function invoke_normalize_upload_url( string $url ): string {
$reflection = new \ReflectionClass( $this->ability );
$method = $reflection->getMethod( 'normalize_upload_url' );
$method->setAccessible( true );

return $method->invoke( $this->ability, $url );
}

/**
* Test that permission_callback() returns error for user without upload_files when using image_url only.
*
Expand Down
2 changes: 2 additions & 0 deletions tests/e2e/specs/admin/settings.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
*/
const {
clearConnectors,
clearCredentials,
seedCredentials,
disableExperiments,
disableExperiment,
Expand Down Expand Up @@ -75,6 +76,7 @@ test.describe( 'Plugin settings', () => {
} ) => {
// Activate the request mocking plugin.
await requestUtils.activatePlugin( 'e2e-test-request-mocking' );
await clearCredentials( requestUtils );
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious why this was needed for this particular PR? Seems unrelated to the changes made here

Copy link
Copy Markdown
Contributor Author

@1d0u 1d0u May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not required by the alt-text production change itself. I added it after this PR’s first CI run exposed an existing retry-isolation issue in this settings E2E test: global setup seeds the OpenAI credential, and when Playwright retried this test in isolation, the connector field was already masked/disabled so the fill failed. Clearing credentials here makes the test self-contained and prevents that unrelated flake from blocking the PR. If you prefer keeping this PR strictly scoped to the alt-text fix, I can split/drop this E2E isolation change.


await visitConnectorsPage( admin );

Expand Down
Loading