diff --git a/ci/nightly/pipeline.template.yml b/ci/nightly/pipeline.template.yml index e36a8ba54806f..5b84ece492ad7 100644 --- a/ci/nightly/pipeline.template.yml +++ b/ci/nightly/pipeline.template.yml @@ -555,6 +555,17 @@ steps: # See: queue: hetzner-x86-64-8cpu-16gb + - id: http-auth + label: HTTP auth + depends_on: build-aarch64 + timeout_in_minutes: 30 + inputs: [test/http-auth] + plugins: + - ./ci/plugins/mzcompose: + composition: http-auth + agents: + queue: hetzner-aarch64-4cpu-8gb + - group: Zippy key: zippy diff --git a/src/environmentd/src/http.rs b/src/environmentd/src/http.rs index 59c07e052750e..af2fe6e0d3f28 100644 --- a/src/environmentd/src/http.rs +++ b/src/environmentd/src/http.rs @@ -547,7 +547,8 @@ impl HttpServer { let login_router = Router::new() .route("/api/login", routing::post(handle_login)) .route("/api/logout", routing::post(handle_logout)) - .layer(Extension(adapter_client_rx)); + .layer(Extension(adapter_client_rx)) + .layer(Extension(allowed_roles)); router = router.merge(login_router).layer(session_layer); } listeners::AuthenticatorKind::None => { @@ -881,8 +882,20 @@ impl IntoResponse for AuthError { pub async fn handle_login( session: Option>, Extension(adapter_client_rx): Extension>, + Extension(allowed_roles): Extension, Json(LoginCredentials { username, password }): Json, ) -> impl IntoResponse { + // Enforce the listener's allowed_roles policy before doing any + // authentication work, mirroring the check performed in `auth` for + // header-based credentials. Without this, a session-based caller could + // log in as a role that header-based callers are forbidden to use. + if let Err(err) = check_role_allowed(&username, allowed_roles) { + warn!( + ?err, + "HTTP login rejected: role not allowed on this listener" + ); + return StatusCode::UNAUTHORIZED; + } let Ok(adapter_client) = adapter_client_rx.clone().await else { return StatusCode::INTERNAL_SERVER_ERROR; }; @@ -956,6 +969,12 @@ async fn http_auth( maybe_get_authenticated_session(req.extensions().get::()).await { let user = ensure_session_unexpired(session, session_data).await?; + // Defense-in-depth: re-check the listener's `allowed_roles` policy on + // every session-authenticated request. The same check runs at + // `/api/login`, but enforcing it here too prevents a session minted + // under a more permissive configuration (or a future bug in the login + // path) from bypassing role restrictions. + check_role_allowed(&user.name, allowed_roles)?; // Need this to set the user of the Adapter client. req.extensions_mut().insert(user); return Ok(next.run(req).await); @@ -1075,8 +1094,16 @@ async fn init_ws( warn!("Unexpected bearer or basic auth provided when using user header"); anyhow::bail!("unexpected") } - (Some(ExistingUser::Session(user)), None) - | (Some(ExistingUser::XMaterializeUserHeader(user)), None) => user, + (Some(ExistingUser::Session(user)), None) => { + // Defense-in-depth: re-enforce the listener's `allowed_roles` + // policy on session-authenticated WebSocket connections. The same + // check runs at `/api/login`, but enforcing it here too prevents a + // session minted under a more permissive configuration (or a + // future bug in the login path) from bypassing role restrictions. + check_role_allowed(&user.name, allowed_roles)?; + user + } + (Some(ExistingUser::XMaterializeUserHeader(user)), None) => user, (_, Some(creds)) => { let authenticator = get_authenticator( authenticator_kind, diff --git a/test/http-auth/listener_config_normal_only.json b/test/http-auth/listener_config_normal_only.json new file mode 100644 index 0000000000000..602c1c702e63e --- /dev/null +++ b/test/http-auth/listener_config_normal_only.json @@ -0,0 +1,50 @@ +{ + "sql": { + "external": { + "addr": "0.0.0.0:6875", + "authenticator_kind": "Password", + "allowed_roles": "NormalAndInternal", + "enable_tls": false + }, + "internal": { + "addr": "0.0.0.0:6877", + "authenticator_kind": "None", + "allowed_roles": "Internal", + "enable_tls": false + } + }, + "http": { + "external": { + "addr": "0.0.0.0:6876", + "authenticator_kind": "Password", + "allowed_roles": "Normal", + "enable_tls": false, + "routes": { + "base": true, + "webhook": false, + "internal": false, + "metrics": false, + "profiling": false, + "mcp_agent": false, + "mcp_developer": false, + "console_config": false + } + }, + "metrics": { + "addr": "0.0.0.0:6878", + "authenticator_kind": "None", + "allowed_roles": "NormalAndInternal", + "enable_tls": false, + "routes": { + "base": false, + "webhook": false, + "internal": false, + "metrics": true, + "profiling": false, + "mcp_agent": false, + "mcp_developer": false, + "console_config": false + } + } + } +} diff --git a/test/http-auth/mzcompose b/test/http-auth/mzcompose new file mode 100755 index 0000000000000..1f866645dabc8 --- /dev/null +++ b/test/http-auth/mzcompose @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +# Copyright Materialize, Inc. and contributors. All rights reserved. +# +# Use of this software is governed by the Business Source License +# included in the LICENSE file at the root of this repository. +# +# As of the Change Date specified in that file, in accordance with +# the Business Source License, use of this software will be governed +# by the Apache License, Version 2.0. +# +# mzcompose — runs Docker Compose with Materialize customizations. + +exec "$(dirname "$0")"/../../bin/pyactivate -m materialize.cli.mzcompose "$@" diff --git a/test/http-auth/mzcompose.py b/test/http-auth/mzcompose.py new file mode 100644 index 0000000000000..9be68f0ef0567 --- /dev/null +++ b/test/http-auth/mzcompose.py @@ -0,0 +1,45 @@ +# Copyright Materialize, Inc. and contributors. All rights reserved. +# +# Use of this software is governed by the Business Source License +# included in the LICENSE file at the root of this repository. +# +# As of the Change Date specified in that file, in accordance with +# the Business Source License, use of this software will be governed +# by the Apache License, Version 2.0. + +import requests + +from materialize import MZ_ROOT +from materialize.mzcompose.composition import Composition +from materialize.mzcompose.services.materialized import Materialized + +SERVICES = [ + Materialized(), +] + + +def workflow_default(c: Composition) -> None: + with c.override( + Materialized( + listeners_config_path=f"{MZ_ROOT}/test/http-auth/listener_config_normal_only.json" + ) + ): + c.up("materialized") + base = f"http://localhost:{c.port('materialized', 6876)}" + + # Regression test for database-issues#11340. With `allowed_roles: + # Normal`, header-based Basic auth correctly rejects `mz_system`, but + # `/api/login` previously did not run the same role check — letting an + # internal role obtain a session cookie and bypass the policy on + # subsequent requests. Make sure `/api/login` enforces the listener's + # role policy directly and never mints a session for a disallowed role. + with c.test_case("session_login_rejects_disallowed_role"): + s = requests.Session() + r = s.post( + f"{base}/api/login", + json={"username": "mz_system", "password": "password"}, + ) + assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}" + assert ( + "mz_session" not in s.cookies + ), f"login rejection must not set a session cookie: {s.cookies}"