fix: cap Base64 media transfers at 10 MB, upload limit at most 32 MB

This commit is contained in:
2026-09-26 05:04:38 +02:00
parent 7f58301a71
commit 28bb033a8b
12 changed files with 122 additions and 25 deletions
+3 -3
View File
@@ -27,10 +27,10 @@ nie auf „indirekt“ – ihr Bild- bzw. Dateiergebnis wird nur bei direktem Au
- `lit-media-upload-from-url` (direkt): lädt eine öffentlich erreichbare `http(s)`-Datei; interne oder - `lit-media-upload-from-url` (direkt): lädt eine öffentlich erreichbare `http(s)`-Datei; interne oder
lokale Adressen werden abgelehnt. lokale Adressen werden abgelehnt.
- `lit/media-upload` (indirekt): lädt eine Datei als Base64. - `lit/media-upload` (indirekt): lädt eine Datei als Base64, höchstens 10 MB – größere Dateien per URL.
- Beide: optional `title`, `alt`, `post_id` (Eintrag, dem das Medium zugeordnet wird – braucht - Beide: optional `title`, `alt`, `post_id` (Eintrag, dem das Medium zugeordnet wird – braucht
Bearbeitungsrecht); erlaubte Dateitypen jpg, png, gif, webp, avif, pdf, mp4, mp3; Größenlimit siehe die Bearbeitungsrecht); erlaubte Dateitypen jpg, png, gif, webp, avif, pdf, mp4, mp3; Größenlimit siehe die
Tool-Beschreibung (Standard 5 MB, einstellbar unter Einstellungen → LIT MCP → Limits). Beide legen bei Tool-Beschreibung (Standard 5 MB, einstellbar bis 32 MB unter Einstellungen → LIT MCP → Limits). Beide legen bei
jedem Aufruf ein neues Medium an (nicht idempotent). jedem Aufruf ein neues Medium an (nicht idempotent).
## Ändern, herunterladen, löschen ## Ändern, herunterladen, löschen
@@ -43,7 +43,7 @@ nie auf „indirekt“ – ihr Bild- bzw. Dateiergebnis wird nur bei direktem Au
nicht bei einer reinen Alt-Text-Änderung. Bei Erfolg meldet `results` jeden Eintrag einzeln (mit neuem nicht bei einer reinen Alt-Text-Änderung. Bei Erfolg meldet `results` jeden Eintrag einzeln (mit neuem
`modified`). `modified`).
- `lit-media-download`: Datei (Original oder eine Bildgröße aus `sizes` in `lit/media-get`) als eingebettete - `lit-media-download`: Datei (Original oder eine Bildgröße aus `sizes` in `lit/media-get`) als eingebettete
Ressource, nur bis zum Upload-Limit – darüber die URL aus `lit/media-get` verwenden. Ressource, nur bis zum Upload-Limit und höchstens 10 MB – darüber die URL aus `lit/media-get` verwenden.
- `lit/media-delete`: nur nach Rückfrage (`confirm: true`). Wird das Medium verwendet (auch nur - `lit/media-delete`: nur nach Rückfrage (`confirm: true`). Wird das Medium verwendet (auch nur
„möglicherweise“), kommt `in_use` mit der Liste der Verwendungen – erst nach ausdrücklicher Zustimmung des „möglicherweise“), kommt `in_use` mit der Liste der Verwendungen – erst nach ausdrücklicher Zustimmung des
Nutzers mit `force: true` löschen. Medien im Papierkorb werden nicht gelöscht. Ohne aktivierten Nutzers mit `force: true` löschen. Medien im Papierkorb werden nicht gelöscht. Ohne aktivierten
+1 -1
View File
@@ -40,7 +40,7 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll,
Tabelle {prefix}lit_mcp_log (nur Verweise, keine Inhalte), täglicher Cron-Job lit_mcp_log_cleanup, Tabelle {prefix}lit_mcp_log (nur Verweise, keine Inhalte), täglicher Cron-Job lit_mcp_log_cleanup,
Transient lit_mcp_endpoint_check. Transient lit_mcp_endpoint_check.
* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar * Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar
(Standard 5 MB); Import per URL nur von öffentlich erreichbaren Adressen (interne/lokale Adressen werden (Standard 5 MB, höchstens 32 MB; Base64-Upload und Download höchstens 10 MB); Import per URL nur von öffentlich erreichbaren Adressen (interne/lokale Adressen werden
abgelehnt). abgelehnt).
== Rückgängig machen == == Rückgängig machen ==
+9 -1
View File
@@ -42,7 +42,15 @@ final class LimitsTab implements AdminTab
$values = (new Limits())->all(); $values = (new Limits())->all();
$bounds = Limits::bounds(); $bounds = Limits::bounds();
$labels = [ $labels = [
'upload_mb' => __('Maximale Upload-/Download-Größe (MB)', 'wp-lit-mcp'), 'upload_mb' => sprintf(
/* translators: 1: maximum value in MB, 2: cap for Base64 transfers in MB */
__(
'Maximale Upload-/Download-Größe (MB, höchstens %1$d; Base64-Upload/Download höchstens %2$d MB)',
'wp-lit-mcp'
),
$bounds['upload_mb'][1],
Limits::INLINE_MAX_MB
),
'log_retention_days' => __('Protokoll aufbewahren (Tage)', 'wp-lit-mcp'), 'log_retention_days' => __('Protokoll aufbewahren (Tage)', 'wp-lit-mcp'),
'snapshot_count' => __('Anzahl Designsystem-Snapshots (ab Etappe 4)', 'wp-lit-mcp'), 'snapshot_count' => __('Anzahl Designsystem-Snapshots (ab Etappe 4)', 'wp-lit-mcp'),
]; ];
@@ -13,8 +13,9 @@ use WP_Error;
* Delivers a media file (original or a named image size) as an MCP embedded resource * Delivers a media file (original or a named image size) as an MCP embedded resource
* (spec §4.3). Direct tool: like ImageContent (lit-media-preview), an embedded resource * (spec §4.3). Direct tool: like ImageContent (lit-media-preview), an embedded resource
* is only turned into a content block by mcp-adapter for a tool called directly, never * is only turned into a content block by mcp-adapter for a tool called directly, never
* via mcp-adapter-execute-ability (ruling Q6). Files above the upload limit (Limits) are * via mcp-adapter-execute-ability (ruling Q6). The whole file is read into memory and sent
* refused; the caller falls back to the URL. * as Base64, so files above Limits::inlineBytes() (upload limit, at most 10 MB) are refused;
* the caller falls back to the file URL.
*/ */
final class MediaDownloadAbility implements Ability final class MediaDownloadAbility implements Ability
{ {
@@ -35,7 +36,8 @@ final class MediaDownloadAbility implements Ability
public function description(): string public function description(): string
{ {
return 'Liefert eine Datei der Mediathek (Original oder Bildgröße, siehe sizes in lit/media-get) als ' return 'Liefert eine Datei der Mediathek (Original oder Bildgröße, siehe sizes in lit/media-get) als '
. 'eingebettete Ressource. Dateien über dem Upload-Limit werden nicht übertragen; dann die URL nutzen.'; . 'eingebettete Ressource. Dateien über dem Upload-Limit (höchstens 10 MB) werden nicht übertragen; '
. 'dann die Datei-URL nutzen.';
} }
public function inputSchema(): array public function inputSchema(): array
@@ -103,14 +105,14 @@ final class MediaDownloadAbility implements Ability
} }
$bytesOnDisk = (int) filesize($path); $bytesOnDisk = (int) filesize($path);
if ($bytesOnDisk > $this->limits->uploadBytes()) { if ($bytesOnDisk > $this->limits->inlineBytes()) {
return Errors::unsupported( return Errors::unsupported(
sprintf( sprintf(
'Die Datei ist zu groß für die Übertragung (%.1f MB, Limit %d MB).', 'Die Datei ist zu groß für die Übertragung (%.1f MB, Limit %d MB).',
$bytesOnDisk / 1048576, $bytesOnDisk / 1048576,
$this->limits->uploadMegabytes() $this->limits->inlineMegabytes()
), ),
'Kleinere Bildgröße (size) wählen oder die URL aus lit/media-get verwenden.' 'Die Datei-URL (url aus lit/media-get) verwenden oder eine kleinere Bildgröße (size) wählen.'
); );
} }
+20 -5
View File
@@ -16,7 +16,8 @@ use WP_Error;
* - URL: only http(s), checked with wp_http_validate_url() before any request and fetched * - URL: only http(s), checked with wp_http_validate_url() before any request and fetched
* with wp_safe_remote_get() (rejects internal addresses, also after redirects), streamed * with wp_safe_remote_get() (rejects internal addresses, also after redirects), streamed
* into a temp file and cut off one byte above the upload limit. * into a temp file and cut off one byte above the upload limit.
* - Base64: decoded strictly; the size is checked before anything is decoded or written. * - Base64: decoded strictly; the size is checked against Limits::inlineBytes() (upload limit,
* at most 10 MB – the whole file sits in memory) before anything is decoded or written.
* - Type: whitelist (filter lit_mcp_upload_mimes) intersected with what WordPress allows the * - Type: whitelist (filter lit_mcp_upload_mimes) intersected with what WordPress allows the
* current user (SVG etc. only if WordPress itself allows it), checked against the real * current user (SVG etc. only if WordPress itself allows it), checked against the real
* file content with wp_check_filetype_and_ext() – disguised files fail. * file content with wp_check_filetype_and_ext() – disguised files fail.
@@ -83,8 +84,8 @@ class MediaImporter
public function fromBase64(string $data, string $filename, array $meta): int|WP_Error public function fromBase64(string $data, string $filename, array $meta): int|WP_Error
{ {
$estimated = intdiv(strlen($data) * 3, 4); $estimated = intdiv(strlen($data) * 3, 4);
if ($estimated > $this->limits->uploadBytes() + 2) { if ($estimated > $this->limits->inlineBytes() + 2) {
return $this->tooLarge($estimated); return $this->tooLargeInline($estimated);
} }
$bytes = base64_decode($data, true); $bytes = base64_decode($data, true);
@@ -95,8 +96,8 @@ class MediaImporter
); );
} }
if (strlen($bytes) > $this->limits->uploadBytes()) { if (strlen($bytes) > $this->limits->inlineBytes()) {
return $this->tooLarge(strlen($bytes)); return $this->tooLargeInline(strlen($bytes));
} }
AdminIncludes::load(); AdminIncludes::load();
@@ -337,6 +338,20 @@ class MediaImporter
); );
} }
private function tooLargeInline(int $bytes): WP_Error
{
if ($this->limits->inlineMegabytes() === $this->limits->uploadMegabytes()) {
return $this->tooLarge($bytes);
}
return Errors::invalidInput(
sprintf('Datei zu groß (%.1f MB, Limit %d MB).', $bytes / 1048576, $this->limits->inlineMegabytes()),
['data' => 'über dem Limit für Base64-Uploads'],
'Größere Dateien über eine öffentliche URL mit lit/media-upload-from-url (direkt: '
. 'lit-media-upload-from-url) importieren.'
);
}
private function tooLarge(int $bytes, string $field = 'data'): WP_Error private function tooLarge(int $bytes, string $field = 'data'): WP_Error
{ {
return Errors::invalidInput( return Errors::invalidInput(
@@ -10,7 +10,8 @@ use WP_Error;
/** /**
* Uploads a Base64-encoded file into the media library (spec §6.3), up to the configured * Uploads a Base64-encoded file into the media library (spec §6.3), up to the configured
* upload limit. Checks and cleanup: MediaImporter; permission and result: MediaUploads. * upload limit (at most Limits::INLINE_MAX_MB, the request carries the whole file).
* Checks and cleanup: MediaImporter; permission and result: MediaUploads.
*/ */
final class MediaUploadAbility implements Ability final class MediaUploadAbility implements Ability
{ {
@@ -36,8 +37,8 @@ final class MediaUploadAbility implements Ability
return sprintf( return sprintf(
'Lädt eine Datei (Bild, PDF, MP4, MP3) als Base64 in die Mediathek, höchstens %d MB; optional mit ' 'Lädt eine Datei (Bild, PDF, MP4, MP3) als Base64 in die Mediathek, höchstens %d MB; optional mit '
. 'Titel, Alt-Text und Zuordnung zu einem Eintrag (post_id). Nicht idempotent: jeder Aufruf legt ' . 'Titel, Alt-Text und Zuordnung zu einem Eintrag (post_id). Nicht idempotent: jeder Aufruf legt '
. 'ein neues Medium an.', . 'ein neues Medium an. Größere Dateien per URL mit lit/media-upload-from-url importieren.',
$this->limits->uploadMegabytes() $this->limits->inlineMegabytes()
); );
} }
+19 -1
View File
@@ -11,7 +11,14 @@ final class Limits
{ {
public const OPTION = 'lit_mcp_limits'; public const OPTION = 'lit_mcp_limits';
public const DEFAULTS = ['upload_mb' => 5, 'log_retention_days' => 90, 'snapshot_count' => 20]; public const DEFAULTS = ['upload_mb' => 5, 'log_retention_days' => 90, 'snapshot_count' => 20];
private const BOUNDS = ['upload_mb' => [1, 100], 'log_retention_days' => [1, 3650], 'snapshot_count' => [1, 100]]; /**
* Hard cap for transfers held in memory as a whole and passed through the MCP response or
* request as Base64 (lit-media-download, lit/media-upload): above it, PHP memory and the
* JSON payload become the problem, whatever upload_mb says. URL imports stream to disk
* and only use upload_mb.
*/
public const INLINE_MAX_MB = 10;
private const BOUNDS = ['upload_mb' => [1, 32], 'log_retention_days' => [1, 3650], 'snapshot_count' => [1, 100]];
public function uploadBytes(): int public function uploadBytes(): int
{ {
@@ -23,6 +30,17 @@ final class Limits
return $this->all()['upload_mb']; return $this->all()['upload_mb'];
} }
/** Limit for Base64 transfers: the upload limit, at most INLINE_MAX_MB. */
public function inlineBytes(): int
{
return $this->inlineMegabytes() * 1024 * 1024;
}
public function inlineMegabytes(): int
{
return min($this->uploadMegabytes(), self::INLINE_MAX_MB);
}
public function logRetentionDays(): int public function logRetentionDays(): int
{ {
return $this->all()['log_retention_days']; return $this->all()['log_retention_days'];
+5 -3
View File
@@ -40,7 +40,9 @@ final class LimitsTabTest extends TestCase
self::assertSame(3, substr_count($html, 'type="number"')); self::assertSame(3, substr_count($html, 'type="number"'));
self::assertStringContainsString('name="lit_mcp_limits[upload_mb]" value="8"', $html); self::assertStringContainsString('name="lit_mcp_limits[upload_mb]" value="8"', $html);
self::assertStringContainsString('name="lit_mcp_limits[log_retention_days]" value="90"', $html); self::assertStringContainsString('name="lit_mcp_limits[log_retention_days]" value="90"', $html);
self::assertStringContainsString('min="1" max="100"', $html); self::assertStringContainsString('min="1" max="32"', $html);
self::assertStringContainsString('höchstens 32', $html);
self::assertStringContainsString('10 MB', $html);
self::assertStringContainsString('min="1" max="3650"', $html); self::assertStringContainsString('min="1" max="3650"', $html);
} }
@@ -50,7 +52,7 @@ final class LimitsTabTest extends TestCase
Functions\stubTranslationFunctions(); Functions\stubTranslationFunctions();
Functions\when('settings_fields')->justReturn(null); Functions\when('settings_fields')->justReturn(null);
Functions\when('submit_button')->justReturn(null); Functions\when('submit_button')->justReturn(null);
Functions\when('get_option')->justReturn(['upload_mb' => 50]); Functions\when('get_option')->justReturn(['upload_mb' => 20]);
Functions\when('wp_max_upload_size')->justReturn(2 * 1024 * 1024); Functions\when('wp_max_upload_size')->justReturn(2 * 1024 * 1024);
ob_start(); ob_start();
@@ -58,7 +60,7 @@ final class LimitsTabTest extends TestCase
$html = (string) ob_get_clean(); $html = (string) ob_get_clean();
self::assertStringContainsString('class="description"', $html); self::assertStringContainsString('class="description"', $html);
self::assertMatchesRegularExpression('/50 MB.*2 MB/s', $html); self::assertMatchesRegularExpression('/20 MB.*2 MB/s', $html);
} }
public function testNoWarningWhenUploadMbIsWithinTheServersOwnUploadLimit(): void public function testNoWarningWhenUploadMbIsWithinTheServersOwnUploadLimit(): void
@@ -62,6 +62,18 @@ final class MediaDownloadAbilityTest extends TestCase
self::assertSame('lit_unsupported', $result->get_error_code()); self::assertSame('lit_unsupported', $result->get_error_code());
} }
public function testDownloadsAreCappedAtTenMegabytesEvenWithAHigherUploadLimit(): void
{
Functions\when('get_option')->justReturn(['upload_mb' => 32]);
file_put_contents($this->file, str_repeat('x', 10 * 1024 * 1024 + 1));
$result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]);
self::assertSame('lit_unsupported', $result->get_error_code());
self::assertStringContainsString('Limit 10 MB', $result->get_error_message());
self::assertStringContainsString('URL', (string) $result->get_error_data()['hint']);
}
public function testUnknownSizeIsNotFound(): void public function testUnknownSizeIsNotFound(): void
{ {
Functions\when('get_option')->justReturn([]); Functions\when('get_option')->justReturn([]);
@@ -131,6 +131,18 @@ final class MediaImporterTest extends TestCase
self::assertSame([], $this->tempFiles, 'no temp file may be written for an oversized upload'); 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::assertSame('lit_validation_failed', $result->get_error_code());
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 public function testInvalidBase64IsRejected(): void
{ {
$result = $this->importer()->fromBase64('***', 'x.png', []); $result = $this->importer()->fromBase64('***', 'x.png', []);
@@ -154,4 +154,12 @@ final class MediaUploadAbilitiesTest extends TestCase
self::assertFalse($ability->isDirect()); self::assertFalse($ability->isDirect());
self::assertTrue((new MediaUploadFromUrlAbility(Mockery::mock(MediaImporter::class), $this->uploads()))->isDirect()); self::assertTrue((new MediaUploadFromUrlAbility(Mockery::mock(MediaImporter::class), $this->uploads()))->isDirect());
} }
public function testDescriptionNamesTheTenMegabyteCapForBase64(): void
{
Functions\when('get_option')->justReturn(['upload_mb' => 32]);
$ability = new MediaUploadAbility(Mockery::mock(MediaImporter::class), $this->uploads(), new Limits());
self::assertStringContainsString('höchstens 10 MB', $ability->description());
}
} }
+21 -2
View File
@@ -24,7 +24,7 @@ final class LimitsTest extends TestCase
{ {
Functions\when('get_option')->justReturn(['upload_mb' => 500, 'log_retention_days' => 0, 'snapshot_count' => '7']); Functions\when('get_option')->justReturn(['upload_mb' => 500, 'log_retention_days' => 0, 'snapshot_count' => '7']);
self::assertSame(['upload_mb' => 100, 'log_retention_days' => 1, 'snapshot_count' => 7], (new Limits())->all()); self::assertSame(['upload_mb' => 32, 'log_retention_days' => 1, 'snapshot_count' => 7], (new Limits())->all());
} }
public function testCorruptOptionFallsBackToDefaults(): void public function testCorruptOptionFallsBackToDefaults(): void
@@ -45,7 +45,7 @@ final class LimitsTest extends TestCase
public function testBoundsMatchesWhatSanitizeClampsTo(): void public function testBoundsMatchesWhatSanitizeClampsTo(): void
{ {
self::assertSame([1, 100], Limits::bounds()['upload_mb']); self::assertSame([1, 32], Limits::bounds()['upload_mb']);
self::assertSame([1, 3650], Limits::bounds()['log_retention_days']); self::assertSame([1, 3650], Limits::bounds()['log_retention_days']);
self::assertSame([1, 100], Limits::bounds()['snapshot_count']); self::assertSame([1, 100], Limits::bounds()['snapshot_count']);
@@ -61,4 +61,23 @@ final class LimitsTest extends TestCase
Limits::sanitize($tooHigh) Limits::sanitize($tooHigh)
); );
} }
public function testInlineTransfersAreCappedAtTenMegabytes(): void
{
Functions\when('get_option')->justReturn(['upload_mb' => 32]);
$limits = new Limits();
self::assertSame(32 * 1024 * 1024, $limits->uploadBytes());
self::assertSame(10 * 1024 * 1024, $limits->inlineBytes());
self::assertSame(10, $limits->inlineMegabytes());
}
public function testInlineLimitFollowsALowerUploadLimit(): void
{
Functions\when('get_option')->justReturn(['upload_mb' => 3]);
$limits = new Limits();
self::assertSame(3 * 1024 * 1024, $limits->inlineBytes());
self::assertSame(3, $limits->inlineMegabytes());
}
} }