Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **A TCP listener can ask the kernel for a port, and say which one it got.** `addListener()`, `addHttp1Listener()` and `addHttp2Listener()` take port 0, which binds to whatever the kernel assigns, and `HttpServer::getBoundListeners()` reports what the server actually holds — one entry per configured listener, in configuration order, empty while the server is not running. Naming a free port before binding it leaves a window in which the port belongs to nobody: the phpt suite picks ports that way and loses the race under `-j4`, where a second server's `start()` answers `Failed to acquire TCP listener for 127.0.0.1:58864 (bind)`. Port 0 removes the window rather than narrowing it. Across several threads such a listener is bound once into the shared set and every thread adopts a duplicate, SO_REUSEPORT or not — binding per thread would hand each a different port. HTTP/3 still requires an explicit port: it binds through the UDP path, which reports no local address, so 0 is refused there rather than answered with a guess.

### Security

- **`sendFile()` and `StaticHandler` walked around `open_basedir` (#280).** The server opens response files itself rather than through PHP streams, and never consulted the interpreter's policy, so a handler could hand out any file the process could read: with `open_basedir` set, `fopen()` refused the path and `sendFile()` answered `200 OK` with its contents. `open_basedir` is the operator's boundary, and the handler is exactly the code it exists to contain — the trust `sendFile()` places in handler code is a contract with the application, not a licence to overrule the operator. All three entry points now call `php_check_open_basedir_ex()`: `sendFile()` throws, the send-file engine answers 500, and a mount rooted outside the boundary is refused at construction. Scope: the mount root is validated once, at construction, so narrowing `open_basedir` at runtime below an existing mount does not retroactively close it; `sendFile()` is checked per call. Evidence: `sendfile/007-sendfile-open-basedir` serves the running binary against `main` and answers 500 here.
Expand Down
170 changes: 159 additions & 11 deletions src/http_server_class.c
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,15 @@ typedef struct {
} http_pool_unix_fd_t;

/* A TCP listener bound once and shared the same way, for every platform
* whose kernel has no load-balanced SO_REUSEPORT. host/port identify the
* entry so a thread can match its config against what is already bound. */
* whose kernel has no load-balanced SO_REUSEPORT. The entry is identified by
* the listener's position in the configuration, which every thread walks in
* the same order: host and port cannot identify it, because two listeners
* asking for port 0 look alike until the kernel has answered. */
typedef struct {
char host[64];
int port;
int requested_port; /* what the configuration asked for; 0 means "the kernel picks" */
int bound_port; /* what the socket is bound to — the answer for a requested 0 */
size_t listener_index;
zend_socket_t fd;
} http_pool_tcp_fd_t;

Expand Down Expand Up @@ -2561,8 +2565,57 @@ static int http_server_prebind_unix(http_server_object *server)
*
* error_out receives a short reason for the caller to phrase; the lock is
* never held across a throw. */
/* The port under a bound socket. `fallback` is answered when the address
* cannot be read, which leaves the caller with what it asked for rather than
* with a zero that reads like a valid answer. */
static int http_socket_local_port(zend_socket_t fd, int fallback)
{
struct sockaddr_storage addr;
socklen_t len = sizeof(addr);

if (getsockname(fd, (struct sockaddr *) &addr, &len) != 0) {
return fallback;
}

if (addr.ss_family == AF_INET) {
return ntohs(((struct sockaddr_in *) &addr)->sin_port);
}

if (addr.ss_family == AF_INET6) {
return ntohs(((struct sockaddr_in6 *) &addr)->sin6_port);
}

return fallback;
}

/* The port the shared set holds for one listener, or 0 when the set has no
* entry for it. A pool parent binds through the set and never builds a listen
* event of its own, so this is the only place its answer lives. */
static int http_listen_set_bound_port(http_listen_set_t *set, size_t listener_index)
{
if (set == NULL) {
return 0;
}

int port = 0;

tsrm_mutex_lock(set->lock);

for (size_t i = 0; i < set->tcp_count; i++) {
if (set->tcp[i].listener_index == listener_index) {
port = set->tcp[i].bound_port;
break;
}
}

tsrm_mutex_unlock(set->lock);

return port;
}

static zend_socket_t http_listen_set_acquire_tcp(http_listen_set_t *set,
const char *host, int port,
size_t listener_index,
int backlog,
const char **error_out)
{
Expand All @@ -2574,7 +2627,11 @@ static zend_socket_t http_listen_set_acquire_tcp(http_listen_set_t *set,
bool found = false;

for (size_t i = 0; i < set->tcp_count; i++) {
if (set->tcp[i].port == port && strcmp(set->tcp[i].host, host) == 0) {
const bool same_listener = set->tcp[i].listener_index == listener_index;
const bool same_address = port != 0 && set->tcp[i].requested_port == port
&& strcmp(set->tcp[i].host, host) == 0;

if (same_listener || same_address) {
master = set->tcp[i].fd;
found = true;
break;
Expand Down Expand Up @@ -2639,9 +2696,11 @@ static zend_socket_t http_listen_set_acquire_tcp(http_listen_set_t *set,
if (host_len >= sizeof(slot->host)) host_len = sizeof(slot->host) - 1;
memcpy(slot->host, host, host_len);
slot->host[host_len] = '\0';
slot->port = port;
slot->fd = fd;
master = fd;
slot->requested_port = port;
slot->bound_port = http_socket_local_port(fd, port);
slot->listener_index = listener_index;
slot->fd = fd;
master = fd;
}

const zend_socket_t copy = http_socket_dup(master);
Expand Down Expand Up @@ -2682,7 +2741,7 @@ static int http_server_prebind_tcp(http_server_object *server)

const char *error = NULL;
const zend_socket_t copy = http_listen_set_acquire_tcp(
server->listen_set, ZSTR_VAL(lc->host), lc->port, cfg->backlog, &error);
server->listen_set, ZSTR_VAL(lc->host), lc->port, i, cfg->backlog, &error);

if (!http_socket_valid(copy)) {
http_listen_set_clear_tcp(server->listen_set);
Expand Down Expand Up @@ -4248,9 +4307,10 @@ ZEND_METHOD(TrueAsync_HttpServer, start)

/* Create listen sockets for each listener */
zval *listener;
zend_ulong listener_index;
server->listener_count = 0;

ZEND_HASH_FOREACH_VAL(Z_ARRVAL(listeners_zval), listener) {
ZEND_HASH_FOREACH_NUM_KEY_VAL(Z_ARRVAL(listeners_zval), listener_index, listener) {
if (server->listener_count >= MAX_LISTENERS) {
break;
}
Expand Down Expand Up @@ -4291,12 +4351,15 @@ ZEND_METHOD(TrueAsync_HttpServer, start)
* adopts a duplicate of that one socket. The duplicate is not
* cosmetic: the reactor closes what it adopts, so a descriptor
* handed out verbatim would die with the first thread to stop. */
if (http_server_use_reuseport()) {
if (http_server_use_reuseport() && port != 0) {
listen_flags |= ZEND_ASYNC_LISTEN_F_REUSEPORT;
} else if (server->listen_set != NULL) {
/* Port 0 always comes here, REUSEPORT or not: the address is
* bound once and every thread adopts a duplicate, so the whole
* server answers on the one port the kernel gave. */
const char *error = NULL;
const zend_socket_t adopted = http_listen_set_acquire_tcp(
server->listen_set, host, port, server->backlog, &error);
server->listen_set, host, port, listener_index, server->backlog, &error);

if (!http_socket_valid(adopted)) {
zend_throw_exception_ex(http_server_runtime_exception_ce, 0,
Expand Down Expand Up @@ -5815,6 +5878,91 @@ ZEND_METHOD(TrueAsync_HttpServer, getConfig)
}
/* }}} */

/* {{{ proto HttpServer::getBoundListeners(): array
*
* The addresses the server holds, one entry per configured listener and in
* configuration order. A TCP entry carries what the socket is bound to, which
* is the only place the answer exists when the listener asked for port 0. */
ZEND_METHOD(TrueAsync_HttpServer, getBoundListeners)
{
ZEND_PARSE_PARAMETERS_NONE();

const http_server_object *const server = Z_HTTP_SERVER_P(ZEND_THIS);

array_init(return_value);

if (!server->running) {
return;
}

const http_server_config_t *const cfg = http_server_get_config((http_server_object *)server);

if (UNEXPECTED(cfg == NULL)) {
return;
}

/* server->listeners holds the TCP and AF_UNIX listen events in config
* order; the HTTP/3 ones live in http3_listeners and are skipped here. */
size_t socket_index = 0;

for (size_t i = 0; i < cfg->listener_count; i++) {
const http_listener_config_t *const lc = &cfg->listeners[i];
zval entry;

array_init(&entry);

if (lc->type == LISTENER_TYPE_UNIX) {
add_assoc_string(&entry, "type", "unix");
add_assoc_str(&entry, "path", zend_string_copy(lc->host));
socket_index++;
} else if (lc->type == LISTENER_TYPE_UDP_H3) {
/* A UDP bind reports no local address through the async API, so an
* HTTP/3 listener answers with the port it was given —
* addHttp3Listener refuses 0 for that reason. */
add_assoc_string(&entry, "type", "udp_h3");
add_assoc_str(&entry, "host", zend_string_copy(lc->host));
add_assoc_long(&entry, "port", lc->port);
add_assoc_bool(&entry, "tls", lc->tls);
} else {
char host[64];
int port = lc->port;
bool from_socket = false;

if (socket_index < server->listener_count) {
zend_async_listen_event_t *const listen_event =
server->listeners[socket_index].listen_event;

if (listen_event != NULL && listen_event->get_local_address != NULL
&& listen_event->get_local_address(listen_event, host, sizeof(host), &port)
== SUCCESS) {
from_socket = true;
}
}

if (!from_socket) {
/* A pool parent holds no listen event of its own: its threads
* do, and what they adopted was bound through the shared set. */
const int shared_port = http_listen_set_bound_port(server->listen_set, i);

if (shared_port != 0) {
port = shared_port;
}
}

add_assoc_string(&entry, "type", "tcp");
add_assoc_str(&entry, "host",
from_socket ? zend_string_init(host, strlen(host), 0)
: zend_string_copy(lc->host));
add_assoc_long(&entry, "port", port);
add_assoc_bool(&entry, "tls", lc->tls);
socket_index++;
}

add_next_index_zval(return_value, &entry);
}
}
/* }}} */

#ifdef HAVE_HTTP_SERVER_HTTP3
/* Append one listener's stats snapshot to the result array. Factored out so
* both the single-thread listeners (server->http3_listeners) and the reactor-
Expand Down
31 changes: 19 additions & 12 deletions src/http_server_config.c
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,21 @@ static void config_add_listener(http_server_config_t *config, http_listener_type
listener->protocol_mask = protocol_mask;
}

/* A TCP port the caller may name, or 0 to let the kernel pick one at bind
* time. Zero closes the window between choosing a port and owning it, which
* is why the server reports the assigned port through
* HttpServer::getBoundListeners() rather than the caller guessing it. */
static bool config_check_tcp_port(zend_long port)
{
if (port < 0 || port > 65535) {
zend_throw_exception(http_server_invalid_argument_exception_ce,
"Port must be 0 (assigned by the kernel) or between 1 and 65535", 0);
return false;
}

return true;
}

/* {{{ proto HttpServerConfig::__construct(?string $host = null, int $port = 8080) */
ZEND_METHOD(TrueAsync_HttpServerConfig, __construct)
{
Expand Down Expand Up @@ -476,9 +491,7 @@ ZEND_METHOD(TrueAsync_HttpServerConfig, __construct)

/* Add default listener if host provided */
if (host) {
if (port < 1 || port > 65535) {
zend_throw_exception(http_server_invalid_argument_exception_ce,
"Port must be between 1 and 65535", 0);
if (!config_check_tcp_port(port)) {
return;
}

Expand Down Expand Up @@ -512,9 +525,7 @@ ZEND_METHOD(TrueAsync_HttpServerConfig, addListener)
return;
}

if (port < 1 || port > 65535) {
zend_throw_exception(http_server_invalid_argument_exception_ce,
"Port must be between 1 and 65535", 0);
if (!config_check_tcp_port(port)) {
return;
}

Expand Down Expand Up @@ -549,9 +560,7 @@ ZEND_METHOD(TrueAsync_HttpServerConfig, addHttp1Listener)
return;
}

if (port < 1 || port > 65535) {
zend_throw_exception(http_server_invalid_argument_exception_ce,
"Port must be between 1 and 65535", 0);
if (!config_check_tcp_port(port)) {
return;
}

Expand Down Expand Up @@ -588,9 +597,7 @@ ZEND_METHOD(TrueAsync_HttpServerConfig, addHttp2Listener)
return;
}

if (port < 1 || port > 65535) {
zend_throw_exception(http_server_invalid_argument_exception_ce,
"Port must be between 1 and 65535", 0);
if (!config_check_tcp_port(port)) {
return;
}

Expand Down
17 changes: 17 additions & 0 deletions stubs/HttpServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,23 @@ public function resetTelemetry(): bool {}
*/
public function getConfig(): HttpServerConfig {}

/**
* Addresses this server is bound to, one entry per configured listener,
* in configuration order.
*
* A listener configured with port 0 is bound to a port the kernel picks,
* and the entry carries that port: there is no gap between choosing an
* address and owning it, which a caller picking a free port beforehand
* cannot avoid. HTTP/3 listeners still require an explicit port and
* report the configured one.
*
* Empty while the server is not running: before start(), after stop().
*
* @return array<int, array{type: 'tcp'|'udp_h3', host: string, port: int, tls: bool}
* |array{type: 'unix', path: string}>
*/
public function getBoundListeners(): array {}

/**
* Get per-listener HTTP/3 observability counters.
*
Expand Down
6 changes: 5 additions & 1 deletion stubs/HttpServer.php_arginfo.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* This is a generated file, edit HttpServer.php.stub.php instead.
* Stub hash: 7b8c5ac0e86ceec057e2e915302f64cd8427790d */
* Stub hash: e2cb9e03600e706ce7a791b36fe0d28bc080a212 */

ZEND_BEGIN_ARG_INFO_EX(arginfo_class_TrueAsync_HttpServer___construct, 0, 0, 1)
ZEND_ARG_OBJ_INFO(0, config, TrueAsync\\HttpServerConfig, 0)
Expand Down Expand Up @@ -70,6 +70,8 @@ ZEND_END_ARG_INFO()
ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_TrueAsync_HttpServer_getConfig, 0, 0, TrueAsync\\HttpServerConfig, 0)
ZEND_END_ARG_INFO()

#define arginfo_class_TrueAsync_HttpServer_getBoundListeners arginfo_class_TrueAsync_HttpServer_getTelemetry

#define arginfo_class_TrueAsync_HttpServer_getHttp3Stats arginfo_class_TrueAsync_HttpServer_getTelemetry

#define arginfo_class_TrueAsync_HttpServer_getRuntimeStats arginfo_class_TrueAsync_HttpServer_getTelemetry
Expand Down Expand Up @@ -97,6 +99,7 @@ ZEND_METHOD(TrueAsync_HttpServer, isRunning);
ZEND_METHOD(TrueAsync_HttpServer, getTelemetry);
ZEND_METHOD(TrueAsync_HttpServer, resetTelemetry);
ZEND_METHOD(TrueAsync_HttpServer, getConfig);
ZEND_METHOD(TrueAsync_HttpServer, getBoundListeners);
ZEND_METHOD(TrueAsync_HttpServer, getHttp3Stats);
ZEND_METHOD(TrueAsync_HttpServer, getRuntimeStats);
ZEND_METHOD(TrueAsync_HttpServer, getStats);
Expand All @@ -123,6 +126,7 @@ static const zend_function_entry class_TrueAsync_HttpServer_methods[] = {
ZEND_ME(TrueAsync_HttpServer, getTelemetry, arginfo_class_TrueAsync_HttpServer_getTelemetry, ZEND_ACC_PUBLIC)
ZEND_ME(TrueAsync_HttpServer, resetTelemetry, arginfo_class_TrueAsync_HttpServer_resetTelemetry, ZEND_ACC_PUBLIC)
ZEND_ME(TrueAsync_HttpServer, getConfig, arginfo_class_TrueAsync_HttpServer_getConfig, ZEND_ACC_PUBLIC)
ZEND_ME(TrueAsync_HttpServer, getBoundListeners, arginfo_class_TrueAsync_HttpServer_getBoundListeners, ZEND_ACC_PUBLIC)
ZEND_ME(TrueAsync_HttpServer, getHttp3Stats, arginfo_class_TrueAsync_HttpServer_getHttp3Stats, ZEND_ACC_PUBLIC)
ZEND_ME(TrueAsync_HttpServer, getRuntimeStats, arginfo_class_TrueAsync_HttpServer_getRuntimeStats, ZEND_ACC_PUBLIC)
ZEND_ME(TrueAsync_HttpServer, getStats, arginfo_class_TrueAsync_HttpServer_getStats, ZEND_ACC_PUBLIC)
Expand Down
Loading
Loading