diff --git a/extension/include/config/dynamic_admin_api/default.h b/extension/include/config/dynamic_admin_api/default.h index 7141f3828..6f45dd553 100644 --- a/extension/include/config/dynamic_admin_api/default.h +++ b/extension/include/config/dynamic_admin_api/default.h @@ -18,4 +18,9 @@ */ void kg_config_dynamic_admin_api_defaults_load(void); +/** + * @brief Releases persistent config strings owned by dynamic admin callbacks. + */ +void kg_config_dynamic_admin_api_defaults_release(void); + #endif /* KING_CONFIG_DYNAMIC_ADMIN_API_DEFAULT_H */ diff --git a/extension/include/php_king.h b/extension/include/php_king.h index e38a6082e..8841ece47 100644 --- a/extension/include/php_king.h +++ b/extension/include/php_king.h @@ -18,9 +18,6 @@ #endif #include -#include -#include -#include #include #include #include diff --git a/extension/src/config/dynamic_admin_api/default.c b/extension/src/config/dynamic_admin_api/default.c index d26c574bc..3a87cfeb1 100644 --- a/extension/src/config/dynamic_admin_api/default.c +++ b/extension/src/config/dynamic_admin_api/default.c @@ -14,6 +14,14 @@ #include "config/dynamic_admin_api/default.h" #include "config/dynamic_admin_api/base_layer.h" +static void dynamic_admin_api_release_owned_string(char **value) +{ + if (*value != NULL) { + pefree(*value, 1); + *value = NULL; + } +} + void kg_config_dynamic_admin_api_defaults_load(void) { king_dynamic_admin_api_config.bind_host = NULL; @@ -23,3 +31,18 @@ void kg_config_dynamic_admin_api_defaults_load(void) king_dynamic_admin_api_config.cert_file = NULL; king_dynamic_admin_api_config.key_file = NULL; } + +void kg_config_dynamic_admin_api_defaults_release(void) +{ + /* + * bind_host is managed by the engine's STD_PHP_INI_ENTRY storage. + * The remaining string fields are persistent copies created by the + * dynamic-admin-api INI callbacks in this config family. + */ + king_dynamic_admin_api_config.bind_host = NULL; + king_dynamic_admin_api_config.port = 2019; + dynamic_admin_api_release_owned_string(&king_dynamic_admin_api_config.auth_mode); + dynamic_admin_api_release_owned_string(&king_dynamic_admin_api_config.ca_file); + dynamic_admin_api_release_owned_string(&king_dynamic_admin_api_config.cert_file); + dynamic_admin_api_release_owned_string(&king_dynamic_admin_api_config.key_file); +} diff --git a/extension/src/config/dynamic_admin_api/index.c b/extension/src/config/dynamic_admin_api/index.c index 08bf2a1f6..a179a805f 100644 --- a/extension/src/config/dynamic_admin_api/index.c +++ b/extension/src/config/dynamic_admin_api/index.c @@ -23,4 +23,5 @@ void kg_config_dynamic_admin_api_init(void) void kg_config_dynamic_admin_api_shutdown(void) { kg_config_dynamic_admin_api_ini_unregister(); + kg_config_dynamic_admin_api_defaults_release(); } diff --git a/extension/tests/server_websocket_wire_helper.inc b/extension/tests/server_websocket_wire_helper.inc index 8ab6df835..9b5e9dd00 100644 --- a/extension/tests/server_websocket_wire_helper.inc +++ b/extension/tests/server_websocket_wire_helper.inc @@ -2,12 +2,20 @@ /* Test helper for starting the on-wire HTTP/1/WebSocket one-shot listener harness. */ +function king_server_websocket_wire_is_decimal_digits(string $value): bool +{ + return $value !== '' && strspn($value, '0123456789') === strlen($value); +} + function king_server_websocket_wire_start_server(string $mode, int $iterations = 1, ?int $fixedPort = null, array $env = []): array { $maxAttempts = $fixedPort !== null ? 1 : 10; for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { if ($fixedPort !== null) { + if ($fixedPort < 1 || $fixedPort > 65535) { + throw new RuntimeException('fixed on-wire HTTP/1 test port is outside the valid TCP port range'); + } $port = $fixedPort; } else { $errno = 0; @@ -19,38 +27,54 @@ function king_server_websocket_wire_start_server(string $mode, int $iterations = $serverName = stream_socket_get_name($probe, false); fclose($probe); - [, $port] = explode(':', $serverName, 2); - $port = (int) $port; + if (!is_string($serverName)) { + throw new RuntimeException('failed to read reserved on-wire HTTP/1 test port name'); + } + + $colon = strrpos($serverName, ':'); + if ($colon === false) { + throw new RuntimeException('reserved on-wire HTTP/1 test port name did not contain a port'); + } + + $portText = substr($serverName, $colon + 1); + if (!king_server_websocket_wire_is_decimal_digits($portText)) { + throw new RuntimeException('reserved on-wire HTTP/1 test port was not numeric'); + } + + $port = (int) $portText; + if ($port < 1 || $port > 65535) { + throw new RuntimeException('reserved on-wire HTTP/1 test port is outside the valid TCP port range'); + } } $capture = tempnam(sys_get_temp_dir(), 'king-server-websocket-wire-'); + if ($capture === false) { + throw new RuntimeException('failed to create on-wire HTTP/1 websocket capture file'); + } + $extensionPath = dirname(__DIR__) . '/modules/king.so'; - $commandParts = [ - escapeshellarg(PHP_BINARY), + $command = [ + PHP_BINARY, '-n', '-d', - escapeshellarg('extension=' . $extensionPath), + 'extension=' . $extensionPath, '-d', - escapeshellarg('king.security_allow_config_override=1'), + 'king.security_allow_config_override=1', ]; if ($mode === 'cors-allowlist') { - $commandParts[] = '-d'; - $commandParts[] = escapeshellarg( - 'king.security_cors_allowed_origins=https://app.king.test, https://admin.king.test' - ); + $command[] = '-d'; + $command[] = 'king.security_cors_allowed_origins=https://app.king.test, https://admin.king.test'; } - $commandParts = array_merge($commandParts, [ - escapeshellarg(__DIR__ . '/server_websocket_wire_server.inc'), - escapeshellarg($capture), + $command = array_merge($command, [ + __DIR__ . '/server_websocket_wire_server.inc', + $capture, (string) (int) $port, - escapeshellarg($mode), + $mode, (string) max(1, $iterations), ]); - $command = implode(' ', $commandParts); - $processEnv = null; if ($env !== []) { $baseEnv = getenv(); @@ -95,6 +119,17 @@ function king_server_websocket_wire_start_server(string $mode, int $iterations = throw new RuntimeException('failed to launch on-wire HTTP/1 websocket server'); } +function king_server_websocket_wire_assert_request_target(string $path): void +{ + if ($path === '' || $path[0] !== '/') { + throw new RuntimeException('raw websocket request target must be an absolute path'); + } + + if (str_contains($path, "\r") || str_contains($path, "\n")) { + throw new RuntimeException('raw websocket request target must not contain CRLF bytes'); + } +} + function king_server_websocket_wire_stop_server(array $server): array { $stdout = isset($server['pipes'][1]) ? stream_get_contents($server['pipes'][1]) : ''; @@ -284,6 +319,8 @@ function king_server_websocket_wire_raw_client_connect(int $port, string $path) $socket = false; $message = 'connection failed'; + king_server_websocket_wire_assert_request_target($path); + if (!function_exists('socket_create')) { throw new RuntimeException('The sockets extension is required for raw websocket client fixtures. Please enable it in php.ini.'); } diff --git a/infra/scripts/install-ubuntu-php-runtime.sh b/infra/scripts/install-ubuntu-php-runtime.sh index dc5289e09..cab7ad775 100755 --- a/infra/scripts/install-ubuntu-php-runtime.sh +++ b/infra/scripts/install-ubuntu-php-runtime.sh @@ -3,12 +3,24 @@ set -euo pipefail PHP_VERSION="${1:-}" +ONDREJ_PHP_KEY_FINGERPRINT="B8DC7E53946656EFBCE4C1DD71DAEAAB4AD4CAB6" +APT_RETRY_OPTIONS=( + -o Acquire::Retries=5 + -o Acquire::http::Timeout=30 + -o Acquire::https::Timeout=30 + -o Acquire::ForceIPv4=true +) if [[ -z "${PHP_VERSION}" ]]; then echo "Usage: install-ubuntu-php-runtime.sh " >&2 exit 1 fi +if [[ ! "${PHP_VERSION}" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + echo "Invalid PHP version: ${PHP_VERSION}" >&2 + exit 1 +fi + normalize_ubuntu_sources_to_scheme() { local scheme="$1" local source_file="" @@ -23,14 +35,44 @@ normalize_ubuntu_sources_to_scheme() { done } +install_ondrej_php_keyring() { + local key_tmp="" + local keyring_tmp="" + local fingerprint="" + + key_tmp="$(mktemp)" + keyring_tmp="$(mktemp)" + + if ! curl --retry 5 --retry-delay 2 --retry-connrefused --retry-all-errors -fsSL \ + 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xB8DC7E53946656EFBCE4C1DD71DAEAAB4AD4CAB6' \ + -o "${key_tmp}"; then + rm -f "${key_tmp}" "${keyring_tmp}" + return 1 + fi + + fingerprint="$( + gpg --show-keys --with-colons "${key_tmp}" \ + | awk -F: '$1 == "fpr" { print $10; exit }' + )" + + if [[ "${fingerprint}" != "${ONDREJ_PHP_KEY_FINGERPRINT}" ]]; then + echo "Unexpected Ondrej PHP PPA key fingerprint: ${fingerprint:-}" >&2 + rm -f "${key_tmp}" "${keyring_tmp}" + return 1 + fi + + if ! gpg --batch --yes --dearmor -o "${keyring_tmp}" "${key_tmp}"; then + rm -f "${key_tmp}" "${keyring_tmp}" + return 1 + fi + + install -m 0644 "${keyring_tmp}" /usr/share/keyrings/ondrej-php.gpg + rm -f "${key_tmp}" "${keyring_tmp}" +} + normalize_ubuntu_sources_to_scheme http -apt-get \ - -o Acquire::Retries=5 \ - -o Acquire::http::Timeout=30 \ - -o Acquire::https::Timeout=30 \ - -o Acquire::ForceIPv4=true \ - update +apt-get "${APT_RETRY_OPTIONS[@]}" update apt-get install -y --no-install-recommends \ ca-certificates \ @@ -41,28 +83,21 @@ apt-get install -y --no-install-recommends \ normalize_ubuntu_sources_to_scheme https -apt-get \ - -o Acquire::Retries=5 \ - -o Acquire::http::Timeout=30 \ - -o Acquire::https::Timeout=30 \ - -o Acquire::ForceIPv4=true \ - update +apt-get "${APT_RETRY_OPTIONS[@]}" update mkdir -p /usr/share/keyrings -curl --retry 5 --retry-delay 2 --retry-connrefused --retry-all-errors -fsSL \ - 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xB8DC7E53946656EFBCE4C1DD71DAEAAB4AD4CAB6' \ - | gpg --dearmor -o /usr/share/keyrings/ondrej-php.gpg +install_ondrej_php_keyring . /etc/os-release +if [[ -z "${VERSION_CODENAME:-}" || ! "${VERSION_CODENAME}" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Ubuntu VERSION_CODENAME is missing or invalid." >&2 + exit 1 +fi + printf 'deb [signed-by=/usr/share/keyrings/ondrej-php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu %s main\n' "${VERSION_CODENAME}" \ > /etc/apt/sources.list.d/ondrej-php.list -apt-get \ - -o Acquire::Retries=5 \ - -o Acquire::http::Timeout=30 \ - -o Acquire::https::Timeout=30 \ - -o Acquire::ForceIPv4=true \ - update +apt-get "${APT_RETRY_OPTIONS[@]}" update apt-get install -y --no-install-recommends \ "php${PHP_VERSION}-cli" \ diff --git a/infra/scripts/resolve-docker-buildx-cache.sh b/infra/scripts/resolve-docker-buildx-cache.sh index 918830af7..f94dc1945 100755 --- a/infra/scripts/resolve-docker-buildx-cache.sh +++ b/infra/scripts/resolve-docker-buildx-cache.sh @@ -56,6 +56,11 @@ if [[ ! "${SCOPE}" =~ ^[A-Za-z0-9._-]+$ ]]; then exit 1 fi +if [[ "${SCOPE}" == "." || "${SCOPE}" == ".." ]]; then + echo "--scope must not be a relative path segment." >&2 + exit 1 +fi + write_output() { local key="$1" local value="$2" @@ -77,15 +82,39 @@ write_scalar_output() { cache_from="type=gha,scope=${SCOPE}" cache_to="type=gha,mode=max,scope=${SCOPE},ignore-error=true" local_root="${KING_CI_LOCAL_DOCKER_BUILDX_CACHE_DIR:-}" +local_root_real="" use_local="false" local_dir="" local_next_dir="" if [[ -n "${local_root}" && -d "${local_root}" && -w "${local_root}" ]]; then - local_dir="${local_root%/}/${SCOPE}" - local_next_dir="${local_root%/}/${SCOPE}.next" + local_root_real="$(realpath -m "${local_root}")" + if [[ "${local_root_real}" == "/" ]]; then + echo "Refusing to use filesystem root as Docker Buildx local cache root." >&2 + exit 1 + fi + + local_dir="${local_root_real}/${SCOPE}" + local_next_dir="${local_root_real}/${SCOPE}.next" + + case "${local_dir}" in + "${local_root_real}/"*) ;; + *) + echo "Resolved local cache directory escaped the configured cache root." >&2 + exit 1 + ;; + esac + + case "${local_next_dir}" in + "${local_root_real}/"*) ;; + *) + echo "Resolved local cache next directory escaped the configured cache root." >&2 + exit 1 + ;; + esac + mkdir -p "${local_dir}" - rm -rf "${local_next_dir}" + rm -rf -- "${local_next_dir}" mkdir -p "${local_next_dir}" cache_from=$(printf '%s\n%s' "type=local,src=${local_dir}" "${cache_from}")