Răsfoiți Sursa

Merge remote-tracking branch 'origin/main'

achiappa 8 ore în urmă
părinte
comite
7e290d1484

+ 16 - 3
.env.example

@@ -5,14 +5,27 @@
 # Generate 32-byte hex secrets with: openssl rand -hex 32
 # =============================================================================
 
+# -----------------------------------------------------------------------------
+# Docker host ports (consumed by docker-compose.yml only)
+# -----------------------------------------------------------------------------
+# Host-side ports for the published service mappings. Container-internal
+# ports are fixed at 8081 (api) and 8080 (ui) — only the host side here is
+# remappable, so port collisions on the host can be resolved without
+# rebuilding the images. If you change UI_PORT, also update PUBLIC_URL
+# below so the browser-facing URL matches.
+API_PORT=8081
+UI_PORT=8080
+
 # -----------------------------------------------------------------------------
 # Shared (consumed by both api and ui containers)
 # -----------------------------------------------------------------------------
 # IRDB-format service token. The api uses this to authenticate the ui's
 # calls; the ui presents it on every API request together with
-# X-Acting-User-Id. Format: irdb_svc_<32 base32 chars>. Generate one with:
-#   docker compose run --rm -T api php -r 'require "/app/vendor/autoload.php";
-#       echo (new App\Domain\Auth\TokenIssuer())->issue(App\Domain\Auth\TokenKind::Service);'
+# X-Acting-User-Id. Format: irdb_svc_<32 base32 chars>. Generate one with
+# (note `--entrypoint php` — the api image's default entrypoint is a
+# dispatcher with `api` / `migrate` modes, so ad-hoc PHP bypasses it):
+#   docker compose run --rm -T --entrypoint php api -r 'require "/app/vendor/autoload.php";
+#       echo (new App\Domain\Auth\TokenIssuer())->issue(App\Domain\Auth\TokenKind::Service), PHP_EOL;'
 UI_SERVICE_TOKEN=
 
 # -----------------------------------------------------------------------------

+ 14 - 0
CHANGELOG.md

@@ -18,6 +18,20 @@ collide with the per-component `api-v…` / `ui-v…` tags in this monorepo.
 
 ## [Unreleased]
 
+### Changed
+- Docker host ports are now configurable via `API_PORT` / `UI_PORT` in
+  `.env` (defaults `8081` / `8080`). Container-internal ports remain
+  fixed, so internal docker DNS, healthchecks, and baked Caddyfiles are
+  unchanged — only the published host-side mapping moves.
+
+### Fixed
+- The "generate `UI_SERVICE_TOKEN`" one-liner in `README.md` and
+  `.env.example` was missing `--entrypoint php`, so it hit the api
+  image's `api`/`migrate` dispatcher and exited with `Unknown mode: php`.
+  Snippets are corrected, and both image entrypoints now point operators
+  at `--entrypoint <bin>` when they hit an unknown mode, so the next
+  copy-paste mistake is self-explanatory.
+
 ## [0.9] — 2026-05-06
 
 First public preview of the bundled stack. Everything specified in

+ 7 - 3
README.md

@@ -29,7 +29,9 @@ docker compose -f docker-compose.yml -f compose.scheduler.yml up -d
 
 That's it. The UI is at `http://localhost:8080`, the api at
 `http://localhost:8081`, and the API reference viewer at
-`http://localhost:8081/api/docs`.
+`http://localhost:8081/api/docs`. The host ports are configurable via
+`API_PORT` / `UI_PORT` in `.env` if those collide on your host;
+container-internal ports stay fixed.
 
 Log in with the local admin credentials you set in `.env`
 (`LOCAL_ADMIN_USERNAME` / `LOCAL_ADMIN_PASSWORD_HASH`). OIDC works too —
@@ -47,8 +49,10 @@ secret you need to generate. Use these one-liners:
 openssl rand -hex 32
 
 # IRDB-format service token (UI_SERVICE_TOKEN — looks like irdb_svc_…)
-docker compose run --rm -T api php -r 'require "/app/vendor/autoload.php";
-    echo (new App\Domain\Auth\TokenIssuer())->issue(App\Domain\Auth\TokenKind::Service);'
+# Note the `--entrypoint php` — the api image's default entrypoint is a
+# dispatcher (`api` / `migrate` modes), so ad-hoc PHP commands bypass it.
+docker compose run --rm -T --entrypoint php api -r 'require "/app/vendor/autoload.php";
+    echo (new App\Domain\Auth\TokenIssuer())->issue(App\Domain\Auth\TokenKind::Service), PHP_EOL;'
 
 # Local admin password hash (LOCAL_ADMIN_PASSWORD_HASH — Argon2id)
 php -r "echo password_hash('your-admin-password', PASSWORD_ARGON2ID);"

+ 4 - 0
api/docker/entrypoint.sh

@@ -26,6 +26,10 @@ case "$mode" in
     *)
         echo "Unknown mode: $mode" >&2
         echo "Usage: entrypoint.sh [api|migrate]" >&2
+        echo "" >&2
+        echo "To run an ad-hoc PHP command (e.g. 'php', 'composer', 'vendor/bin/phpunit')," >&2
+        echo "bypass this dispatcher with '--entrypoint <bin>'. For example:" >&2
+        echo "  docker compose run --rm --entrypoint php api bin/console auth:create-token --kind=admin --role=admin" >&2
         exit 1
         ;;
 esac

+ 1 - 0
appctl

@@ -0,0 +1 @@
+bin/appctl

+ 268 - 0
bin/appctl

@@ -0,0 +1,268 @@
+#!/usr/bin/env bash
+# appctl — convenience wrapper for the irdb docker compose stack and CI checks.
+#
+# The canonical stack is api + ui + migrate (docker-compose.yml). The
+# scheduler sidecar (compose.scheduler.yml) is opt-in via --scheduler.
+#
+# Per-subproject scope is accepted as an optional positional arg on the
+# check commands (lint/stan/test/audit/check). With no arg, the command
+# runs against both api and ui — the same coverage as scripts/ci.sh.
+#
+# Lint, stan, test, audit and check all run in ephemeral composer/node
+# containers (mirroring scripts/ci.sh) so the host needs no PHP or Node
+# toolchain installed.
+
+set -euo pipefail
+
+# Resolve repo root regardless of where the user invokes us from. Works
+# whether called as bin/appctl or via the top-level ./appctl symlink.
+SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
+SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
+if [[ "$(basename "$SCRIPT_DIR")" == "bin" ]]; then
+    REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+else
+    REPO_ROOT="$SCRIPT_DIR"
+fi
+cd "$REPO_ROOT"
+
+PHP_IMAGE="composer:2"
+NODE_IMAGE="node:20-alpine"
+UID_GID="$(id -u):$(id -g)"
+
+usage() {
+    cat <<'EOF'
+appctl — irdb docker compose / CI wrapper
+
+USAGE
+  appctl <command> [args]
+
+STACK (docker compose; canonical stack is api + ui + migrate)
+  appctl start  [--scheduler] [-d]   bring the stack up (foreground; -d to detach)
+  appctl stop   [--scheduler]        stop and remove containers
+  appctl build  [api|ui|scheduler]   build images (no arg = api + ui)
+  appctl shell  [api|ui]             bash into a running container (default: api)
+  appctl logs   [service]            follow logs (no arg = all services)
+  appctl ps                          list stack status (incl. scheduler if running)
+  appctl migrate                     run the one-shot migrate container
+
+CHECKS (ephemeral containers, no running stack required)
+  appctl lint   [api|ui]             composer cs (php-cs-fixer --dry-run)
+  appctl stan   [api|ui]             composer stan (phpstan)
+  appctl test   [api|ui]             composer test (phpunit)
+  appctl audit  [api|ui]             composer audit --no-dev
+  appctl check  [api|ui]             lint + stan + audit + test for the target(s)
+  appctl ci                          full CI pipeline (delegates to scripts/ci.sh)
+
+OTHER
+  appctl help                        show this message
+EOF
+}
+
+die() {
+    printf 'appctl: %s\n' "$*" >&2
+    exit 2
+}
+
+# --- compose helpers --------------------------------------------------
+
+# Canonical stack only (api + ui + migrate).
+compose_main() {
+    docker compose -f docker-compose.yml "$@"
+}
+
+# Canonical stack plus the scheduler sidecar overlay. Used for start
+# --scheduler, build scheduler, and read-only commands (logs / ps) that
+# should still see the sidecar when it's running.
+compose_full() {
+    docker compose -f docker-compose.yml -f compose.scheduler.yml "$@"
+}
+
+# Parse `--scheduler` and `-d|--detach` flags out of the args.
+# Sets globals: SCHED, DETACH, REST (remaining positional args).
+parse_stack_flags() {
+    SCHED=0
+    DETACH=0
+    REST=()
+    while (($#)); do
+        case "$1" in
+            --scheduler) SCHED=1 ;;
+            -d|--detach) DETACH=1 ;;
+            --) shift; REST+=("$@"); break ;;
+            *) REST+=("$1") ;;
+        esac
+        shift
+    done
+}
+
+# --- subproject helpers (echo space-separated for `for` loops) --------
+
+resolve_targets() {
+    local arg="${1:-}"
+    case "$arg" in
+        "")        echo "api ui" ;;
+        api|ui)    echo "$arg" ;;
+        *)         die "unknown subproject: $arg (try: api|ui)" ;;
+    esac
+}
+
+# Run a command inside a one-off composer container, mounted at /app.
+# Mirrors scripts/ci.sh::run_php so behavior stays consistent.
+run_php() {
+    local dir="$1"; shift
+    mkdir -p "$HOME/.composer-cache"
+    docker run --rm \
+        -u "$UID_GID" \
+        -v "$REPO_ROOT/$dir":/app \
+        -v "$HOME/.composer-cache":/tmp \
+        -w /app \
+        -e COMPOSER_HOME=/tmp/composer \
+        -e COMPOSER_CACHE_DIR=/tmp/composer-cache \
+        -e XDG_CONFIG_HOME=/tmp \
+        "$PHP_IMAGE" "$@"
+}
+
+# Bootstrap vendor/ on demand so tests/lint don't fail with a confusing
+# "vendor/bin/phpunit not found" the first time.
+ensure_vendor() {
+    local dir="$1"
+    [[ -d "$REPO_ROOT/$dir/vendor" ]] && return 0
+    printf '[appctl] %s/vendor missing — running composer install\n' "$dir"
+    run_php "$dir" composer install --no-interaction --prefer-dist
+}
+
+# --- subcommands ------------------------------------------------------
+
+cmd_start() {
+    parse_stack_flags "$@"
+    local up_args=(up)
+    [[ "$DETACH" == "1" ]] && up_args+=(-d)
+    if [[ "$SCHED" == "1" ]]; then
+        compose_full "${up_args[@]}" "${REST[@]}"
+    else
+        compose_main "${up_args[@]}" "${REST[@]}"
+    fi
+}
+
+cmd_stop() {
+    parse_stack_flags "$@"
+    # `down` operates on the project name and tears down everything
+    # currently running, regardless of which overlays were used to
+    # start it — but layering the scheduler overlay keeps things tidy
+    # when --scheduler was passed.
+    if [[ "$SCHED" == "1" ]]; then
+        compose_full down "${REST[@]}"
+    else
+        compose_main down "${REST[@]}"
+    fi
+}
+
+cmd_build() {
+    local target="${1:-}"
+    case "$target" in
+        "")              compose_main build ;;
+        api|ui|migrate)  compose_main build "$target" ;;
+        scheduler)       compose_full build scheduler ;;
+        *) die "unknown build target: $target (try: api|ui|scheduler|migrate)" ;;
+    esac
+}
+
+cmd_shell() {
+    local service="${1:-api}"
+    case "$service" in
+        api|ui) ;;
+        *) die "unknown shell target: $service (try: api|ui)" ;;
+    esac
+    # Both images are alpine-based today; fall back to sh if bash isn't
+    # present so this keeps working if the base image changes.
+    compose_main exec "$service" \
+        sh -c 'command -v bash >/dev/null && exec bash || exec sh'
+}
+
+cmd_logs() {
+    local service="${1:-}"
+    if [[ -z "$service" ]]; then
+        compose_full logs -f
+    else
+        compose_full logs -f "$service"
+    fi
+}
+
+cmd_ps() {
+    compose_full ps
+}
+
+cmd_migrate() {
+    compose_main run --rm migrate
+}
+
+cmd_lint() {
+    local target
+    for target in $(resolve_targets "${1:-}"); do
+        ensure_vendor "$target"
+        printf '\n[appctl] %s: composer cs\n' "$target"
+        run_php "$target" composer cs
+    done
+}
+
+cmd_stan() {
+    local target
+    for target in $(resolve_targets "${1:-}"); do
+        ensure_vendor "$target"
+        printf '\n[appctl] %s: composer stan\n' "$target"
+        run_php "$target" composer stan
+    done
+}
+
+cmd_test() {
+    local target
+    for target in $(resolve_targets "${1:-}"); do
+        ensure_vendor "$target"
+        printf '\n[appctl] %s: composer test\n' "$target"
+        run_php "$target" composer test
+    done
+}
+
+cmd_audit() {
+    local target
+    for target in $(resolve_targets "${1:-}"); do
+        ensure_vendor "$target"
+        printf '\n[appctl] %s: composer audit\n' "$target"
+        run_php "$target" composer audit --no-dev
+    done
+}
+
+cmd_check() {
+    local target="${1:-}"
+    cmd_lint  "$target"
+    cmd_stan  "$target"
+    cmd_audit "$target"
+    cmd_test  "$target"
+}
+
+cmd_ci() {
+    [[ -x scripts/ci.sh ]] || die "scripts/ci.sh missing or not executable"
+    exec scripts/ci.sh
+}
+
+# --- dispatch ---------------------------------------------------------
+
+cmd="${1:-help}"
+shift || true
+
+case "$cmd" in
+    start)              cmd_start "$@" ;;
+    stop)               cmd_stop "$@" ;;
+    build)              cmd_build "$@" ;;
+    shell)              cmd_shell "$@" ;;
+    logs)               cmd_logs "$@" ;;
+    ps)                 cmd_ps ;;
+    migrate)            cmd_migrate ;;
+    lint)               cmd_lint "$@" ;;
+    stan)               cmd_stan "$@" ;;
+    test)               cmd_test "$@" ;;
+    audit)              cmd_audit "$@" ;;
+    check)              cmd_check "$@" ;;
+    ci)                 cmd_ci ;;
+    help|-h|--help|"")  usage ;;
+    *) die "unknown command: $cmd (try: appctl help)" ;;
+esac

+ 11 - 2
doc/development/SPEC.md

@@ -514,6 +514,15 @@ The UI also conditionally renders elements based on the cached role in the sessi
 
 Single `.env` file at the repo root, consumed by docker-compose. Each container reads only the variables it needs.
 
+### Docker host ports (compose only)
+```dotenv
+# Host-side ports for the published service mappings. Container-internal
+# ports stay fixed at 8081 (api) / 8080 (ui); only the host side is
+# remappable. If UI_PORT changes, also update PUBLIC_URL.
+API_PORT=8081
+UI_PORT=8080
+```
+
 ### Shared (both containers)
 ```dotenv
 # A 32-byte hex string. Used by api to authenticate the ui's calls.
@@ -644,7 +653,7 @@ services:
     command: api
     env_file: .env
     ports:
-      - "8081:8081"
+      - "${API_PORT:-8081}:8081"
     volumes:
       - irdb-data:/data
     depends_on:
@@ -662,7 +671,7 @@ services:
     build: { context: ./ui }
     env_file: .env
     ports:
-      - "8080:8080"
+      - "${UI_PORT:-8080}:8080"
     depends_on:
       api:
         condition: service_healthy

+ 2 - 2
docker-compose.yml

@@ -29,7 +29,7 @@ services:
       - /home/app/.config:uid=1000,gid=1000,mode=0700
       - /home/app/.local/share:uid=1000,gid=1000,mode=0700
     ports:
-      - "8081:8081"
+      - "${API_PORT:-8081}:8081"
     volumes:
       - irdb-data:/data
     depends_on:
@@ -55,7 +55,7 @@ services:
       - /home/app/.config:uid=1000,gid=1000,mode=0700
       - /home/app/.local/share:uid=1000,gid=1000,mode=0700
     ports:
-      - "8080:8080"
+      - "${UI_PORT:-8080}:8080"
     depends_on:
       api:
         condition: service_healthy

+ 4 - 0
ui/docker/entrypoint.sh

@@ -10,6 +10,10 @@ case "$mode" in
     *)
         echo "Unknown mode: $mode" >&2
         echo "Usage: entrypoint.sh [ui]" >&2
+        echo "" >&2
+        echo "To run an ad-hoc PHP command (e.g. 'php', 'composer', 'vendor/bin/phpunit')," >&2
+        echo "bypass this dispatcher with '--entrypoint <bin>'. For example:" >&2
+        echo "  docker compose run --rm --entrypoint php ui vendor/bin/phpunit" >&2
         exit 1
         ;;
 esac

+ 15 - 1
ui/src/App/Container.php

@@ -94,6 +94,7 @@ final class Container
                 ?? sys_get_temp_dir() . '/irdb_login_throttle.json'),
             'settings.geoip_provider' => strtolower((string) ($settings['geoip_provider'] ?? 'dbip')),
             'settings.ui_locale' => trim((string) ($settings['ui_locale'] ?? '')),
+            'settings.public_url' => trim((string) ($settings['public_url'] ?? '')),
 
             LoggerInterface::class => factory(static function (ContainerInterface $c): LoggerInterface {
                 $logger = new Logger('ui');
@@ -207,7 +208,20 @@ final class Container
 
             // Middlewares — autowire works directly.
             SessionMiddleware::class => autowire(),
-            CsrfMiddleware::class => autowire(),
+            CsrfMiddleware::class => factory(static function (ContainerInterface $c): CsrfMiddleware {
+                /** @var ResponseFactoryInterface $rf */
+                $rf = $c->get(ResponseFactoryInterface::class);
+                /** @var string $publicUrl */
+                $publicUrl = $c->get('settings.public_url');
+                // PUBLIC_URL is the operator-declared browser-facing
+                // origin. When the UI sits behind a TLS-terminating
+                // reverse proxy, the embedded Caddy reports HTTPS=off
+                // / SERVER_PORT=8080 to PHP, so the request URI's
+                // origin won't match the browser's `Origin: https://…`.
+                // Trusting PUBLIC_URL bridges that gap without
+                // honouring arbitrary X-Forwarded-* headers.
+                return new CsrfMiddleware($rf, $publicUrl !== '' ? [$publicUrl] : []);
+            }),
             CspMiddleware::class => autowire(),
             AuthRequiredMiddleware::class => factory(static function (ContainerInterface $c): AuthRequiredMiddleware {
                 /** @var SessionManager $sessions */

+ 51 - 12
ui/src/Http/CsrfMiddleware.php

@@ -44,8 +44,36 @@ final class CsrfMiddleware implements MiddlewareInterface
 
     private const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
 
-    public function __construct(private readonly ResponseFactoryInterface $responseFactory)
-    {
+    /**
+     * Origins (scheme://host[:port]) that count as "same origin" in
+     * addition to the request URI's own origin. Populated from
+     * `PUBLIC_URL`: when the UI sits behind a TLS-terminating reverse
+     * proxy, the embedded Caddy reports `HTTPS=off` / `SERVER_PORT=8080`
+     * to PHP, so the request URI is `http://host:8080` while the browser
+     * sends `Origin: https://host`. PUBLIC_URL is the operator's
+     * declaration of the canonical browser-facing origin and is the
+     * trustworthy way to bridge that gap.
+     *
+     * @var list<string>
+     */
+    private readonly array $trustedOrigins;
+
+    /**
+     * @param list<string> $trustedOrigins Extra accepted origins
+     *   (typically a parsed PUBLIC_URL). Empty disables this fallback.
+     */
+    public function __construct(
+        private readonly ResponseFactoryInterface $responseFactory,
+        array $trustedOrigins = [],
+    ) {
+        $normalized = [];
+        foreach ($trustedOrigins as $origin) {
+            $parsed = self::originFromUrl((string) $origin);
+            if ($parsed !== null) {
+                $normalized[] = $parsed;
+            }
+        }
+        $this->trustedOrigins = array_values(array_unique($normalized));
     }
 
     public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
@@ -56,7 +84,7 @@ final class CsrfMiddleware implements MiddlewareInterface
             // SEC_REVIEW F54: same-origin Origin/Referer check before
             // the token compare. Refuses a cross-origin POST even if
             // the token cookie were somehow exfiltrated.
-            if (!self::isSameOrigin($request)) {
+            if (!$this->isSameOrigin($request)) {
                 return $this->forbidden('cross-origin request refused');
             }
 
@@ -109,29 +137,40 @@ final class CsrfMiddleware implements MiddlewareInterface
     /**
      * SEC_REVIEW F54: same-origin gate. Returns true iff the request's
      * `Origin` (preferred) or `Referer` (fallback) matches the
-     * scheme+host+port the request itself was made to. Both headers
+     * scheme+host+port the request itself was made to OR any
+     * operator-configured trusted origin (PUBLIC_URL). Both headers
      * absent → returns true (legitimate older clients / direct curl).
      * The token check still runs in that case; this is purely an
      * additional layer.
      *
+     * The trusted-origin fallback handles TLS-terminating reverse
+     * proxies: PHP sees `http://host:8080` while the browser sends
+     * `Origin: https://host`. PUBLIC_URL declares the canonical
+     * browser-facing origin, so accepting it as same-origin is what
+     * the operator already promised this deployment serves.
+     *
      * Modern browsers always send `Origin` on POST/PUT/PATCH/DELETE,
      * so the absent-both branch is effectively the curl/test path.
      */
-    private static function isSameOrigin(ServerRequestInterface $request): bool
+    private function isSameOrigin(ServerRequestInterface $request): bool
     {
         $expected = self::originOf($request->getUri());
-        if ($expected === null) {
+        $accepted = $this->trustedOrigins;
+        if ($expected !== null) {
+            $accepted[] = $expected;
+        }
+        if ($accepted === []) {
             // Request URI has no host (CLI / test path / malformed
-            // entrypoint). Real browser traffic always carries a Host;
-            // fail open here so test setups don't have to thread a
-            // fake host through every fixture, and rely on the token
-            // check as the sole gate in this case.
+            // entrypoint) AND no trusted origin configured. Fail open
+            // so test setups don't have to thread a fake host through
+            // every fixture, and rely on the token check as the sole
+            // gate in this case.
             return true;
         }
 
         $origin = trim($request->getHeaderLine('Origin'));
         if ($origin !== '' && $origin !== 'null') {
-            return self::normalizeOrigin($origin) === $expected;
+            return in_array(self::normalizeOrigin($origin), $accepted, true);
         }
 
         $referer = trim($request->getHeaderLine('Referer'));
@@ -141,7 +180,7 @@ final class CsrfMiddleware implements MiddlewareInterface
                 return false;
             }
 
-            return $refOrigin === $expected;
+            return in_array($refOrigin, $accepted, true);
         }
 
         // Neither header present — defer to the token check. Modern

+ 50 - 0
ui/tests/Unit/Http/CsrfMiddlewareTest.php

@@ -190,6 +190,56 @@ final class CsrfMiddlewareTest extends TestCase
         self::assertSame(200, $response->getStatusCode());
     }
 
+    public function testTrustedPublicUrlOriginIsAcceptedBehindProxy(): void
+    {
+        // TLS-terminating reverse proxy in front of the UI: the
+        // browser sends `Origin: https://reputation.example.com` but
+        // FrankenPHP listens on plain :8080 so PHP's request URI is
+        // `http://reputation.example.com:8080`. The operator's
+        // PUBLIC_URL (passed to the constructor) is the canonical
+        // browser-facing origin and bridges the mismatch.
+        $mw = new CsrfMiddleware(new ResponseFactory(), ['https://reputation.example.com']);
+        $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
+        $request = (new ServerRequestFactory())
+            ->createServerRequest('POST', 'http://reputation.example.com:8080/login/local')
+            ->withHeader('Origin', 'https://reputation.example.com')
+            ->withParsedBody(['csrf_token' => 'fixed-token']);
+
+        $response = $mw->process($request, $this->handler(static fn () => true));
+
+        self::assertSame(200, $response->getStatusCode());
+    }
+
+    public function testTrustedOriginRefererIsAcceptedBehindProxy(): void
+    {
+        $mw = new CsrfMiddleware(new ResponseFactory(), ['https://reputation.example.com']);
+        $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
+        $request = (new ServerRequestFactory())
+            ->createServerRequest('POST', 'http://reputation.example.com:8080/login/local')
+            ->withHeader('Referer', 'https://reputation.example.com/login')
+            ->withParsedBody(['csrf_token' => 'fixed-token']);
+
+        $response = $mw->process($request, $this->handler(static fn () => true));
+
+        self::assertSame(200, $response->getStatusCode());
+    }
+
+    public function testTrustedOriginDoesNotWidenToOtherHosts(): void
+    {
+        // Configuring PUBLIC_URL must not turn the same-origin gate
+        // into "any host" — only the configured origin is added.
+        $mw = new CsrfMiddleware(new ResponseFactory(), ['https://reputation.example.com']);
+        $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
+        $request = (new ServerRequestFactory())
+            ->createServerRequest('POST', 'http://reputation.example.com:8080/login/local')
+            ->withHeader('Origin', 'https://evil.example.com')
+            ->withParsedBody(['csrf_token' => 'fixed-token']);
+
+        $response = $mw->process($request, $this->handler(static fn () => true));
+
+        self::assertSame(403, $response->getStatusCode());
+    }
+
     public function testJsonBodyWithWrongTokenIs403(): void
     {
         $mw = new CsrfMiddleware(new ResponseFactory());