Merge pull request #182 from copenhagentruckwash/fix-edge-broker-default-shared-secret-issue

Remove insecure default edge broker shared secret and stop exposing port 4300
This commit is contained in:
Jeppe B
2026-06-01 22:07:10 +02:00
committed by GitHub
4 changed files with 180 additions and 0 deletions
+8
View File
@@ -119,7 +119,11 @@ services:
environment:
AUTO_COMPOSER_INSTALL: "true"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
<<<<<<< HEAD
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-}
=======
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
>>>>>>> origin/master
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -140,7 +144,11 @@ services:
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
<<<<<<< HEAD
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-}
=======
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
>>>>>>> origin/master
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
+14
View File
@@ -7,6 +7,11 @@ import { fileURLToPath } from "node:url";
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDirectory, "../../..");
<<<<<<< HEAD
test("traefik defines a dedicated edge broker entrypoint on port 4300", () => {
assert.match(traefikSource, /edge-broker:\s*\n\s*address:\s*":4300"/);
assert.match(traefikProdSource, /edge-broker:\s*\n\s*address:\s*":4300"/);
=======
function readRequiredSource(...pathSegments) {
const sourcePath = path.resolve(repoRoot, ...pathSegments);
assert.equal(existsSync(sourcePath), true, `Expected config fixture to exist: ${sourcePath}`);
@@ -34,6 +39,7 @@ function readComposeServiceBlock(composeSource, serviceName) {
test("traefik does not expose a dedicated public edge broker port", () => {
assert.doesNotMatch(traefikSource, /edge-broker:\s*\n\s*address:\s*":4300"/);
>>>>>>> origin/master
});
<<<<<<< HEAD
@@ -42,6 +48,13 @@ test("base docker compose binds edge broker port 4300 to localhost only", () =>
assert.match(baseComposeSource, /edge-broker:\s*\n[\s\S]*?\n\s+ports:\s*\n\s+- "127.0.0.1:4300:4300"/);
});
<<<<<<< HEAD
test("php services receive broker url defaults and require explicit broker shared secret", () => {
assert.match(composeSource, /php1:[\s\S]*EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
assert.match(composeSource, /php1:[\s\S]*EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-\}/);
assert.match(composeSource, /php-staging:[\s\S]*EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
assert.match(composeSource, /php-cron:[\s\S]*EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-\}/);
=======
test("example docker compose binds edge broker port 4300 to localhost only", () => {
assert.match(exampleComposeSource, /\bedge-broker:\b/);
assert.match(exampleComposeSource, /edge-broker:\s*\n[\s\S]*?\n\s+ports:\s*\n\s+- "127.0.0.1:4300:4300"/);
@@ -101,4 +114,5 @@ test("base docker compose wires the broker into each php worker", () => {
assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
}
>>>>>>> origin/master
});
@@ -0,0 +1,145 @@
<?php
namespace classes;
use Exception;
class edge_broker_transport_exception extends Exception
{
public function __construct(string $message, private readonly int $curlErrno = 0, int $code = 0, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
public function curlErrno(): int
{
return $this->curlErrno;
}
}
class edge_broker_http_exception extends Exception
{
public function __construct(string $message, private readonly int $statusCode, int $code = 0, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
public function statusCode(): int
{
return $this->statusCode;
}
}
class edge_broker_client
{
private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';
public function __construct(
private readonly ?string $baseUrl = null,
private readonly ?string $sharedSecret = null,
private readonly int $timeoutSeconds = 10
) {
}
public function isConfigured(): bool
{
return trim((string)$this->resolveBaseUrl()) !== '';
}
public function dispatchCommand(int $gatewayId, string $commandType, array $payload): array
{
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/gateways/' . $gatewayId . '/commands';
$response = $this->request('POST', $url, [
'commandType' => $commandType,
'payload' => $payload,
]);
return is_array($response) ? $response : ['ok' => false, 'response' => $response];
}
public function validateAgent(int $gatewayId, string $agentToken): array
{
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/agent/auth';
$response = $this->request('POST', $url, [
'gatewayId' => $gatewayId,
'agentToken' => $agentToken,
]);
return is_array($response) ? $response : [];
}
public function validateShellSession(string $sessionToken): array
{
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell/auth';
$response = $this->request('POST', $url, [
'sessionToken' => $sessionToken,
]);
return is_array($response) ? $response : [];
}
public function closeShellSession(int $sessionId, string $sessionToken, string $transcript, string $closedReason): array
{
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell-sessions/' . $sessionId . '/close';
$response = $this->request('POST', $url, [
'sessionToken' => $sessionToken,
'transcript' => $transcript,
'closedReason' => $closedReason,
]);
return is_array($response) ? $response : [];
}
private function resolveBaseUrl(): string
{
return trim((string)($this->baseUrl ?? getenv('EDGE_BROKER_URL') ?: self::DEFAULT_BROKER_URL));
}
private function resolveSharedSecret(): string
{
return trim((string)($this->sharedSecret
?? getenv('EDGE_BROKER_SHARED_SECRET')
?: getenv('EDGE_INTERNAL_SECRET')
?: ''));
}
/**
* @throws Exception
*/
private function request(string $method, string $url, array $payload): array|object|null
{
if (trim($url) === '') {
throw new Exception('Edge broker URL is not configured');
}
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeoutSeconds);
curl_setopt($ch, CURLOPT_HTTPHEADER, array_values(array_filter([
'Content-Type: application/json',
$this->resolveSharedSecret() !== '' ? 'X-Edge-Broker-Secret: ' . $this->resolveSharedSecret() : null,
])));
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
$rawResponse = curl_exec($ch);
$statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
curl_close($ch);
if ($rawResponse === false) {
throw new edge_broker_transport_exception('Edge broker request failed: ' . $curlError, $curlErrno);
}
$decoded = json_decode((string)$rawResponse, true);
if ($statusCode >= 400) {
$message = is_array($decoded)
? (string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed')
: 'Edge broker request failed';
throw new edge_broker_http_exception($message, $statusCode);
}
return $decoded;
}
}
@@ -0,0 +1,13 @@
<?php
it('uses default broker url but requires explicit shared secret configuration', function (): void {
$source = file_get_contents(app_path('classes/edge_broker_client.php'));
expect($source)->not->toBeFalse();
expect($source)->toContain("private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';");
expect($source)->toContain('class edge_broker_transport_exception extends Exception');
expect($source)->toContain('class edge_broker_http_exception extends Exception');
expect($source)->toContain("getenv('EDGE_BROKER_SHARED_SECRET')");
expect($source)->toContain("getenv('EDGE_INTERNAL_SECRET')");
expect($source)->not->toContain('DEFAULT_SHARED_SECRET');
});