Files
janniclas.loosenandClaude Opus 5.5 99eda25452 fix(security): Divi-markup refusal sees content as storage will (ruling S52)
Control characters removed by kses (wp_kses_no_null: C0, \\0 sequences)
or ControlChars (DEL) could hide a divi/* delimiter or an et_pb shortcode
from the raw-input check. The shared DiviMarkup helper checks the raw
and the normalized form and now guards every raw-content path:
content-create/-update content and media-update description (attachment
post_content). MediaImporter logs import failures with scheme, host,
port and path only; uninstall.php lists the removed user meta.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 19:50:31 +02:00

526 lines
24 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Media;
use Brain\Monkey\Actions;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Media\MediaImporter;
use LIT\MCP\Limits;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;
final class MediaImporterTest extends TestCase
{
/** Key shapes as in WordPress core's wp_get_mime_types(). */
private const CORE_MIMES = [
'jpg|jpeg|jpe' => 'image/jpeg',
'gif' => 'image/gif',
'png' => 'image/png',
'webp' => 'image/webp',
'avif' => 'image/avif',
'htm|html' => 'text/html',
'mp3|m4a|m4b' => 'audio/mpeg',
'mp4|m4v' => 'video/mp4',
'pdf' => 'application/pdf',
];
/** @var list<string> */
private array $tempFiles = [];
/** error_log() is a PHP internal and cannot be stubbed; it is redirected to this file. */
private string $errorLog = '';
private string|false $previousErrorLog = false;
protected function setUp(): void
{
parent::setUp();
$this->errorLog = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$this->previousErrorLog = ini_set('error_log', $this->errorLog);
Functions\when('get_option')->justReturn(['upload_mb' => 1]);
Functions\when('wp_tempnam')->alias(function (): string {
$file = (string) tempnam(sys_get_temp_dir(), 'lit-up');
$this->tempFiles[] = $file;
return $file;
});
Functions\when('wp_delete_file')->alias(static function (string $file): void {
if (is_file($file)) {
unlink($file);
}
});
Functions\when('sanitize_file_name')->returnArg();
Functions\when('wp_basename')->alias('basename');
Functions\when('wp_parse_url')->alias('parse_url');
Functions\when('get_allowed_mime_types')->justReturn(self::CORE_MIMES);
Functions\when('wp_check_filetype')->alias(static function (string $filename, array $mimes): array {
foreach ($mimes as $extensions => $mime) {
if (preg_match('!\.(' . $extensions . ')$!i', $filename, $match)) {
return ['ext' => $match[1], 'type' => $mime];
}
}
return ['ext' => false, 'type' => false];
});
}
protected function tearDown(): void
{
ini_set('error_log', (string) $this->previousErrorLog);
if (is_file($this->errorLog)) {
unlink($this->errorLog);
}
foreach ($this->tempFiles as $file) {
if (is_file($file)) {
unlink($file);
}
}
parent::tearDown();
}
/** @param list<string> $addresses What every host name resolves to (no DNS in unit tests). */
private function importer(array $addresses = ['93.184.216.34']): MediaImporter
{
return new MediaImporter(new Limits(), static fn (string $host): array => $addresses);
}
private function assertTempFilesRemoved(): void
{
foreach ($this->tempFiles as $file) {
self::assertFileDoesNotExist($file);
}
}
/** Lets the next wp_safe_remote_get write $body into the stream target and answer with $headers. */
private function respond(string $body, int $code = 200, array $headers = []): void
{
Functions\when('wp_safe_remote_get')->alias(static function (string $url, array $args) use ($body, $code, $headers): array {
file_put_contents($args['filename'], $body);
return ['response' => ['code' => $code], 'headers' => $headers, 'filename' => $args['filename']];
});
$this->responseReaders();
}
private function responseReaders(): void
{
Functions\when('wp_http_validate_url')->returnArg();
Functions\when('wp_remote_retrieve_response_code')->alias(static fn (array $r): int => $r['response']['code']);
Functions\when('wp_remote_retrieve_header')->alias(
static fn (array $r, string $name): string => (string) ($r['headers'][$name] ?? '')
);
}
public function testBase64UploadIsSideloadedWithAltText(): void
{
Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]);
Functions\expect('media_handle_sideload')->once()
->with(Mockery::on(static fn (array $file): bool => $file['name'] === 'logo.png' && is_file($file['tmp_name'])), 5, 'Logo')
->andReturnUsing(static function (array $file): int {
unlink($file['tmp_name']); // WordPress moves the temp file into uploads.
return 55;
});
Functions\expect('update_post_meta')->once()->with(55, '_wp_attachment_image_alt', 'Firmenlogo')->andReturn(true);
$id = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', [
'title' => 'Logo',
'alt' => 'Firmenlogo',
'post_id' => 5,
]);
self::assertSame(55, $id);
}
public function testOversizedBase64IsRejectedBeforeDecoding(): void
{
$result = $this->importer()->fromBase64(str_repeat('A', 2 * 1024 * 1024), 'big.png', []);
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('Limit 1 MB', $result->get_error_message());
self::assertSame([], $this->tempFiles, 'no temp file may be written for an oversized upload');
}
public function testBase64UploadsAreCappedAtTenMegabytesEvenWithAHigherUploadLimit(): void
{
Functions\when('get_option')->justReturn(['upload_mb' => 32]);
$result = $this->importer()->fromBase64(str_repeat('A', 16 * 1024 * 1024), 'big.png', []);
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('Limit 10 MB', $result->get_error_message());
self::assertStringContainsString('lit/media-upload-from-url', (string) $result->get_error_data()['hint']);
self::assertSame([], $this->tempFiles);
}
public function testInvalidBase64IsRejected(): void
{
$result = $this->importer()->fromBase64('***', 'x.png', []);
self::assertToolError($result, 'validation_failed');
self::assertSame([], $this->tempFiles);
}
public function testDisguisedFileIsRejectedAndTheTempFileRemoved(): void
{
Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => false, 'type' => false, 'proper_filename' => false]);
Functions\expect('media_handle_sideload')->never();
$result = $this->importer()->fromBase64(base64_encode('<?php evil();'), 'bild.png', []);
self::assertToolError($result, 'validation_failed');
self::assertNotSame([], $this->tempFiles);
$this->assertTempFilesRemoved();
}
public function testTheWhitelistOnlyKeepsTypesWordPressAllowsTheUser(): void
{
Functions\when('get_allowed_mime_types')->justReturn(['png' => 'image/png', 'svg' => 'image/svg+xml']);
Functions\expect('wp_check_filetype_and_ext')->once()
->with(Mockery::type('string'), 'logo.png', ['png' => 'image/png'])
->andReturn(['ext' => false, 'type' => false, 'proper_filename' => false]);
$result = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', []);
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('png', $result->get_error_message());
}
public function testSideloadFailureIsAWriteFailureAndCleansUp(): void
{
Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]);
Functions\when('media_handle_sideload')->justReturn(new WP_Error('upload_error', 'Verzeichnis nicht beschreibbar'));
Functions\when('media_handle_sideload')->justReturn(
new WP_Error('upload_error', 'Verzeichnis /var/www/html/wp-content/uploads/2026/09 nicht beschreibbar')
);
$result = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', []);
$logged = (string) file_get_contents($this->errorLog);
self::assertToolError($result, 'write_failed');
self::assertStringNotContainsString('/var/www', $result->get_error_message());
self::assertStringNotContainsString('/var/www', (string) json_encode($result->get_error_data()));
self::assertStringContainsString('WordPress hat die Datei nicht übernommen', $result->get_error_message());
self::assertStringContainsString('/var/www/html/wp-content/uploads/2026/09 nicht beschreibbar', $logged);
$this->assertTempFilesRemoved();
}
public function testTitleAndAltTextAreSlashedForWordPress(): void
{
Functions\when('wp_slash')->alias('addslashes');
Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]);
Functions\expect('media_handle_sideload')->once()
->with(Mockery::type('array'), 0, 'C:\\\\Pfad')
->andReturn(55);
Functions\expect('update_post_meta')->once()
->with(55, '_wp_attachment_image_alt', 'Back\\\\slash')
->andReturn(true);
$id = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', [
'title' => 'C:\\Pfad',
'alt' => 'Back\\slash',
]);
self::assertSame(55, $id);
}
public function testFailedAltTextDeletesTheNewAttachmentAgain(): void
{
Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]);
Functions\when('media_handle_sideload')->justReturn(55);
Functions\when('update_post_meta')->justReturn(false);
Functions\when('get_post_meta')->justReturn('');
Functions\expect('wp_delete_attachment')->once()->with(55, true)->andReturn(new \stdClass());
try {
$this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', ['alt' => 'Logo']);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
self::assertToolError($e->toError(), 'write_failed');
}
$this->assertTempFilesRemoved();
}
public function testAltTextFailureWithoutRollbackIsAPartialWriteNamingTheAttachment(): void
{
Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]);
Functions\when('media_handle_sideload')->justReturn(55);
Functions\when('update_post_meta')->justReturn(false);
Functions\when('get_post_meta')->justReturn('');
Functions\when('wp_delete_attachment')->justReturn(false);
try {
$this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', ['alt' => 'Logo']);
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertSame(55, $e->postId);
$error = $e->toError();
self::assertToolError($error, 'partial_write');
self::assertStringContainsString('Medium 55', $error->get_error_message());
self::assertSame(55, $error->get_error_data()['attachment_id']);
}
}
public function testAnAltTextAlreadyTakenFromTheImageIsNoFailure(): void
{
Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]);
Functions\when('media_handle_sideload')->justReturn(55);
Functions\when('update_post_meta')->justReturn(false); // Same value already stored (IPTC alt).
Functions\when('get_post_meta')->justReturn('Logo');
Functions\expect('wp_delete_attachment')->never();
self::assertSame(55, $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', ['alt' => 'Logo']));
}
public function testInternalUrlsAreRejectedWithoutRequest(): void
{
Functions\when('wp_http_validate_url')->justReturn(false);
Functions\expect('wp_safe_remote_get')->never();
$result = $this->importer()->fromUrl('http://127.0.0.1/wp-config.php', null, []);
self::assertToolError($result, 'validation_failed');
}
public function testOnlyHttpUrlsAreFetched(): void
{
Functions\when('wp_http_validate_url')->returnArg();
Functions\expect('wp_safe_remote_get')->never();
$result = $this->importer()->fromUrl('ftp://example.com/logo.png', null, []);
self::assertToolError($result, 'validation_failed');
}
public function testUrlImportDownloadsSafelyWithTheSizeLimit(): void
{
$this->responseReaders();
Functions\expect('wp_safe_remote_get')->once()->with(
'https://example.com/bilder/logo.png',
Mockery::on(static function (array $args): bool {
file_put_contents($args['filename'], 'PNGDATA');
return $args['stream'] === true && $args['limit_response_size'] === 1024 * 1024 + 1;
})
)->andReturn(['response' => ['code' => 200], 'headers' => []]);
Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]);
Functions\expect('media_handle_sideload')->once()
->with(Mockery::on(static fn (array $file): bool => $file['name'] === 'logo.png'), 0, null)
->andReturn(56);
self::assertSame(56, $this->importer()->fromUrl('https://example.com/bilder/logo.png', null, []));
$this->assertTempFilesRemoved();
}
public function testAMissingExtensionIsTakenFromTheContentType(): void
{
$this->respond('PNGDATA', 200, ['content-type' => 'image/png; charset=binary']);
Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]);
Functions\expect('media_handle_sideload')->once()
->with(Mockery::on(static fn (array $file): bool => $file['name'] === 'foto.png'), 0, null)
->andReturn(57);
self::assertSame(57, $this->importer()->fromUrl('https://cdn.example.com/foto?w=800', null, []));
}
public function testFailedDownloadIsReportedAndCleansUp(): void
{
Functions\when('wp_http_validate_url')->returnArg();
Functions\when('wp_safe_remote_get')->justReturn(new WP_Error('http_request_failed', 'Not Found'));
$result = $this->importer()->fromUrl('https://example.com/fehlt.jpg', null, []);
// Security M3: no refused/timeout/404 oracle for the client; the detail goes to the log only.
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('Die Datei konnte nicht geladen werden.', $result->get_error_message());
self::assertStringNotContainsString('Not Found', $result->get_error_message());
self::assertStringContainsString('Not Found', (string) file_get_contents($this->errorLog));
$this->assertTempFilesRemoved();
}
public function testHttpErrorStatusIsReported(): void
{
$this->respond('<html>404</html>', 404);
Functions\expect('media_handle_sideload')->never();
$result = $this->importer()->fromUrl('https://example.com/fehlt.jpg', null, []);
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('Die Datei konnte nicht geladen werden.', $result->get_error_message());
self::assertStringNotContainsString('404', $result->get_error_message());
self::assertStringContainsString('404', (string) file_get_contents($this->errorLog));
$this->assertTempFilesRemoved();
}
public function testOversizedContentLengthIsRejected(): void
{
$this->respond('x', 200, ['content-length' => (string) (5 * 1024 * 1024)]);
Functions\expect('media_handle_sideload')->never();
$result = $this->importer()->fromUrl('https://example.com/gross.jpg', null, []);
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('Limit 1 MB', $result->get_error_message());
$this->assertTempFilesRemoved();
}
public function testDownloadCutOffAtTheLimitIsRejected(): void
{
$this->respond(str_repeat('x', 1024 * 1024 + 1));
Functions\expect('media_handle_sideload')->never();
$result = $this->importer()->fromUrl('https://example.com/gross.jpg', null, []);
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('Limit 1 MB', $result->get_error_message());
$this->assertTempFilesRemoved();
}
public function testTheWhitelistIsMatchedPerExtensionAgainstCoreKeys(): void
{
Functions\expect('wp_check_filetype_and_ext')->once()
->with(Mockery::type('string'), 'song.mp3', Mockery::on(static function (array $mimes): bool {
return ($mimes['mp3'] ?? null) === 'audio/mpeg'
&& ($mimes['jpeg'] ?? null) === 'image/jpeg'
&& ($mimes['m4v'] ?? null) === 'video/mp4'
&& ! isset($mimes['m4a'], $mimes['html'], $mimes['htm']);
}))
->andReturn(['ext' => 'mp3', 'type' => 'audio/mpeg', 'proper_filename' => false]);
Functions\when('media_handle_sideload')->justReturn(60);
self::assertSame(60, $this->importer()->fromBase64(base64_encode('ID3DATA'), 'song.mp3', []));
}
public function testAnEmptyWhitelistRejectsEverythingInsteadOfFallingBackToAllTypes(): void
{
Functions\when('get_allowed_mime_types')->justReturn(['htm|html' => 'text/html']);
Functions\expect('wp_check_filetype_and_ext')->never();
Functions\expect('media_handle_sideload')->never();
$result = $this->importer()->fromBase64(base64_encode('<html></html>'), 'seite.html', []);
self::assertToolError($result, 'validation_failed');
$this->assertTempFilesRemoved();
}
public function testTheMovedFileIsRemovedWhenTheAttachmentCannotBeCreated(): void
{
Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]);
$record = null;
Filters\expectAdded('wp_handle_upload')->once()->whenHappen(static function (callable $callback) use (&$record): void {
$record = $callback;
});
$moved = (string) tempnam(sys_get_temp_dir(), 'lit-moved');
$this->tempFiles[] = $moved;
Functions\when('media_handle_sideload')->alias(static function () use (&$record, $moved): WP_Error {
$record(['file' => $moved, 'url' => 'https://kunde.de/x.png', 'type' => 'image/png'], 'sideload');
return new WP_Error('db_insert_error', 'Datenbankfehler');
});
$result = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', []);
self::assertToolError($result, 'write_failed');
self::assertFileDoesNotExist($moved);
self::assertFalse(has_filter('wp_handle_upload'));
}
public function testTheSavedFileIsKeptOnSuccess(): void
{
Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]);
$record = null;
Filters\expectAdded('wp_handle_upload')->once()->whenHappen(static function (callable $callback) use (&$record): void {
$record = $callback;
});
$moved = (string) tempnam(sys_get_temp_dir(), 'lit-moved');
$this->tempFiles[] = $moved;
Functions\when('media_handle_sideload')->alias(static function () use (&$record, $moved): int {
$record(['file' => $moved], 'sideload');
return 61;
});
self::assertSame(61, $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', []));
self::assertFileExists($moved);
}
public function testCurlIsRestrictedToIpv4ForTheImportRequestOnly(): void
{
$this->respond('PNGDATA', 200);
Actions\expectAdded('http_api_curl')->once()->whenHappen(static function (callable $callback): void {
if (function_exists('curl_init')) {
$callback(curl_init()); // Must accept a real cURL handle without error.
}
});
Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => false, 'type' => false, 'proper_filename' => false]);
$this->importer()->fromUrl('https://example.com/logo.png', null, []);
self::assertFalse(has_action('http_api_curl'));
}
public function testAMissingTempFileNameIsAWriteFailure(): void
{
Functions\when('wp_http_validate_url')->returnArg();
Functions\when('wp_tempnam')->justReturn('');
Functions\expect('wp_safe_remote_get')->never();
$result = $this->importer()->fromUrl('https://example.com/logo.png', null, []);
self::assertToolError($result, 'write_failed');
}
/** Security M3: the plugin's own range check, whatever wp_http_validate_url() of the core version says. */
public function testPrivateTargetsAreRejectedEvenWhenCoreLetsThemPass(): void
{
Functions\when('wp_http_validate_url')->returnArg();
Functions\expect('wp_safe_remote_get')->never();
foreach (['http://169.254.169.254/latest/meta-data/', 'http://100.64.0.1/x.png', 'http://[fd00::1]/x.png'] as $url) {
self::assertToolError($this->importer()->fromUrl($url, null, []), 'validation_failed', $url);
}
$internal = $this->importer(['10.0.0.5'])->fromUrl('https://intern.example.com/x.png', null, []);
self::assertToolError($internal, 'validation_failed');
$unresolvable = $this->importer([])->fromUrl('https://nirgends.example.com/x.png', null, []);
self::assertToolError($unresolvable, 'validation_failed');
}
/** Redirect targets get the same check (Requests' before_redirect hook, only during the import). */
public function testRedirectsToPrivateTargetsAreStopped(): void
{
$this->respond('PNGDATA', 200);
$listener = null;
Actions\expectAdded('requests-requests.before_redirect')->once()
->whenHappen(static function (callable $callback) use (&$listener): void {
$listener = $callback;
});
Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => false, 'type' => false, 'proper_filename' => false]);
$this->importer()->fromUrl('https://example.com/logo.png', null, []);
self::assertIsCallable($listener);
self::assertFalse(has_action('requests-requests.before_redirect'));
$listener('https://cdn.example.com/logo.png'); // public: no exception
$this->expectException(\WpOrg\Requests\Exception::class);
$listener('http://169.254.169.254/latest/meta-data/');
}
/** Fix round 1: the log names scheme, host and path only – no credentials, no query string. */
public function testTheFailureLogOmitsUserinfoAndQuery(): void
{
Functions\when('wp_http_validate_url')->returnArg();
Functions\when('wp_parse_url')->alias('parse_url');
Functions\when('wp_safe_remote_get')->justReturn(new WP_Error('http_request_failed', 'timed out'));
$this->importer()->fromUrl('https://nutzer:geheim@example.com:8443/bilder/a.jpg?token=abc#x', null, []);
$log = (string) file_get_contents($this->errorLog);
self::assertStringContainsString('https://example.com:8443/bilder/a.jpg', $log);
self::assertStringNotContainsString('geheim', $log);
self::assertStringNotContainsString('nutzer', $log);
self::assertStringNotContainsString('token=abc', $log);
}
}