From e49b0a2ac8623a7f4ed3799eb115f3bb40e4c1f5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:53:01 +0200 Subject: [PATCH] fix(applications): normalize nested base64 custom labels --- .../Api/ApplicationsController.php | 5 ++-- app/Models/Application.php | 16 ++++++------ bootstrap/helpers/shared.php | 26 +++++++++++++++++++ .../Api/ApplicationSettingsApiTest.php | 20 ++++++++++++++ tests/Unit/CustomLabelEncodingTest.php | 25 ++++++++++++++++++ 5 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/CustomLabelEncodingTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 285cf8e2f1..bb09bca588 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -4456,8 +4456,8 @@ class ApplicationsController extends Controller ], ], 422); } - $customLabels = base64_decode($request->custom_labels); - if (mb_detect_encoding($customLabels, 'UTF-8', true) === false) { + $customLabels = decodeBase64EncodedLabels($request->custom_labels); + if ($customLabels === null) { return response()->json([ 'message' => 'Validation failed.', 'errors' => [ @@ -4465,6 +4465,7 @@ class ApplicationsController extends Controller ], ], 422); } + $request->offsetSet('custom_labels', base64_encode($customLabels)); } if ($request->has('domains') && $server->isProxyShouldRun()) { $uuid = $request->uuid; diff --git a/app/Models/Application.php b/app/Models/Application.php index 0868bdf9cd..6df48b2eec 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2243,18 +2243,18 @@ class Application extends BaseModel public function parseContainerLabels(?ApplicationPreview $preview = null) { - $customLabels = data_get($this, 'custom_labels'); - if (! $customLabels) { + $storedLabels = data_get($this, 'custom_labels'); + if (! $storedLabels) { return; } - if (base64_encode(base64_decode($customLabels, true)) !== $customLabels) { - $this->custom_labels = str($customLabels)->replace(',', "\n"); - $this->custom_labels = base64_encode($customLabels); - } - $customLabels = base64_decode($this->custom_labels); - if (mb_detect_encoding($customLabels, 'UTF-8', true) === false) { + + $customLabels = decodeBase64EncodedLabels($storedLabels); + if ($customLabels === null && ! isBase64Encoded($storedLabels)) { + $customLabels = str($storedLabels)->replace(',', "\n")->value(); + } elseif ($customLabels === null) { $customLabels = str(implode('|coolify|', generateLabelsApplication($this, $preview)))->replace('|coolify|', "\n"); } + $this->custom_labels = base64_encode($customLabels); $this->save(); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index c3bd4a2238..0a9af3b707 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2414,6 +2414,32 @@ function isBase64Encoded($strValue) { return base64_encode(base64_decode($strValue, true)) === $strValue; } + +function decodeBase64EncodedLabels(string $value): ?string +{ + if (! isBase64Encoded($value)) { + return null; + } + + $decoded = base64_decode($value, true); + $labels = $decoded; + + while ($decoded !== '' && isBase64Encoded($decoded)) { + $decoded = base64_decode($decoded, true); + if (mb_detect_encoding($decoded, 'UTF-8', true) !== false) { + $lines = preg_split('/\r\n|\n|\r/', $decoded); + $containsOnlyLabels = collect($lines) + ->filter(fn (string $line) => $line !== '') + ->every(fn (string $line) => str_contains($line, '=') && ! str_starts_with($line, '=')); + + if ($containsOnlyLabels) { + $labels = $decoded; + } + } + } + + return mb_detect_encoding($labels, 'UTF-8', true) === false ? null : $labels; +} function customApiValidator(Collection|array $item, array $rules, array $messages = []) { if (is_array($item)) { diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php index c1d08bf388..5637c78363 100644 --- a/tests/Feature/Api/ApplicationSettingsApiTest.php +++ b/tests/Feature/Api/ApplicationSettingsApiTest.php @@ -185,6 +185,26 @@ test('http basic auth updates preserve user-managed labels', function () { expect(base64_decode($this->application->fresh()->custom_labels))->toBe('sentinel-label=true'); }); +test('nested base64 custom labels are stored in canonical form', function () { + $labels = "traefik.enable=true\ntraefik.http.routers.web.rule=Host(`example.com`)"; + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'custom_labels' => base64_encode(base64_encode($labels)), + ]) + ->assertOk(); + + expect($this->application->fresh()->custom_labels)->toBe(base64_encode($labels)); +}); + +test('deployment parsing repairs historically nested custom labels', function () { + $labels = "traefik.enable=true\ntraefik.http.routers.web.rule=Host(`example.com`)"; + $this->application->update(['custom_labels' => base64_encode(base64_encode($labels))]); + + expect($this->application->parseContainerLabels())->toBe($labels) + ->and($this->application->fresh()->custom_labels)->toBe(base64_encode($labels)); +}); + test('rejects invalid boolean application settings', function () { $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) ->patchJson("/api/v1/applications/{$this->application->uuid}", [ diff --git a/tests/Unit/CustomLabelEncodingTest.php b/tests/Unit/CustomLabelEncodingTest.php new file mode 100644 index 0000000000..3613a3efaa --- /dev/null +++ b/tests/Unit/CustomLabelEncodingTest.php @@ -0,0 +1,25 @@ +toBe($labels); +}); + +it('decodes single encoded container labels without changing them', function () { + $labels = "traefik.enable=true\ncom.example.version=1"; + + expect(decodeBase64EncodedLabels(base64_encode($labels)))->toBe($labels); +}); + +it('rejects values that are not valid base64', function () { + expect(decodeBase64EncodedLabels('traefik.enable=true'))->toBeNull(); +}); + +it('handles an empty encoded value', function () { + expect(decodeBase64EncodedLabels(''))->toBe(''); +}); + +it('does not decode label text that happens to be valid base64', function () { + expect(decodeBase64EncodedLabels(base64_encode('foo=')))->toBe('foo='); +});