diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index 8c0922642f..9fd3eecbc8 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -4,6 +4,7 @@ namespace App\Actions\Server; use App\Models\Server; use App\Models\Service; +use App\Rules\ValidFluentBitConfig; use Lorisleiva\Actions\Concerns\AsAction; class StartLogDrain @@ -131,6 +132,14 @@ class StartLogDrain if (! $server->settings->is_logdrain_custom_enabled) { throw new \Exception('Custom log drain is not enabled.'); } + $configError = (new ValidFluentBitConfig)->check($server->settings->logdrain_custom_config); + if ($configError !== null) { + throw new \Exception("Custom log drain config is not allowed: {$configError}"); + } + $parsersError = (new ValidFluentBitConfig(parsersOnly: true))->check($server->settings->logdrain_custom_config_parser); + if ($parsersError !== null) { + throw new \Exception("Custom log drain parser config is not allowed: {$parsersError}"); + } $config = base64_encode($server->settings->logdrain_custom_config); $parsers = base64_encode($server->settings->logdrain_custom_config_parser); } else { @@ -153,8 +162,8 @@ services: env_file: - .env volumes: - - ./fluent-bit.conf:/fluent-bit.conf - - ./parsers.conf:/parsers.conf + - ./fluent-bit.conf:/fluent-bit.conf:ro + - ./parsers.conf:/parsers.conf:ro ports: - 127.0.0.1:24224:24224 labels: diff --git a/app/Http/Controllers/Api/ServerLogDrainsController.php b/app/Http/Controllers/Api/ServerLogDrainsController.php index ba1faa787e..1c46881307 100644 --- a/app/Http/Controllers/Api/ServerLogDrainsController.php +++ b/app/Http/Controllers/Api/ServerLogDrainsController.php @@ -6,6 +6,7 @@ use App\Actions\Server\StartLogDrain; use App\Actions\Server\StopLogDrain; use App\Http\Controllers\Controller; use App\Models\Server; +use App\Rules\ValidFluentBitConfig; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -108,7 +109,7 @@ class ServerLogDrainsController extends Controller #[OA\Patch( summary: 'Update log drain settings', - description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission.', + description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.', path: '/servers/{uuid}/log-drains', operationId: 'update-server-log-drains', security: [['bearerAuth' => []]], @@ -137,6 +138,7 @@ class ServerLogDrainsController extends Controller new OA\Response(response: 200, description: 'Updated log drain settings.'), new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'Missing the deploy permission for a custom log drain change.'), new OA\Response(response: 404, ref: '#/components/responses/404'), new OA\Response(response: 422, ref: '#/components/responses/422'), ], @@ -160,6 +162,14 @@ class ServerLogDrainsController extends Controller $this->authorize('update', $server); + // The custom config is applied on the server, like a deployment. + $customConfigChanged = collect(['logdrain_custom_config', 'logdrain_custom_config_parser']) + ->contains(fn (string $field) => $request->has($field) + && (! is_string($request->input($field) ?? '') || (string) $request->input($field) !== (string) $server->settings->{$field})); + if ($customConfigChanged && ! $request->user()->tokenCan('deploy') && ! $request->user()->tokenCan('root')) { + return response()->json(['message' => 'Missing required permissions: deploy'], 403); + } + $validator = customApiValidator($request->all(), [ 'is_logdrain_newrelic_enabled' => 'boolean', 'logdrain_newrelic_license_key' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], @@ -168,8 +178,8 @@ class ServerLogDrainsController extends Controller 'logdrain_axiom_dataset_name' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], 'logdrain_axiom_api_key' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], 'is_logdrain_custom_enabled' => 'boolean', - 'logdrain_custom_config' => 'nullable|string', - 'logdrain_custom_config_parser' => 'nullable|string', + 'logdrain_custom_config' => ['nullable', 'string', new ValidFluentBitConfig], + 'logdrain_custom_config_parser' => ['nullable', 'string', new ValidFluentBitConfig(parsersOnly: true)], ]); $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); @@ -187,13 +197,6 @@ class ServerLogDrainsController extends Controller $settings = $server->settings; - // The custom config is applied on the server, like a deployment. - $customConfigChanged = collect(['logdrain_custom_config', 'logdrain_custom_config_parser']) - ->contains(fn (string $field) => $request->has($field) && (string) $request->input($field) !== (string) $settings->{$field}); - if ($customConfigChanged && ! $request->user()->tokenCan('deploy') && ! $request->user()->tokenCan('root')) { - return response()->json(['message' => 'Missing required permissions: deploy'], 403); - } - foreach (self::ALLOWED_FIELDS as $field) { if ($request->has($field)) { $settings->{$field} = $request->input($field); diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 965232af44..ef9addf231 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -5,6 +5,7 @@ namespace App\Livewire\Server; use App\Actions\Server\StartLogDrain; use App\Actions\Server\StopLogDrain; use App\Models\Server; +use App\Rules\ValidFluentBitConfig; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; @@ -158,8 +159,8 @@ class LogDrains extends Component } elseif ($this->isLogDrainCustomEnabled) { try { $this->validate([ - 'logDrainCustomConfig' => ['required'], - 'logDrainCustomConfigParser' => ['string', 'nullable'], + 'logDrainCustomConfig' => ['required', new ValidFluentBitConfig], + 'logDrainCustomConfigParser' => ['string', 'nullable', new ValidFluentBitConfig(parsersOnly: true)], ]); } catch (\Throwable $e) { $this->isLogDrainCustomEnabled = false; @@ -279,8 +280,8 @@ class LogDrains extends Component 'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], ]), 'custom' => $this->validate([ - 'logDrainCustomConfig' => ['required'], - 'logDrainCustomConfigParser' => ['string', 'nullable'], + 'logDrainCustomConfig' => ['required', new ValidFluentBitConfig], + 'logDrainCustomConfigParser' => ['string', 'nullable', new ValidFluentBitConfig(parsersOnly: true)], ]), default => throw new \InvalidArgumentException('Unknown log drain type.'), }; diff --git a/app/Rules/ValidFluentBitConfig.php b/app/Rules/ValidFluentBitConfig.php new file mode 100644 index 0000000000..96a9b609a6 --- /dev/null +++ b/app/Rules/ValidFluentBitConfig.php @@ -0,0 +1,105 @@ +check($value); + if ($error !== null) { + $fail("The :attribute is not allowed: {$error}"); + } + } + + /** + * Returns the reason the value is not allowed, or null when it is safe. + */ + public function check(mixed $value): ?string + { + if (blank($value)) { + return null; + } + if (! is_string($value)) { + return 'it must be a string.'; + } + + $allowedSections = $this->parsersOnly ? self::PARSER_SECTIONS : self::CONFIG_SECTIONS; + $section = null; + + foreach (preg_split('/\r\n|\r|\n/', $value) as $number => $rawLine) { + $line = trim($rawLine); + $lineNumber = $number + 1; + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + + if (str_starts_with($line, '@')) { + $directive = strtolower(preg_split('/\s+/', $line)[0]); + if ($this->parsersOnly || $directive !== '@set') { + return "line {$lineNumber} uses the {$directive} directive."; + } + + continue; + } + + if (str_starts_with($line, '[')) { + if (! preg_match('/^\[\s*([A-Za-z_]+)\s*\]$/', $line, $matches)) { + return "line {$lineNumber} is not a valid section header."; + } + $section = strtoupper($matches[1]); + if (! in_array($section, $allowedSections, true)) { + return "line {$lineNumber} uses the [{$section}] section. Allowed sections: ".implode(', ', $allowedSections).'.'; + } + + continue; + } + + if ($section === null) { + return "line {$lineNumber} is outside a section."; + } + + $parts = preg_split('/\s+/', $line, 2); + $key = strtolower($parts[0]); + if (! preg_match('/^[a-z_][a-z0-9_.\-]*$/', $key)) { + return "line {$lineNumber} has an invalid key."; + } + if (in_array($key, self::DENIED_KEYS, true)) { + return "line {$lineNumber} uses the {$key} key."; + } + + if ($key === 'name' && in_array($section, self::PLUGIN_SECTIONS, true)) { + $plugin = strtolower(explode('://', trim($parts[1] ?? ''), 2)[0]); + if (! preg_match('/^[a-z0-9_]+$/', $plugin)) { + return "line {$lineNumber} must name a plugin literally."; + } + if (in_array($plugin, self::DENIED_PLUGINS, true)) { + return "line {$lineNumber} uses the {$plugin} plugin."; + } + } + } + + return null; + } +} diff --git a/openapi.json b/openapi.json index 95e9a7a275..a15dcdde82 100644 --- a/openapi.json +++ b/openapi.json @@ -16575,7 +16575,7 @@ "Servers" ], "summary": "Update log drain settings", - "description": "Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission.", + "description": "Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.", "operationId": "update-server-log-drains", "parameters": [ { @@ -16637,6 +16637,9 @@ "400": { "$ref": "#\/components\/responses\/400" }, + "403": { + "description": "Missing the deploy permission for a custom log drain change." + }, "404": { "$ref": "#\/components\/responses\/404" }, @@ -24012,6 +24015,9 @@ "is_env_sorting_enabled": { "type": "boolean" }, + "include_coolify_default_variables": { + "type": "boolean" + }, "is_container_label_readonly_enabled": { "type": "boolean" }, diff --git a/openapi.yaml b/openapi.yaml index 9a8b956142..2e172706d0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10522,7 +10522,7 @@ paths: tags: - Servers summary: 'Update log drain settings' - description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission.' + description: 'Update New Relic, Axiom, or custom log drain settings for a server owned by the authenticated team. Changing `logdrain_custom_config` or `logdrain_custom_config_parser` needs a token with the `deploy` permission. Custom configs cannot use the lua, wasm, exec, exec_wasi, or file plugins, @INCLUDE, or plugins_file, and the parser file can only contain [PARSER] and [MULTILINE_PARSER] sections.' operationId: update-server-log-drains parameters: - @@ -10564,6 +10564,8 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '403': + description: 'Missing the deploy permission for a custom log drain change.' '404': $ref: '#/components/responses/404' '422': @@ -15309,6 +15311,8 @@ components: type: boolean is_env_sorting_enabled: type: boolean + include_coolify_default_variables: + type: boolean is_container_label_readonly_enabled: type: boolean is_preserve_repository_enabled: diff --git a/tests/Feature/LogDrain/CustomLogDrainValidationTest.php b/tests/Feature/LogDrain/CustomLogDrainValidationTest.php new file mode 100644 index 0000000000..d3084685b4 --- /dev/null +++ b/tests/Feature/LogDrain/CustomLogDrainValidationTest.php @@ -0,0 +1,202 @@ + 'array', + 'cache.default' => 'array', + 'cache.stores.redis.driver' => 'array', + ]); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); +}); + +function customLogDrainPatch(string $token, string $serverUuid, array $payload) +{ + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json', + ])->patchJson("/api/v1/servers/{$serverUuid}/log-drains", $payload); +} + +describe('ValidFluentBitConfig rule', function () { + test('accepts normal configs and parser files', function () { + expect((new ValidFluentBitConfig)->check(SAFE_CUSTOM_LOG_DRAIN_CONFIG))->toBeNull() + ->and((new ValidFluentBitConfig(parsersOnly: true))->check(SAFE_CUSTOM_LOG_DRAIN_PARSERS))->toBeNull() + ->and((new ValidFluentBitConfig)->check(null))->toBeNull(); + }); + + test('rejects unsupported plugins and directives', function (string $config) { + expect((new ValidFluentBitConfig)->check($config))->not->toBeNull(); + })->with([ + 'lua filter' => customLogDrainConfigWith("[FILTER]\n Name lua\n Match *\n script /parsers.conf\n call cb"), + 'lua filter, other case' => customLogDrainConfigWith("[filter]\n\tNAME\tLua\n Match *"), + 'wasm filter' => customLogDrainConfigWith("[FILTER]\n Name wasm\n Match *"), + 'exec input' => "[INPUT]\n Name exec\n", + 'exec_wasi input' => "[INPUT]\n Name exec_wasi\n", + 'file output' => "[INPUT]\n Name forward\n[OUTPUT]\n Name file\n Path /etc\n", + 'plugin name with protocol' => "[INPUT]\n Name exec://x\n", + 'plugin name from a variable' => "@SET p=lua\n".customLogDrainConfigWith("[FILTER]\n Name \${p}\n Match *"), + 'include directive' => "@INCLUDE /parsers.conf\n", + 'plugins section' => "[PLUGINS]\n Path /parsers.conf\n", + 'plugins_file key' => "[SERVICE]\n Plugins_File /parsers.conf\n", + 'malformed section header' => "[FILTER] x\n Name lua\n", + 'key outside a section' => "Name lua\n", + ]); + + test('rejects parser files that are not parser declarations', function (string $parsers) { + expect((new ValidFluentBitConfig(parsersOnly: true))->check($parsers))->not->toBeNull(); + })->with([ + 'script code' => "function cb(tag, timestamp, record)\n return 1, timestamp, record\nend\n", + 'input section' => "[INPUT]\n Name forward\n", + 'set directive' => "@SET a=b\n[PARSER]\n Name x\n", + ]); +}); + +describe('custom log drain API', function () { + test('a write-only token cannot change a custom log drain', function (array $payload) { + Queue::fake(); + $token = $this->user->createToken('write-only', ['write'])->plainTextToken; + + customLogDrainPatch($token, $this->server->uuid, $payload) + ->assertForbidden() + ->assertJsonPath('message', 'Missing required permissions: deploy'); + + $settings = $this->server->settings->fresh(); + expect((bool) $settings->is_logdrain_custom_enabled)->toBeFalse() + ->and($settings->logdrain_custom_config)->toBeNull(); + Queue::assertNothingPushed(); + })->with([ + 'enable with config' => [['is_logdrain_custom_enabled' => true, 'logdrain_custom_config' => SAFE_CUSTOM_LOG_DRAIN_CONFIG]], + 'config only' => [['logdrain_custom_config' => SAFE_CUSTOM_LOG_DRAIN_CONFIG]], + 'parser only' => [['logdrain_custom_config_parser' => SAFE_CUSTOM_LOG_DRAIN_PARSERS]], + ]); + + test('a write-only token can still change the built-in log drains', function () { + Queue::fake(); + $token = $this->user->createToken('write-only', ['write'])->plainTextToken; + + customLogDrainPatch($token, $this->server->uuid, [ + 'logdrain_axiom_dataset_name' => 'api-dataset', + 'logdrain_axiom_api_key' => 'axiom-key-123', + ])->assertOk(); + + expect($this->server->settings->fresh()->logdrain_axiom_dataset_name)->toBe('api-dataset'); + }); + + test('a deploy token can save a safe custom log drain', function () { + Queue::fake(); + $token = $this->user->createToken('write-deploy', ['write', 'deploy'])->plainTextToken; + + customLogDrainPatch($token, $this->server->uuid, [ + 'is_logdrain_custom_enabled' => true, + 'logdrain_custom_config' => SAFE_CUSTOM_LOG_DRAIN_CONFIG, + 'logdrain_custom_config_parser' => SAFE_CUSTOM_LOG_DRAIN_PARSERS, + ])->assertOk(); + + $settings = $this->server->settings->fresh(); + expect((bool) $settings->is_logdrain_custom_enabled)->toBeTrue() + ->and($settings->logdrain_custom_config)->toBe(SAFE_CUSTOM_LOG_DRAIN_CONFIG); + }); + + test('a deploy token cannot save an unsupported custom log drain', function (array $payload, string $errorField) { + Queue::fake(); + $token = $this->user->createToken('write-deploy', ['write', 'deploy'])->plainTextToken; + + customLogDrainPatch($token, $this->server->uuid, ['is_logdrain_custom_enabled' => true, ...$payload]) + ->assertUnprocessable() + ->assertJsonValidationErrors($errorField); + + expect((bool) $this->server->settings->fresh()->is_logdrain_custom_enabled)->toBeFalse(); + Queue::assertNothingPushed(); + })->with([ + 'lua filter' => [ + ['logdrain_custom_config' => customLogDrainConfigWith("[FILTER]\n Name lua\n Match *\n script /parsers.conf\n call cb")], + 'logdrain_custom_config', + ], + 'parser file with script code' => [ + ['logdrain_custom_config' => SAFE_CUSTOM_LOG_DRAIN_CONFIG, 'logdrain_custom_config_parser' => "function cb()\nend\n"], + 'logdrain_custom_config_parser', + ], + ]); +}); + +test('StartLogDrain does not start a stored unsupported custom log drain', function () { + StopLogDrain::mock()->shouldReceive('handle')->andReturn('stopped'); + $this->server->settings->forceFill([ + 'is_logdrain_custom_enabled' => true, + 'logdrain_custom_config' => customLogDrainConfigWith("[FILTER]\n Name lua\n Match *\n script /parsers.conf\n call cb"), + 'logdrain_custom_config_parser' => SAFE_CUSTOM_LOG_DRAIN_PARSERS, + ])->save(); + + expect(fn () => StartLogDrain::run($this->server->fresh())) + ->toThrow(Exception::class, 'Custom log drain config is not allowed: line 4 uses the lua plugin.'); +}); + +test('the UI does not enable an unsupported custom log drain', function () { + $this->actingAs($this->user); + StartLogDrain::mock()->shouldNotReceive('handle'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCustomConfig', customLogDrainConfigWith("[FILTER]\n Name wasm\n Match *")) + ->call('toggleLogDrain', 'custom') + ->assertDispatched('error') + ->assertSet('isLogDrainCustomEnabled', false); + + expect((bool) $this->server->settings->fresh()->is_logdrain_custom_enabled)->toBeFalse(); +});