fix(log-drains): validate custom log drain configuration (#12197)

This commit is contained in:
Andras Bacsai
2026-10-18 23:40:54 +02:00
committed by GitHub
parent a16994a88f
commit 9355728d03
7 changed files with 348 additions and 18 deletions
+11 -2
View File
@@ -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:
@@ -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);
+5 -4
View File
@@ -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.'),
};
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Validates a custom Fluent Bit classic-mode file for the log drain container.
*
* Only a supported subset of sections, directives, and plugins is allowed. With $parsersOnly, the value
* must only contain [PARSER] and [MULTILINE_PARSER] declarations.
*/
class ValidFluentBitConfig implements ValidationRule
{
private const CONFIG_SECTIONS = ['SERVICE', 'INPUT', 'FILTER', 'OUTPUT', 'PARSER', 'MULTILINE_PARSER'];
private const PARSER_SECTIONS = ['PARSER', 'MULTILINE_PARSER'];
private const PLUGIN_SECTIONS = ['INPUT', 'FILTER', 'OUTPUT'];
private const DENIED_PLUGINS = ['lua', 'wasm', 'exec', 'exec_wasi', 'file'];
private const DENIED_KEYS = ['plugins_file'];
public function __construct(private bool $parsersOnly = false) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$error = $this->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;
}
}
+7 -1
View File
@@ -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"
},
+5 -1
View File
@@ -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:
@@ -0,0 +1,202 @@
<?php
use App\Actions\Server\StartLogDrain;
use App\Actions\Server\StopLogDrain;
use App\Livewire\Server\LogDrains;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use App\Rules\ValidFluentBitConfig;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
const SAFE_CUSTOM_LOG_DRAIN_CONFIG = <<<'CONF'
[SERVICE]
Flush 5
Parsers_File parsers.conf
@SET region=eu
[INPUT]
Name forward
[FILTER]
Name grep
Match *
Exclude log 127.0.0.1
[OUTPUT]
Name http
Match *
Host logs.example.com
Port 443
CONF;
const SAFE_CUSTOM_LOG_DRAIN_PARSERS = <<<'CONF'
# Skip empty lines
[PARSER]
Name empty_line_skipper
Format regex
Regex /^(?!\s*$).+/
[MULTILINE_PARSER]
name multiline-regex
type regex
rule "start_state" "/^\d+/" "cont"
CONF;
function customLogDrainConfigWith(string $section): string
{
return "[INPUT]\n Name forward\n{$section}\n[OUTPUT]\n Name stdout\n Match *\n";
}
beforeEach(function () {
Server::flushIdentityMap();
config([
'app.maintenance.store' => '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();
});