fix: cap Base64 media transfers at 10 MB, upload limit at most 32 MB
This commit is contained in:
@@ -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
|
||||
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
|
||||
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).
|
||||
|
||||
## Ä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
|
||||
`modified`).
|
||||
- `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
|
||||
„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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
Transient lit_mcp_endpoint_check.
|
||||
* 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).
|
||||
|
||||
== Rückgängig machen ==
|
||||
|
||||
@@ -42,7 +42,15 @@ final class LimitsTab implements AdminTab
|
||||
$values = (new Limits())->all();
|
||||
$bounds = Limits::bounds();
|
||||
$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'),
|
||||
'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
|
||||
* (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
|
||||
* via mcp-adapter-execute-ability (ruling Q6). Files above the upload limit (Limits) are
|
||||
* refused; the caller falls back to the URL.
|
||||
* via mcp-adapter-execute-ability (ruling Q6). The whole file is read into memory and sent
|
||||
* 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
|
||||
{
|
||||
@@ -35,7 +36,8 @@ final class MediaDownloadAbility implements Ability
|
||||
public function description(): string
|
||||
{
|
||||
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
|
||||
@@ -103,14 +105,14 @@ final class MediaDownloadAbility implements Ability
|
||||
}
|
||||
|
||||
$bytesOnDisk = (int) filesize($path);
|
||||
if ($bytesOnDisk > $this->limits->uploadBytes()) {
|
||||
if ($bytesOnDisk > $this->limits->inlineBytes()) {
|
||||
return Errors::unsupported(
|
||||
sprintf(
|
||||
'Die Datei ist zu groß für die Übertragung (%.1f MB, Limit %d MB).',
|
||||
$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.'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ use WP_Error;
|
||||
* - 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
|
||||
* 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
|
||||
* 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.
|
||||
@@ -83,8 +84,8 @@ class MediaImporter
|
||||
public function fromBase64(string $data, string $filename, array $meta): int|WP_Error
|
||||
{
|
||||
$estimated = intdiv(strlen($data) * 3, 4);
|
||||
if ($estimated > $this->limits->uploadBytes() + 2) {
|
||||
return $this->tooLarge($estimated);
|
||||
if ($estimated > $this->limits->inlineBytes() + 2) {
|
||||
return $this->tooLargeInline($estimated);
|
||||
}
|
||||
|
||||
$bytes = base64_decode($data, true);
|
||||
@@ -95,8 +96,8 @@ class MediaImporter
|
||||
);
|
||||
}
|
||||
|
||||
if (strlen($bytes) > $this->limits->uploadBytes()) {
|
||||
return $this->tooLarge(strlen($bytes));
|
||||
if (strlen($bytes) > $this->limits->inlineBytes()) {
|
||||
return $this->tooLargeInline(strlen($bytes));
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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
|
||||
* 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
|
||||
{
|
||||
@@ -36,8 +37,8 @@ final class MediaUploadAbility implements Ability
|
||||
return sprintf(
|
||||
'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 '
|
||||
. 'ein neues Medium an.',
|
||||
$this->limits->uploadMegabytes()
|
||||
. 'ein neues Medium an. Größere Dateien per URL mit lit/media-upload-from-url importieren.',
|
||||
$this->limits->inlineMegabytes()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+19
-1
@@ -11,7 +11,14 @@ final class Limits
|
||||
{
|
||||
public const OPTION = 'lit_mcp_limits';
|
||||
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
|
||||
{
|
||||
@@ -23,6 +30,17 @@ final class Limits
|
||||
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
|
||||
{
|
||||
return $this->all()['log_retention_days'];
|
||||
|
||||
@@ -40,7 +40,9 @@ final class LimitsTabTest extends TestCase
|
||||
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[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);
|
||||
}
|
||||
|
||||
@@ -50,7 +52,7 @@ final class LimitsTabTest extends TestCase
|
||||
Functions\stubTranslationFunctions();
|
||||
Functions\when('settings_fields')->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);
|
||||
|
||||
ob_start();
|
||||
@@ -58,7 +60,7 @@ final class LimitsTabTest extends TestCase
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
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
|
||||
|
||||
@@ -62,6 +62,18 @@ final class MediaDownloadAbilityTest extends TestCase
|
||||
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
|
||||
{
|
||||
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');
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$result = $this->importer()->fromBase64('***', 'x.png', []);
|
||||
|
||||
@@ -154,4 +154,12 @@ final class MediaUploadAbilitiesTest extends TestCase
|
||||
self::assertFalse($ability->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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ final class LimitsTest extends TestCase
|
||||
{
|
||||
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
|
||||
@@ -45,7 +45,7 @@ final class LimitsTest extends TestCase
|
||||
|
||||
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, 100], Limits::bounds()['snapshot_count']);
|
||||
|
||||
@@ -61,4 +61,23 @@ final class LimitsTest extends TestCase
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user