From 6e4acd30353967324b1dc267ca41a5fd92efe5e1 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:59:29 +0000 Subject: [PATCH 01/74] fix(security): bind Ray dashboard to localhost in docker-compose The Ray dashboard (8265) was published on all host interfaces. It has no authentication and its job-submission API allows arbitrary code execution on the cluster, so exposing it to the network is a remote code execution vector. Bind the published port to 127.0.0.1 so it is reachable only from the host. Forward-ported from c1079d7f (main) into the infra/compose/ layout. --- infra/compose/docker-compose.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/infra/compose/docker-compose.yaml b/infra/compose/docker-compose.yaml index 1a31ebfb6..5d885a9a8 100644 --- a/infra/compose/docker-compose.yaml +++ b/infra/compose/docker-compose.yaml @@ -29,7 +29,10 @@ x-openrag: &openrag_template - ${LOG_VOLUME:-../../logs}:/app/logs # For dev mode ports: - ${APP_PORT:-8080}:${APP_iPORT:-8080} - - ${RAY_DASHBOARD_PORT:-8265}:8265 # Disable when in cluster mode + # Bind the Ray dashboard to localhost only: it has no authentication and + # its job-submission API allows arbitrary code execution on the cluster, + # so it must never be reachable from outside the host. + - 127.0.0.1:${RAY_DASHBOARD_PORT:-8265}:8265 # Disable when in cluster mode - ${CHAINLIT_PORT:-8090}:${CHAINLIT_PORT:-8090} networks: default: From 7003e061dd4914266291100e1663d3c85b1ff400 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:01:49 +0000 Subject: [PATCH 02/74] fix(security): run the Ray container as a non-root user (H4) The Ray worker image ran as root. Add a dedicated uid/gid 10001 user that owns /app (where the app writes the venv, data, logs and model weights) and switch to it with USER, so a container escape does not land as root on the host. HOME is set to /app before the uv install so uv's managed Python and cache land under the user-owned tree rather than /root. Forward-ported from 8914dfbb (main) into infra/docker/ray.Dockerfile. --- infra/docker/ray.Dockerfile | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/infra/docker/ray.Dockerfile b/infra/docker/ray.Dockerfile index 250a04c68..2029065cf 100644 --- a/infra/docker/ray.Dockerfile +++ b/infra/docker/ray.Dockerfile @@ -32,6 +32,10 @@ ENV HF_HUB_CACHE=${HF_HUB_CACHE:-/app/model_weights/hub} # Set workdir for uv WORKDIR /app +# Set HOME before installing so uv's Python and cache land under /app (owned by +# the non-root user below), not /root. +ENV HOME=/app + # Install uv & setup venv COPY pyproject.toml uv.lock ./ RUN pip3 install uv && \ @@ -54,3 +58,11 @@ COPY conf/ /app/conf/ RUN ln -s /app/.venv/bin/ray /usr/local/bin/ray ENV PYTHONPATH=/app/openrag/ + +# Run as non-root. The app writes under /app (venv, data, logs, model_weights), +# so the user owns /app. +RUN groupadd --gid 10001 app \ + && useradd --uid 10001 --gid 10001 --home-dir /app --no-create-home app \ + && mkdir -p /app/data /app/logs /app/model_weights \ + && chown -R 10001:10001 /app +USER 10001:10001 From b69a812ef07898d769b46446e9c8ba4344a89845 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:02:29 +0000 Subject: [PATCH 03/74] chore(docker): run the app image as a non-root, OpenShift-compatible user The API image ran as root. Add a fixed uid (APP_UID=10001, primary group 0) for plain Docker/Kubernetes and make every runtime-writable path group-owned by GID 0 and group-writable so the arbitrary UID OpenShift assigns can start the app: strip group-write from the whole tree, then grant it back only on the venv, egg-info, $HOME, data, db, logs, the HF model cache and uv's cache. Chainlit's ./.files and ./.chainlit dirs are pre-created group-writable so the app does not crash with PermissionError. UV_*/HOME/USER/LOGNAME env vars keep uv's Python and cache on root-owned paths and let getpass.getuser() resolve without an /etc/passwd entry. Forward-ported from 8914dfbb, 6860341c, 26a70dbc and c0847d8f (main) into infra/docker/api.Dockerfile. The later OpenShift/USER-LOGNAME/chainlit commits fully replaced 8914dfbb's initial app-image non-root approach, so they are consolidated into this single commit against the new layout (which copies scripts/ instead of prompts/). --- infra/docker/api.Dockerfile | 53 +++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/infra/docker/api.Dockerfile b/infra/docker/api.Dockerfile index 28d68bcc6..0d8bd504b 100644 --- a/infra/docker/api.Dockerfile +++ b/infra/docker/api.Dockerfile @@ -28,6 +28,24 @@ ENV HF_HUB_CACHE=${HF_HUB_CACHE:-/app/model_weights/hub} # Set workdir for uv WORKDIR /app +# Keep uv's managed Python and cache on stable, root-owned paths outside any +# user $HOME, and put the project venv under /app so it can be made +# group-writable for the arbitrary UID OpenShift assigns (see below). HOME is a +# dedicated writable subdir (not /app) so libraries that fall back to $HOME +# never need /app itself writable. UV_FROZEN keeps `uv run` from rewriting +# uv.lock at runtime, so the project root can stay read-only. +# USER/LOGNAME are set because the arbitrary UID OpenShift assigns has no +# /etc/passwd entry: getpass.getuser() reads these env vars first and so +# resolves without a passwd lookup (the same approach used for the vllm +# service in docker-compose.yaml). This avoids making /etc/passwd writable. +ENV UV_PYTHON_INSTALL_DIR=/opt/uv/python \ + UV_CACHE_DIR=/opt/uv/cache \ + UV_PROJECT_ENVIRONMENT=/app/.venv \ + UV_FROZEN=1 \ + HOME=/app/home \ + USER=openrag \ + LOGNAME=openrag + # Install uv & setup venv COPY pyproject.toml uv.lock ./ RUN pip3 install uv && \ @@ -47,4 +65,39 @@ COPY scripts/ /app/scripts/ COPY conf/ /app/conf/ ENV PYTHONPATH=/app/openrag/ ENV APP_iPORT=${APP_iPORT:-8080} + +# --- Run as an unprivileged, OpenShift-compatible user --------------------- +# OpenShift runs containers as an arbitrary, unpredictable UID that is always a +# member of the root group (GID 0). For the app to start under that policy, +# every path it writes at runtime must be group-owned by GID 0 and +# group-writable (chmod g=u). We strip group-write from the whole tree first +# (chmod -R g-w) and then grant it back ONLY on those exact paths: the venv, +# the editable install's egg-info, $HOME, data, db, logs, the HF model cache, +# and uv's cache. So /app and /opt/uv themselves, plus the copied code/config +# and uv's managed Python (/app/openrag, /app/conf, /app/scripts, +# /opt/uv/python), stay group-readable but NOT group-writable regardless of +# their source mode — the arbitrary UID cannot create, rename, or replace +# entries in them. +# The two exceptions under the otherwise read-only /app/openrag are Chainlit's +# runtime-writable dirs: it creates ./.files at import time and writes +# ./.chainlit/config.toml + translations on startup (WORKDIR is /app/openrag), +# so both are pre-created and granted group-write below or the app crashes with +# PermissionError: '/app/openrag/.files'. +# We also bake a fixed non-root UID for plain Docker/Kubernetes, where the +# arbitrary-UID remap does not happen; APP_UID is a build arg so a compose +# build can match the host user that owns the bind-mounted volumes. The user's +# primary group is 0 so it shares the same group access on either platform. +ARG APP_UID=10001 +RUN useradd --uid ${APP_UID} --gid 0 --no-log-init --no-create-home \ + --home-dir /app/home --shell /sbin/nologin openrag \ + && mkdir -p /app/home /app/data /app/db /app/logs /app/model_weights/hub \ + /app/.venv /app/openrag.egg-info /opt/uv/cache \ + /app/openrag/.files /app/openrag/.chainlit \ + && chgrp -R 0 /app /opt/uv \ + && chmod -R g-w /app /opt/uv \ + && chmod -R g=u /app/home /app/data /app/db /app/logs /app/model_weights \ + /app/.venv /app/openrag.egg-info /opt/uv/cache \ + /app/openrag/.files /app/openrag/.chainlit +USER ${APP_UID} + ENTRYPOINT ../entrypoint.sh From 1c502f00d3bcef4eccf7bfb016b8f2b3fe6e6f65 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:03:46 +0000 Subject: [PATCH 04/74] fix(security): don't bind-mount source / auto-reload in prod (N8) The production compose bind-mounted ./openrag over the image and the entrypoint always ran uvicorn with --reload (a dev feature, also incompatible with multiple workers). Both let host-side changes override the running code. - Comment out the ../../openrag dev bind-mount (uncomment for local dev). - Gate --reload behind UVICORN_RELOAD=true (default off). Forward-ported from 645128dc (main) into infra/compose/docker-compose.yaml and infra/scripts/entrypoint.sh (uvicorn/Ray entrypoints target api.main here). --- infra/compose/docker-compose.yaml | 7 +++++-- infra/scripts/entrypoint.sh | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/infra/compose/docker-compose.yaml b/infra/compose/docker-compose.yaml index 5d885a9a8..339b93b89 100644 --- a/infra/compose/docker-compose.yaml +++ b/infra/compose/docker-compose.yaml @@ -24,9 +24,12 @@ x-openrag: &openrag_template - ${DATA_VOLUME:-../../data}:/app/data - ../../i8n:/app/openrag/.chainlit/translations - ${MODEL_WEIGHTS_VOLUME:-~/.cache/huggingface}:/app/model_weights # Model weights for RAG - - ../../openrag:/app/openrag # For dev mode + # Dev only: bind-mounting the source tree over the image lets host changes + # (or a writable host path) override the running code. Uncomment for local + # development; keep it off in production. + # - ../../openrag:/app/openrag # For dev mode - /$SHARED_ENV:/ray_mount/.env # Shared environment variables - - ${LOG_VOLUME:-../../logs}:/app/logs # For dev mode + - ${LOG_VOLUME:-../../logs}:/app/logs ports: - ${APP_PORT:-8080}:${APP_iPORT:-8080} # Bind the Ray dashboard to localhost only: it has no authentication and diff --git a/infra/scripts/entrypoint.sh b/infra/scripts/entrypoint.sh index 5fcdf4fb5..22d4930cd 100644 --- a/infra/scripts/entrypoint.sh +++ b/infra/scripts/entrypoint.sh @@ -1,13 +1,23 @@ #!/bin/bash -ENV_ARG="" +ENV_ARGS=() if [[ -n "${SHARED_ENV}" ]]; then - ENV_ARG="--env-file=${SHARED_ENV}" + ENV_ARGS+=("--env-file=${SHARED_ENV}") fi if [[ "${ENABLE_RAY_SERVE}" == "true" ]]; then echo "🔁 Starting with Ray Serve..." - uv run $ENV_ARG -m api.main + uv run "${ENV_ARGS[@]}" -m api.main else echo "🚀 Starting with Uvicorn..." - uv run --no-dev $ENV_ARG uvicorn api.main:app --host 0.0.0.0 --port ${APP_iPORT:-8080} --reload --workers ${API_NUM_WORKERS:-1} + # --reload is a development feature (watches/auto-imports source on change) + # and is incompatible with multiple workers. Enable it only with + # UVICORN_RELOAD=true for local dev; production runs without it. + RELOAD_ARGS=() + WORKERS="${API_NUM_WORKERS:-1}" + if [[ "${UVICORN_RELOAD}" == "true" ]]; then + RELOAD_ARGS+=("--reload") + # uvicorn rejects --reload together with multiple workers; force single worker. + WORKERS="1" + fi + uv run --no-dev "${ENV_ARGS[@]}" uvicorn api.main:app --host 0.0.0.0 --port "${APP_iPORT:-8080}" "${RELOAD_ARGS[@]}" --workers "${WORKERS}" fi From bc9e286046b49aba480a61311b37d667e9f10bd9 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:04:58 +0000 Subject: [PATCH 05/74] fix(deploy): remove API_NUM_WORKERS footgun, force single uvicorn worker The uvicorn deployment path fed API_NUM_WORKERS into `uvicorn --workers N`, but the app calls ray.init() at import time, so each extra worker starts its own isolated Ray cluster with duplicate named actors (Indexer, Vectordb, TaskStateManager), fragmenting task state and vector-DB access. - entrypoint.sh: always run a single uvicorn worker; warn if API_NUM_WORKERS is set to a non-1 value, pointing operators to Ray Serve. - charts: drop the dead API_NUM_WORKERS: "8" (the chart runs Ray Serve, which takes the api.main branch and never reads it). - .env.example / docs: remove the knob and document Ray Serve (ENABLE_RAY_SERVE + RAY_SERVE_NUM_REPLICAS) as the HTTP scaling path. Closes #500 Forward-ported from 0e5687bc (main) into the infra/ layout. --- docs/content/docs/documentation/env_vars.md | 15 +++++++++++++-- infra/charts/openrag-stack/values.yaml | 3 ++- infra/compose/.env.example | 4 +++- infra/scripts/entrypoint.sh | 19 ++++++++++++------- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/docs/content/docs/documentation/env_vars.md b/docs/content/docs/documentation/env_vars.md index e55fbb444..ae2e0517e 100644 --- a/docs/content/docs/documentation/env_vars.md +++ b/docs/content/docs/documentation/env_vars.md @@ -416,7 +416,19 @@ Controls the maximum number of concurrent operations for different indexer tasks | `RAY_SEMAPHORE_CONCURRENCY` | int | 100000 | Global concurrency limit for Ray semaphore operations | #### Ray Serve Configuration -Ray Serve enables deployment of the FastAPI as a scalable service. For simple deployment, without the intend to scale, one can usage the [uvicorn deployment mode](/openrag/documentation/env_vars/#ray-serve-configuration) + +Ray Serve enables deployment of the FastAPI app as a horizontally scalable service. + +By default (`ENABLE_RAY_SERVE=false`) OpenRAG runs under **uvicorn with a single worker**. This is intentional: the app initializes Ray and its named actors (`Indexer`, `Vectordb`, `TaskStateManager`, …) at import time, so a second uvicorn worker would start its **own isolated Ray cluster** with duplicate actors, fragmenting task state and vector-DB access. Concurrency within the single worker comes from the async app and from Ray itself — **not** from multiple uvicorn workers (there is intentionally no `API_NUM_WORKERS` knob). + +**To scale the HTTP layer, enable Ray Serve** — it runs `RAY_SERVE_NUM_REPLICAS` replicas inside one shared Ray cluster: + +```bash +ENABLE_RAY_SERVE=true +RAY_SERVE_NUM_REPLICAS=4 +``` + +For multi-node distributed deployments, see [Distributed Deployment in a Ray Cluster](/openrag/documentation/deploy_ray_cluster/). | Variable | Type | Default | Description | |----------|------|---------|-------------| @@ -512,7 +524,6 @@ The following environment variables configure the FastAPI server and control acc | `AUTH_TOKEN` | `string` | `EMPTY` | An authentication token is required to access protected API endpoints. By default, this token corresponds to the API key of the created admin (see [Admin Bootstrapping](/openrag/documentation/user_auth/#2-admin-bootstrapping)). If left empty, authentication is disabled. | | `SUPER_ADMIN_MODE` | `boolean` | `false` | Enables super admin privileges when set to `true`, [granting unrestricted access](/openrag/documentation/data_model/#access-control) to all operations and bypassing standard access controls. This is for debugging | | `DEFAULT_FILE_QUOTA` | `int` | `-1` | Default per-user file quota. `<0` disables quotas globally; `>=0` sets the default limit when a user has no explicit quota. | -|`API_NUM_WORKERS`|`int`|1|Number of uvicorn workers| | `PREFERRED_URL_SCHEME` | `string` | `null` | URL scheme (`http` or `https`) used when generating URLs in API responses (e.g., `task_status_url`). When running behind a reverse proxy that terminates SSL, set this to `https` to ensure generated URLs use the correct scheme. If unset, the scheme from the incoming request is used. | | `CORS_EXTRA_ORIGINS` | `string` | _(unset)_ | Semicolon-separated list of additional origins allowed by CORS (e.g. `https://app.example.com;https://other.example.com`). Extends the default list without replacing it. | diff --git a/infra/charts/openrag-stack/values.yaml b/infra/charts/openrag-stack/values.yaml index 5bd1892e3..13c746a24 100644 --- a/infra/charts/openrag-stack/values.yaml +++ b/infra/charts/openrag-stack/values.yaml @@ -288,7 +288,8 @@ env: WITH_CHAINLIT_UI: "false" SAVE_UPLOADED_FILES: "false" - API_NUM_WORKERS: "8" + # HTTP scaling is handled by Ray Serve above (ENABLE_RAY_SERVE + + # RAY_SERVE_NUM_REPLICAS), not uvicorn workers — see entrypoint.sh. INDEXERUI_URL: "http://indexer-ui:3042" # Vector DB diff --git a/infra/compose/.env.example b/infra/compose/.env.example index c50fc7b26..8fe38997c 100644 --- a/infra/compose/.env.example +++ b/infra/compose/.env.example @@ -10,7 +10,9 @@ VLM_MODEL= ## FastAPI App (no need to change it) # APP_PORT=8080 # this is the forwarded port -# API_NUM_WORKERS=1 # Number of uvicorn workers for the FastAPI app +# The uvicorn path runs a single worker by design (Ray provides concurrency). +# To scale the HTTP layer, use Ray Serve: ENABLE_RAY_SERVE=true with +# RAY_SERVE_NUM_REPLICAS=N (see the Ray Serve configuration section). ## To enable API HTTP authentication via HTTPBearer # AUTH_TOKEN=sk-openrag-1234 diff --git a/infra/scripts/entrypoint.sh b/infra/scripts/entrypoint.sh index 22d4930cd..a615e4d60 100644 --- a/infra/scripts/entrypoint.sh +++ b/infra/scripts/entrypoint.sh @@ -9,15 +9,20 @@ if [[ "${ENABLE_RAY_SERVE}" == "true" ]]; then uv run "${ENV_ARGS[@]}" -m api.main else echo "🚀 Starting with Uvicorn..." - # --reload is a development feature (watches/auto-imports source on change) - # and is incompatible with multiple workers. Enable it only with - # UVICORN_RELOAD=true for local dev; production runs without it. + # This path always runs a SINGLE uvicorn worker. The app initializes Ray and + # its named actors (Indexer, Vectordb, TaskStateManager, ...) at import time, + # so each extra worker would be a separate process starting its own isolated + # Ray cluster with duplicate actors — fragmenting task state and the vector + # DB. Concurrency comes from the async app + Ray, not from uvicorn workers. + # To scale the HTTP layer horizontally, use Ray Serve (ENABLE_RAY_SERVE=true, + # RAY_SERVE_NUM_REPLICAS=N), which runs N replicas inside one Ray cluster. + if [[ -n "${API_NUM_WORKERS}" && "${API_NUM_WORKERS}" != "1" ]]; then + echo "⚠️ API_NUM_WORKERS=${API_NUM_WORKERS} is ignored: this app runs a single uvicorn worker (Ray provides concurrency). To scale, set ENABLE_RAY_SERVE=true with RAY_SERVE_NUM_REPLICAS." >&2 + fi + # --reload is dev-only (set UVICORN_RELOAD=true); it also forces a single worker. RELOAD_ARGS=() - WORKERS="${API_NUM_WORKERS:-1}" if [[ "${UVICORN_RELOAD}" == "true" ]]; then RELOAD_ARGS+=("--reload") - # uvicorn rejects --reload together with multiple workers; force single worker. - WORKERS="1" fi - uv run --no-dev "${ENV_ARGS[@]}" uvicorn api.main:app --host 0.0.0.0 --port "${APP_iPORT:-8080}" "${RELOAD_ARGS[@]}" --workers "${WORKERS}" + uv run --no-dev "${ENV_ARGS[@]}" uvicorn api.main:app --host 0.0.0.0 --port "${APP_iPORT:-8080}" "${RELOAD_ARGS[@]}" --workers 1 fi From 7fd37525518f8751d2e05e639076e33c179b9495 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:05:48 +0000 Subject: [PATCH 06/74] docs: drop API_NUM_WORKERS from example env assets .env.example removed the API_NUM_WORKERS knob, but its two hand-maintained mirrors under docs/assets/ (env_example.env, env_linux_gpu.env) still advertised it with the old, now-incorrect description. Apply the same comment as .env.example pointing to the Ray Serve scaling path. Forward-ported from 319bc5cd (main). --- docs/assets/env_example.env | 4 +++- docs/assets/env_linux_gpu.env | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/assets/env_example.env b/docs/assets/env_example.env index 74b42cc50..1c119f54c 100644 --- a/docs/assets/env_example.env +++ b/docs/assets/env_example.env @@ -10,7 +10,9 @@ VLM_MODEL= ## FastAPI App (no need to change it) # APP_PORT=8080 # this is the forwarded port -# API_NUM_WORKERS=1 # Number of uvicorn workers for the FastAPI app +# The uvicorn path runs a single worker by design (Ray provides concurrency). +# To scale the HTTP layer, use Ray Serve: ENABLE_RAY_SERVE=true with +# RAY_SERVE_NUM_REPLICAS=N (see the Ray Serve configuration section). ## To enable API HTTP authentication via HTTPBearer # AUTH_TOKEN=sk-openrag-1234 diff --git a/docs/assets/env_linux_gpu.env b/docs/assets/env_linux_gpu.env index 099114125..194773e9d 100644 --- a/docs/assets/env_linux_gpu.env +++ b/docs/assets/env_linux_gpu.env @@ -10,7 +10,9 @@ VLM_MODEL= ## FastAPI App (no need to change it) # APP_PORT=8080 # this is the forwarded port -# API_NUM_WORKERS=1 # Number of uvicorn workers for the FastAPI app +# The uvicorn path runs a single worker by design (Ray provides concurrency). +# To scale the HTTP layer, use Ray Serve: ENABLE_RAY_SERVE=true with +# RAY_SERVE_NUM_REPLICAS=N (see the Ray Serve configuration section). ## To enable API HTTP authentication via HTTPBearer # AUTH_TOKEN=sk-openrag-1234 From bb5797bd27a997ee368787fa09f241ae2cb46dbf Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:06:09 +0000 Subject: [PATCH 07/74] chore(release): bump version to 1.1.12 Forward-ported from c558dd1f (main). --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index aea654cc8..579dfa4aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openrag" -version = "1.1.11" +version = "1.1.12" description = "Add your description here" readme = "README.md" requires-python = ">=3.12" From 0ae15c057374184e6b57abc71991687f05516793 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:06:16 +0000 Subject: [PATCH 08/74] chore(release): bump version to 1.1.13 Forward-ported from d69be1da (main). --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 579dfa4aa..2b11cd527 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openrag" -version = "1.1.12" +version = "1.1.13" description = "Add your description here" readme = "README.md" requires-python = ">=3.12" From 389072ebba0623604ef28e72d2e2a1c84c4f9831 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:06:59 +0000 Subject: [PATCH 09/74] fix(security): harden Ansible deploy (host key checking, .env 0600, pinned uv installer) (#488) - ansible.cfg: enable host_key_checking and switch ssh_args to StrictHostKeyChecking=accept-new (drop UserKnownHostsFile=/dev/null + StrictHostKeyChecking=no, which disabled MITM protection). - openrag.yml: write the deployed .env as 0600 (was 0644) and pin the uv installer to a specific version under `set -euo pipefail` / bash. Forward-ported from 2af1d76f (main) into infra/ansible/. --- infra/ansible/ansible.cfg | 4 ++-- infra/ansible/playbooks/openrag.yml | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/infra/ansible/ansible.cfg b/infra/ansible/ansible.cfg index d5db45cc6..6d99d1494 100644 --- a/infra/ansible/ansible.cfg +++ b/infra/ansible/ansible.cfg @@ -1,10 +1,10 @@ [defaults] inventory = inventory.ini -host_key_checking = False +host_key_checking = True retry_files_enabled = False stdout_callback = default gathering = smart fact_caching = memory [ssh_connection] -ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no +ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new diff --git a/infra/ansible/playbooks/openrag.yml b/infra/ansible/playbooks/openrag.yml index 71bb73c0a..db2b125a8 100644 --- a/infra/ansible/playbooks/openrag.yml +++ b/infra/ansible/playbooks/openrag.yml @@ -104,7 +104,7 @@ dest: "{{ project_path }}/.env" owner: "{{ project_user }}" group: "{{ project_user }}" - mode: "0644" + mode: "0600" when: - not env_file.stat.exists - local_env_file.stat.exists @@ -124,8 +124,11 @@ - name: Setup Python environment block: - name: Install uv (Python package manager) - shell: curl -LsSf https://astral.sh/uv/install.sh | sh + shell: | + set -euo pipefail + curl -LsSf https://astral.sh/uv/0.5.11/install.sh | sh args: + executable: /bin/bash creates: "/home/{{ project_user }}/.cargo/bin/uv" - name: Add uv to PATH in .bashrc From fec84c2d604b8e6758386cf1d0105eb7fcc0b899 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:08:42 +0000 Subject: [PATCH 10/74] fix(security): bind Ray dashboard to localhost by default (C3) The Ray dashboard and co-hosted Jobs API are unauthenticated (CVE-2023-48022 "ShadowRay"), so exposing them on a routable interface is an unauthenticated-RCE vector. Bind to 127.0.0.1 by default everywhere it is configured, overridable via RAY_DASHBOARD_HOST: - openrag/api/main.py + openrag/api/mcp/server.py: ray.init reads RAY_DASHBOARD_HOST (default 127.0.0.1). The refactor has two embedded ray.init call sites (the API lifespan and the MCP server) where main had one, so both are hardened. - infra/quick_start/docker-compose.yaml: publish 127.0.0.1:8265 only - infra/cluster.yaml: --dashboard-host ${RAY_DASHBOARD_HOST:-127.0.0.1} - docs/assets/compose_ollama_cpu.yaml: localhost-only dashboard port The KubeRay pod keeps 0.0.0.0 because the dashboard Service requires in-pod reachability; it is instead isolated via the NetworkPolicy added separately (N12). Forward-ported from 34dc3a2f (main). --- docs/assets/compose_ollama_cpu.yaml | 3 ++- infra/cluster.yaml | 3 ++- infra/quick_start/docker-compose.yaml | 2 +- openrag/api/main.py | 9 ++++++++- openrag/api/mcp/server.py | 7 ++++++- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/assets/compose_ollama_cpu.yaml b/docs/assets/compose_ollama_cpu.yaml index 92d0ebead..cf39668dc 100644 --- a/docs/assets/compose_ollama_cpu.yaml +++ b/docs/assets/compose_ollama_cpu.yaml @@ -7,7 +7,8 @@ x-openrag: &openrag_template - ./ray_mount/logs:/app/logs ports: - 8090:8080 - - 8265:8265 # Disable when in cluster mode + # Localhost only: Ray dashboard/Jobs API is unauthenticated (CVE-2023-48022). Disable when in cluster mode + - 127.0.0.1:${RAY_DASHBOARD_PORT:-8265}:8265 networks: default: aliases: diff --git a/infra/cluster.yaml b/infra/cluster.yaml index 066c9c22c..17ed0d1eb 100644 --- a/infra/cluster.yaml +++ b/infra/cluster.yaml @@ -20,7 +20,8 @@ auth: head_start_ray_commands: - uv run ray stop - - uv run ray start --head --dashboard-host 0.0.0.0 --dashboard-port ${RAY_DASHBOARD_PORT:-8265} --node-ip-address ${HEAD_NODE_IP} --autoscaling-config=~/ray_bootstrap_config.yaml + # Dashboard/Jobs API is unauthenticated (CVE-2023-48022); bind to localhost by default and front with an auth proxy if remote access is needed. + - uv run ray start --head --dashboard-host ${RAY_DASHBOARD_HOST:-127.0.0.1} --dashboard-port ${RAY_DASHBOARD_PORT:-8265} --node-ip-address ${HEAD_NODE_IP} --autoscaling-config=~/ray_bootstrap_config.yaml worker_start_ray_commands: - uv run ray stop - uv run ray start --address ${HEAD_NODE_IP:-10.0.0.1}:6379 \ No newline at end of file diff --git a/infra/quick_start/docker-compose.yaml b/infra/quick_start/docker-compose.yaml index 9cecd3a98..7016e6e83 100644 --- a/infra/quick_start/docker-compose.yaml +++ b/infra/quick_start/docker-compose.yaml @@ -16,7 +16,7 @@ x-openrag: &openrag_template # - ./logs:/app/logs ports: - ${APP_PORT:-8080}:${APP_iPORT:-8080} - - ${RAY_DASHBOARD_PORT:-8265}:8265 # Disable when in cluster mode + - 127.0.0.1:${RAY_DASHBOARD_PORT:-8265}:8265 # Localhost only: Ray dashboard/Jobs API is unauthenticated. Disable when in cluster mode networks: default: aliases: diff --git a/openrag/api/main.py b/openrag/api/main.py index 2da2858e3..da25b1ee0 100644 --- a/openrag/api/main.py +++ b/openrag/api/main.py @@ -150,7 +150,14 @@ async def lifespan(app: FastAPI): """ if not ray.is_initialized(): logger.info("Startup: initializing Ray") - ray.init(dashboard_host="0.0.0.0", ignore_reinit_error=True) + # Bind the Ray dashboard to localhost by default; the dashboard / Jobs + # API is unauthenticated (CVE-2023-48022 "ShadowRay") so it must never + # listen on a routable interface. Operators that front it with an auth + # proxy can override via RAY_DASHBOARD_HOST. + ray.init( + dashboard_host=os.environ.get("RAY_DASHBOARD_HOST", "127.0.0.1"), + ignore_reinit_error=True, + ) logger.info("Startup: Ray is initialized") # ``ensure_worker_bootstrap`` imports ``services.workers.bootstrap`` diff --git a/openrag/api/mcp/server.py b/openrag/api/mcp/server.py index acd4d09f1..03f4a064c 100644 --- a/openrag/api/mcp/server.py +++ b/openrag/api/mcp/server.py @@ -77,7 +77,12 @@ def _service(): async def _startup() -> None: global _container if not ray.is_initialized(): - ray.init(dashboard_host="0.0.0.0", ignore_reinit_error=True) + # Bind the Ray dashboard to localhost by default; it is unauthenticated + # (CVE-2023-48022). Override via RAY_DASHBOARD_HOST behind an auth proxy. + ray.init( + dashboard_host=os.environ.get("RAY_DASHBOARD_HOST", "127.0.0.1"), + ignore_reinit_error=True, + ) ensure_worker_bootstrap() container = ServiceContainer(config) try: From 32e6dcadcb919347a20ca715342fd5440abde858 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:10:34 +0000 Subject: [PATCH 11/74] feat(api): connect to an external Ray cluster via RAY_ADDRESS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When RAY_ADDRESS is set, attach to an existing Ray cluster instead of starting an embedded one (so no local dashboard is started — the head node owns it). The embedded branch keeps binding the unauthenticated dashboard to 127.0.0.1 by default (CVE-2023-48022), overridable via RAY_DASHBOARD_HOST. Applied to both refactor ray.init sites (API lifespan and the standalone MCP server). Also document RAY_ADDRESS and RAY_DASHBOARD_HOST in the env examples and the env-vars / Ray-cluster deployment docs. Forward-ported from aa015bdd (main). --- docs/assets/env_example.env | 5 ++++ docs/assets/env_linux_gpu.env | 5 ++++ .../docs/documentation/deploy_ray_cluster.md | 4 ++++ docs/content/docs/documentation/env_vars.md | 2 ++ infra/compose/.env.example | 8 +++++++ openrag/api/main.py | 23 ++++++++++++------- openrag/api/mcp/server.py | 18 ++++++++++----- 7 files changed, 51 insertions(+), 14 deletions(-) diff --git a/docs/assets/env_example.env b/docs/assets/env_example.env index 1c119f54c..8a6e7948e 100644 --- a/docs/assets/env_example.env +++ b/docs/assets/env_example.env @@ -44,6 +44,11 @@ RAY_DEDUP_LOGS=0 # turns off ray log deduplication that appear across multiple p RAY_ENABLE_RECORD_ACTOR_TASK_LOGGING=1 # # to enable logs at task level in ray dashboard RAY_task_retry_delay_ms=3000 RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 # critical with the newest version of UV +# Attach to an external Ray cluster instead of starting an embedded one (disables the local dashboard). +# RAY_ADDRESS=ray://X.X.X.X:10001 +# Interface the embedded Ray dashboard binds to. Defaults to 127.0.0.1 (loopback) because the +# dashboard/job API is unauthenticated (CVE-2023-48022). Set 0.0.0.0 only behind a firewall/auth proxy. +# RAY_DASHBOARD_HOST=127.0.0.1 # Indexer UI ## 1. replace X.X.X.X with localhost if launching local or with your server IP diff --git a/docs/assets/env_linux_gpu.env b/docs/assets/env_linux_gpu.env index 194773e9d..5bd3332f0 100644 --- a/docs/assets/env_linux_gpu.env +++ b/docs/assets/env_linux_gpu.env @@ -40,6 +40,11 @@ RAY_DEDUP_LOGS=0 # turns off ray log deduplication that appear across multiple p RAY_ENABLE_RECORD_ACTOR_TASK_LOGGING=1 # # to enable logs at task level in ray dashboard RAY_task_retry_delay_ms=3000 RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 # critical with the newest version of UV +# Attach to an external Ray cluster instead of starting an embedded one (disables the local dashboard). +# RAY_ADDRESS=ray://X.X.X.X:10001 +# Interface the embedded Ray dashboard binds to. Defaults to 127.0.0.1 (loopback) because the +# dashboard/job API is unauthenticated (CVE-2023-48022). Set 0.0.0.0 only behind a firewall/auth proxy. +# RAY_DASHBOARD_HOST=127.0.0.1 # Indexer UI ## 1. replace X.X.X.X with localhost if launching local or with your server IP diff --git a/docs/content/docs/documentation/deploy_ray_cluster.md b/docs/content/docs/documentation/deploy_ray_cluster.md index c5ad01f7e..80b9390a6 100644 --- a/docs/content/docs/documentation/deploy_ray_cluster.md +++ b/docs/content/docs/documentation/deploy_ray_cluster.md @@ -136,6 +136,10 @@ docker compose up -d Once running, **OpenRAG will auto-connect** to the Ray cluster using `RAY_ADDRESS` from `.env`. +:::note +When `RAY_ADDRESS` is set, the app **attaches** to the external cluster and does **not** start its own embedded Ray dashboard — the head node owns it (started above via `--dashboard-host 0.0.0.0 --dashboard-port ${RAY_DASHBOARD_PORT:-8265}`). The app-side `RAY_DASHBOARD_HOST` setting is only used in embedded (single-node) mode, where it defaults to `127.0.0.1` because the dashboard API is unauthenticated ([CVE-2023-48022](https://nvd.nist.gov/vuln/detail/CVE-2023-48022)). +::: + --- With this setup, your app is now fully distributed and ready to handle concurrent tasks across your Ray cluster. diff --git a/docs/content/docs/documentation/env_vars.md b/docs/content/docs/documentation/env_vars.md index ae2e0517e..2a2695d75 100644 --- a/docs/content/docs/documentation/env_vars.md +++ b/docs/content/docs/documentation/env_vars.md @@ -375,6 +375,8 @@ Ray is used for distributed task processing and parallel execution in the RAG pi | `RAY_POOL_SIZE` | `int` | 1 | Number of serializer actor instances (typically 1 actor per cluster node) | | `RAY_MAX_TASKS_PER_WORKER` | `int` | 8 | Maximum number of concurrent tasks (serialization tasks) per serializer actor instance | | `RAY_DASHBOARD_PORT` | `int` | 8265 | Ray Dashboard port used for monitoring. In production, [comment out this line](https://github.com/linagora/openrag/blob/ee732ea8e080dcde0107d62d12703a7525f810cd/docker-compose.yaml#L21C1-L22C1) to avoid exposing the port, as it may introduce security vulnerabilities. | +| `RAY_DASHBOARD_HOST` | `str` | `127.0.0.1` | Interface the **embedded** Ray dashboard binds to. Defaults to loopback because the Ray dashboard/job-submission API is **unauthenticated** ([CVE-2023-48022](https://nvd.nist.gov/vuln/detail/CVE-2023-48022)). Set to `0.0.0.0` only when the dashboard port is firewalled or sits behind an authenticating proxy. Ignored when `RAY_ADDRESS` is set. | +| `RAY_ADDRESS` | `str` | (unset) | When set, attach to an **external** Ray cluster at this address (e.g. `ray://HEAD_IP:10001`) instead of starting an embedded cluster in-process. In this mode the app does not start a local dashboard — the head node owns it. See [Ray Cluster deployment](/openrag/documentation/deploy_ray_cluster/). | :::danger[Attention] The following environment variables control Ray's logging behavior, task retry settings. These are not set by default and must be supplied [as suggested in the .env](/openrag/getting_started/quickstart#2-create-a-env-file) diff --git a/infra/compose/.env.example b/infra/compose/.env.example index 8fe38997c..bdbc5b3f0 100644 --- a/infra/compose/.env.example +++ b/infra/compose/.env.example @@ -65,6 +65,14 @@ RAY_task_retry_delay_ms=3000 RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 # critical with the newest version of UV # # To disable worker killing # RAY_memory_monitor_refresh_ms=0 +# Connect to an external Ray cluster instead of starting an embedded one. +# When set, the app attaches to this cluster and does NOT start a local dashboard +# (the head node owns it). See docs/documentation/deploy_ray_cluster. +# RAY_ADDRESS=ray://X.X.X.X:10001 +# Interface the embedded Ray dashboard binds to. Defaults to 127.0.0.1 (loopback) +# because the dashboard/job API is unauthenticated (CVE-2023-48022). Set to 0.0.0.0 +# only when the port is firewalled or behind an auth proxy. Ignored when RAY_ADDRESS is set. +# RAY_DASHBOARD_HOST=127.0.0.1 # Indexer UI ## 1. replace X.X.X.X with localhost if launching local or with your server IP diff --git a/openrag/api/main.py b/openrag/api/main.py index da25b1ee0..a20ec65dd 100644 --- a/openrag/api/main.py +++ b/openrag/api/main.py @@ -150,14 +150,21 @@ async def lifespan(app: FastAPI): """ if not ray.is_initialized(): logger.info("Startup: initializing Ray") - # Bind the Ray dashboard to localhost by default; the dashboard / Jobs - # API is unauthenticated (CVE-2023-48022 "ShadowRay") so it must never - # listen on a routable interface. Operators that front it with an auth - # proxy can override via RAY_DASHBOARD_HOST. - ray.init( - dashboard_host=os.environ.get("RAY_DASHBOARD_HOST", "127.0.0.1"), - ignore_reinit_error=True, - ) + _ray_address = os.environ.get("RAY_ADDRESS") + if _ray_address: + # Connect to an external Ray cluster (e.g. a dedicated ray-head + # container). No local dashboard is started — the head node owns it. + ray.init(address=_ray_address, ignore_reinit_error=True) + else: + # Embedded mode: start a local Ray cluster inside this process. + # Bind the Ray dashboard to localhost by default; the dashboard / + # Jobs API is unauthenticated (CVE-2023-48022 "ShadowRay") so it + # must never listen on a routable interface. Operators that front it + # with an auth proxy can override via RAY_DASHBOARD_HOST. + ray.init( + dashboard_host=os.environ.get("RAY_DASHBOARD_HOST", "127.0.0.1"), + ignore_reinit_error=True, + ) logger.info("Startup: Ray is initialized") # ``ensure_worker_bootstrap`` imports ``services.workers.bootstrap`` diff --git a/openrag/api/mcp/server.py b/openrag/api/mcp/server.py index 03f4a064c..a32a61d0c 100644 --- a/openrag/api/mcp/server.py +++ b/openrag/api/mcp/server.py @@ -77,12 +77,18 @@ def _service(): async def _startup() -> None: global _container if not ray.is_initialized(): - # Bind the Ray dashboard to localhost by default; it is unauthenticated - # (CVE-2023-48022). Override via RAY_DASHBOARD_HOST behind an auth proxy. - ray.init( - dashboard_host=os.environ.get("RAY_DASHBOARD_HOST", "127.0.0.1"), - ignore_reinit_error=True, - ) + _ray_address = os.environ.get("RAY_ADDRESS") + if _ray_address: + # Attach to an external Ray cluster; the head node owns the dashboard. + ray.init(address=_ray_address, ignore_reinit_error=True) + else: + # Bind the Ray dashboard to localhost by default; it is + # unauthenticated (CVE-2023-48022). Override via RAY_DASHBOARD_HOST + # behind an auth proxy. + ray.init( + dashboard_host=os.environ.get("RAY_DASHBOARD_HOST", "127.0.0.1"), + ignore_reinit_error=True, + ) ensure_worker_bootstrap() container = ServiceContainer(config) try: From 3ddc3a2678b5088ebea5d1c6268ba2b2514078a3 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:12:16 +0000 Subject: [PATCH 12/74] fix(security): require MinIO credentials via env, drop minioadmin default (H3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardcoded minioadmin:minioadmin in the Milvus stacks meant any foothold on the internal network yielded full object-store access. Require operators to supply MINIO_ACCESS_KEY / MINIO_SECRET_KEY (no default — compose fails fast if unset) in both the compose milvus stack and quick_start/vdb/milvus.yaml, and pass the same credentials to the Milvus container (MINIO_ACCESS_KEY_ID / MINIO_SECRET_ACCESS_KEY) so it no longer relies on the minioadmin default. Document the new required vars in .env.example. Forward-ported from 0515f705 (main): vdb/milvus.yaml -> infra/compose/milvus/ milvus.yaml; quick_start/vdb -> infra/quick_start/vdb; .env -> infra/compose. --- infra/compose/.env.example | 6 ++++++ infra/compose/milvus/milvus.yaml | 8 ++++++-- infra/quick_start/vdb/milvus.yaml | 7 +++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/infra/compose/.env.example b/infra/compose/.env.example index bdbc5b3f0..95492470b 100644 --- a/infra/compose/.env.example +++ b/infra/compose/.env.example @@ -54,6 +54,12 @@ RERANKER_MODEL=Alibaba-NLP/gte-multilingual-reranker-base # or jinaai/jina-reran # API accepts natively. Override to restrict (e.g. to ".wav" only for vLLM deployments). # TRANSCRIBER_DIRECT_UPLOAD_SUFFIXES=.wav|.flac|.ogg|.mp3|.mp4|.m4a|.webm|.mpeg|.mpga +# Object storage (MinIO, used by Milvus) — REQUIRED, no default. +# Generate strong random values, e.g. `openssl rand -hex 16`. These are shared +# between the minio service and Milvus; both must match. +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= + # Prompts (templates ship inside the package at openrag/prompts/templates; # set PROMPTS_DIR only to override with a custom template directory) # PROMPTS_DIR=/path/to/custom/templates diff --git a/infra/compose/milvus/milvus.yaml b/infra/compose/milvus/milvus.yaml index 968919c42..96c8e3f0f 100644 --- a/infra/compose/milvus/milvus.yaml +++ b/infra/compose/milvus/milvus.yaml @@ -18,8 +18,8 @@ services: minio: image: minio/minio:RELEASE.2024-12-18T13-15-44Z environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ${MILVUS_VOLUME_DIRECTORY:-./volumes}/minio:/minio_data command: minio server /minio_data --console-address ":9001" @@ -37,6 +37,10 @@ services: environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 + # Milvus must authenticate to MinIO with the same credentials; otherwise + # it falls back to the built-in minioadmin default and fails to connect. + MINIO_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ${MILVUS_VOLUME_DIRECTORY:-./volumes}/milvus:/var/lib/milvus healthcheck: diff --git a/infra/quick_start/vdb/milvus.yaml b/infra/quick_start/vdb/milvus.yaml index 6a9f37e0c..b80eba6c0 100644 --- a/infra/quick_start/vdb/milvus.yaml +++ b/infra/quick_start/vdb/milvus.yaml @@ -18,8 +18,8 @@ services: minio: image: minio/minio:RELEASE.2023-03-20T20-16-18Z environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ${MILVUS_VOLUME_DIRECTORY:-./volumes}/minio:/minio_data command: minio server /minio_data --console-address ":9001" @@ -37,6 +37,9 @@ services: environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 + # Keep Milvus's MinIO credentials in sync with the minio service above. + MINIO_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ${MILVUS_VOLUME_DIRECTORY:-./volumes}/milvus:/var/lib/milvus healthcheck: From fb93d2feaa2ba6a85d6ca55bc524c66e8187278a Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:13:18 +0000 Subject: [PATCH 13/74] fix(security): remove weak default DB password and AUTH_TOKEN (M2/M3) Shipped defaults (root_password, POSTGRES_PASSWORD=root, AUTH_TOKEN=OpenRAG, minioadmin) are usable credentials on any exposed deployment. - docker-compose.yaml / quick_start: require ${POSTGRES_PASSWORD:?} (fail fast) - conf/config.yaml: drop the "root_password" default; password must come from the POSTGRES_PASSWORD env var - docs/assets/compose_ollama_cpu.yaml: require AUTH_TOKEN, POSTGRES_PASSWORD and MinIO credentials via env instead of the weak literals - document POSTGRES_PASSWORD in .env.example The Helm chart's Postgres password is addressed in the N7 commit (moved to a Secret) alongside the rest of the chart hardening. Forward-ported from 0164b829 (main) into the infra/ layout. --- conf/config.yaml | 4 +++- docs/assets/compose_ollama_cpu.yaml | 8 ++++---- infra/compose/.env.example | 5 +++++ infra/compose/docker-compose.yaml | 2 +- infra/quick_start/docker-compose.yaml | 2 +- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/conf/config.yaml b/conf/config.yaml index 0cd663eaa..2cda7321c 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -71,7 +71,9 @@ rdb: host: rdb port: 5432 user: root - password: "root_password" + # No default: supply via the POSTGRES_PASSWORD env var. Shipping a known + # password would be a usable default credential on any exposed deployment. + password: "" default_file_quota: -1 # Leave unset to derive partitions_for_collection_. database: null diff --git a/docs/assets/compose_ollama_cpu.yaml b/docs/assets/compose_ollama_cpu.yaml index cf39668dc..e80bd6063 100644 --- a/docs/assets/compose_ollama_cpu.yaml +++ b/docs/assets/compose_ollama_cpu.yaml @@ -17,7 +17,7 @@ x-openrag: &openrag_template - .env environment: - APP_PORT=8090 - - AUTH_TOKEN=OpenRAG + - AUTH_TOKEN=${AUTH_TOKEN:?Set a strong AUTH_TOKEN in your .env} - RERANKER_ENABLED=false - MARKER_MAX_PROCESSES=1 - INDEXERUI_COMPOSE_FILE=true # Does not serve any purpose but needs to be enabled until PR is merged @@ -39,7 +39,7 @@ services: rdb: image: postgres:15 environment: - - POSTGRES_PASSWORD=root + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your .env} - POSTGRES_USER=root volumes: - ./db:/var/lib/postgresql/data @@ -73,8 +73,8 @@ services: minio: image: minio/minio:RELEASE.2023-03-20T20-16-18Z environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ./volumes/minio:/minio_data command: minio server /minio_data --console-address ":9001" diff --git a/infra/compose/.env.example b/infra/compose/.env.example index 95492470b..09c877326 100644 --- a/infra/compose/.env.example +++ b/infra/compose/.env.example @@ -60,6 +60,11 @@ RERANKER_MODEL=Alibaba-NLP/gte-multilingual-reranker-base # or jinaai/jina-reran MINIO_ACCESS_KEY= MINIO_SECRET_KEY= +# PostgreSQL — REQUIRED, no default. The compose stacks fail to start if unset. +# Generate a strong value, e.g. `openssl rand -hex 16`. +POSTGRES_PASSWORD= +# POSTGRES_USER=root + # Prompts (templates ship inside the package at openrag/prompts/templates; # set PROMPTS_DIR only to override with a custom template directory) # PROMPTS_DIR=/path/to/custom/templates diff --git a/infra/compose/docker-compose.yaml b/infra/compose/docker-compose.yaml index 339b93b89..e590f29c0 100644 --- a/infra/compose/docker-compose.yaml +++ b/infra/compose/docker-compose.yaml @@ -140,7 +140,7 @@ services: rdb: image: postgres:15 environment: - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-root_password} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your .env} - POSTGRES_USER=${POSTGRES_USER:-root} volumes: - ${DB_VOLUME:-../../db}:/var/lib/postgresql/data diff --git a/infra/quick_start/docker-compose.yaml b/infra/quick_start/docker-compose.yaml index 7016e6e83..010c57db2 100644 --- a/infra/quick_start/docker-compose.yaml +++ b/infra/quick_start/docker-compose.yaml @@ -105,7 +105,7 @@ services: rdb: image: postgres:15 environment: - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-root_password} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your .env} - POSTGRES_USER=${POSTGRES_USER:-root} volumes: - ${DB_VOLUME:-./db}:/var/lib/postgresql/data From c1cac2e5ff829c81ca9c430be6d18c67cad04748 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:13:54 +0000 Subject: [PATCH 14/74] fix(security): drop seccomp:unconfined from Milvus containers (N11) The Milvus services disabled syscall filtering entirely (security_opt: seccomp:unconfined), widening container-escape surface. Remove the override so the default seccomp profile applies. The refactor split the compose Milvus into milvus.yaml + milvus.named-volumes.yaml, so the override is dropped from both plus quick_start/vdb/milvus.yaml. Forward-ported from b8002ef0 (main) into the infra/ layout. --- infra/compose/milvus/milvus.named-volumes.yaml | 5 +++-- infra/compose/milvus/milvus.yaml | 5 +++-- infra/quick_start/vdb/milvus.yaml | 3 +-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/infra/compose/milvus/milvus.named-volumes.yaml b/infra/compose/milvus/milvus.named-volumes.yaml index 693361349..e3409e736 100644 --- a/infra/compose/milvus/milvus.named-volumes.yaml +++ b/infra/compose/milvus/milvus.named-volumes.yaml @@ -32,8 +32,9 @@ services: milvus: image: milvusdb/milvus:v2.6.11 command: ["milvus", "run", "standalone"] - security_opt: - - seccomp:unconfined + # Run under Docker's default seccomp profile (do not disable syscall + # filtering). If a specific kernel needs a wider profile, supply a vetted + # custom profile rather than seccomp:unconfined. environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 diff --git a/infra/compose/milvus/milvus.yaml b/infra/compose/milvus/milvus.yaml index 96c8e3f0f..82b9616b1 100644 --- a/infra/compose/milvus/milvus.yaml +++ b/infra/compose/milvus/milvus.yaml @@ -32,8 +32,9 @@ services: milvus: image: milvusdb/milvus:v2.6.11 command: ["milvus", "run", "standalone"] - security_opt: - - seccomp:unconfined + # Run under Docker's default seccomp profile (do not disable syscall + # filtering). If a specific kernel needs a wider profile, supply a vetted + # custom profile rather than seccomp:unconfined. environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 diff --git a/infra/quick_start/vdb/milvus.yaml b/infra/quick_start/vdb/milvus.yaml index b80eba6c0..a26ada08c 100644 --- a/infra/quick_start/vdb/milvus.yaml +++ b/infra/quick_start/vdb/milvus.yaml @@ -32,8 +32,7 @@ services: milvus: image: milvusdb/milvus:v2.5.4 command: ["milvus", "run", "standalone"] - security_opt: - - seccomp:unconfined + # Run under Docker's default seccomp profile (do not disable syscall filtering). environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 From 03b8618f150d0310d5ed5ddc2537a1fda1155f31 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:15:15 +0000 Subject: [PATCH 15/74] fix(security): move Helm DB password to Secret, drop weak default (N7, M2) The Postgres password was rendered into the world-readable ConfigMap (env.config) and defaulted to root_password. Move POSTGRES_PASSWORD to env.secrets (rendered into the Opaque Secret, which the deployment already mounts via secretRef) and replace the default with an obvious placeholder that must be overridden at install time. Forward-ported from 24efaa66 (main) into infra/charts/openrag-stack/values.yaml. --- infra/charts/openrag-stack/values.yaml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/infra/charts/openrag-stack/values.yaml b/infra/charts/openrag-stack/values.yaml index 13c746a24..fd612c98d 100644 --- a/infra/charts/openrag-stack/values.yaml +++ b/infra/charts/openrag-stack/values.yaml @@ -27,7 +27,10 @@ postgresql: enabled: true auth: username: &pgUser root - password: &pgPass root_password + # MUST be overridden at install time, e.g. + # --set postgresql.auth.password=$(openssl rand -hex 16) + # Shipping a known password is a usable default credential. + password: &pgPass "CHANGE_ME_STRONG_PASSWORD" primary: persistence: enabled: true @@ -301,7 +304,8 @@ env: POSTGRES_HOST: "{{ .Release.Name }}-postgresql" POSTGRES_PORT: *pgPort POSTGRES_USER: *pgUser - POSTGRES_PASSWORD: *pgPass + # POSTGRES_PASSWORD is a secret — defined under env.secrets, not here in the + # (world-readable) ConfigMap. POSTGRES_AUTO_CREATE_DB: "{{ .Values.postgresProvisioning.autoCreateDatabase }}" POSTGRES_RUN_MIGRATIONS: "{{ .Values.postgresProvisioning.runMigrationsInApp }}" @@ -347,3 +351,6 @@ env: TRANSCRIBER_API_KEY: "EMPTY" AUTH_TOKEN: "sk-xxxx" # API KEY for OpenRAG HF_TOKEN: "hf_xxxx" # HuggingFace token + # DB password lives in the Secret (not the ConfigMap). Reuses the same + # value as postgresql.auth.password via the *pgPass anchor. + POSTGRES_PASSWORD: *pgPass From a75a15667968505ab72f51bc4ed9f89bc48491bf Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:15:41 +0000 Subject: [PATCH 16/74] fix(security): add default-deny NetworkPolicy to Helm chart (N12, C3 k8s) The chart shipped no NetworkPolicy, so any pod in the cluster could reach the unauthenticated Ray dashboard (8265), GCS (6379), Ray client (10001), Postgres and Milvus. Add a default-deny-ingress NetworkPolicy (podSelector: {}) that allows only intra-namespace traffic plus the configurable public HTTP ports (8080, 3000). Gated by networkPolicy.enabled (default true). This is the in-cluster isolation referenced by the C3 fix for the KubeRay dashboard. Forward-ported from c8f2d47f (main) into infra/charts/openrag-stack/. --- .../templates/networkpolicy.yaml | 33 +++++++++++++++++++ infra/charts/openrag-stack/values.yaml | 12 +++++++ 2 files changed, 45 insertions(+) create mode 100644 infra/charts/openrag-stack/templates/networkpolicy.yaml diff --git a/infra/charts/openrag-stack/templates/networkpolicy.yaml b/infra/charts/openrag-stack/templates/networkpolicy.yaml new file mode 100644 index 000000000..abfd56e0d --- /dev/null +++ b/infra/charts/openrag-stack/templates/networkpolicy.yaml @@ -0,0 +1,33 @@ +{{- if .Values.networkPolicy.enabled }} +# Default-deny ingress baseline: applies to every pod in the namespace +# (podSelector: {}). Two allowances: +# 1. any pod in the same namespace (intra-stack traffic), and +# 2. the public HTTP ports, reachable from anywhere (Ingress controller). +# Everything else — notably the unauthenticated Ray dashboard (8265), GCS +# (6379), Ray client (10001), Postgres and Milvus — is only reachable from +# within the namespace. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "openrag-stack.fullname" . }}-default-deny + labels: + {{- include "openrag-stack.labels" . | nindent 4 }} +spec: + podSelector: {} + policyTypes: + - Ingress + ingress: + # 1. Allow all traffic originating from pods in this namespace. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Release.Namespace }} + # 2. Allow the public HTTP ports from any source (e.g. the Ingress controller). + {{- with .Values.networkPolicy.externalPorts }} + - ports: + {{- range . }} + - port: {{ . }} + protocol: TCP + {{- end }} + {{- end }} +{{- end }} diff --git a/infra/charts/openrag-stack/values.yaml b/infra/charts/openrag-stack/values.yaml index fd612c98d..8ab6e0d37 100644 --- a/infra/charts/openrag-stack/values.yaml +++ b/infra/charts/openrag-stack/values.yaml @@ -1,3 +1,15 @@ +# === Network isolation === +# Default-deny ingress for all pods in the release namespace, allowing only +# intra-namespace traffic plus the public HTTP ports below. This isolates the +# unauthenticated Ray dashboard (8265), GCS (6379), Postgres, Milvus, etc. from +# outside the namespace. Disable only if you manage isolation elsewhere. +networkPolicy: + enabled: true + # Ports reachable from outside the namespace (e.g. via the Ingress controller). + externalPorts: + - 8080 # openrag API/UI + - 3000 # indexer-ui + # === Global shared persistence === persistence: enabled: true From 525d649e4fd9d725f44ea5203e876386450e0e6a Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:16:23 +0000 Subject: [PATCH 17/74] fix(security): restrict metrics stack exposure (N9) The metrics compose exposed Prometheus and exporters on 0.0.0.0 and enabled Prometheus' unauthenticated lifecycle endpoints. - Prometheus: bind 127.0.0.1:9090 and drop --web.enable-lifecycle (unauthenticated /-/reload and /-/quit). - node-exporter (host pid + rootfs) and nvidia-gpu-exporter: bind to 127.0.0.1. Prometheus scrapes them over the compose network by service name, so localhost binding doesn't affect scraping. Forward-ported from 5caec50b (main): openrag_metrics/docker-compose.yaml -> infra/compose/monitoring.docker-compose.yaml. --- infra/compose/monitoring.docker-compose.yaml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/infra/compose/monitoring.docker-compose.yaml b/infra/compose/monitoring.docker-compose.yaml index d415825c9..901f98545 100644 --- a/infra/compose/monitoring.docker-compose.yaml +++ b/infra/compose/monitoring.docker-compose.yaml @@ -3,7 +3,8 @@ services: image: prom/prometheus:latest container_name: openrag-prometheus ports: - - "9090:9090" + # Localhost only: the Prometheus API is unauthenticated. + - "127.0.0.1:9090:9090" volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./prometheus/openrag_token:/etc/prometheus/openrag_token:ro @@ -11,7 +12,8 @@ services: command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.retention.time=30d" - - "--web.enable-lifecycle" + # --web.enable-lifecycle is intentionally NOT set: it exposes unauthenticated + # /-/reload and /-/quit endpoints. Re-enable only behind an auth proxy. extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped @@ -37,7 +39,9 @@ services: image: prom/node-exporter:latest container_name: openrag-node-exporter ports: - - "9100:9100" + # Localhost only: Prometheus scrapes it over the compose network by name, + # so it needn't be exposed on all interfaces (it surfaces host metrics). + - "127.0.0.1:9100:9100" pid: host volumes: - /proc:/host/proc:ro @@ -54,7 +58,8 @@ services: image: utkuozdemir/nvidia_gpu_exporter:1.2.0 container_name: openrag-nvidia-gpu-exporter ports: - - "9835:9835" + # Localhost only: scraped over the compose network by name. + - "127.0.0.1:9835:9835" volumes: - /usr/lib/x86_64-linux-gnu/libnvidia-ml.so:/usr/lib/x86_64-linux-gnu/libnvidia-ml.so:ro - /usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1:/usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1:ro From 81c039e57b4eb5d5d660477cdfc7affed6199891 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:17:51 +0000 Subject: [PATCH 18/74] fix(security): pin image tags instead of latest (N10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracking :latest makes deploys non-reproducible and silently pulls unreviewed images. Pin the OpenRAG-owned chart images to the release version and the metrics stack to specific stable versions: - charts: openrag, openrag-ray, indexer-ui -> 1.1.13 (this branch's release; main pinned 1.1.11) - metrics: prometheus v2.54.1, grafana 11.2.2, node-exporter v1.8.2 Operator-supplied model-serving images (vLLM engines, infinity reranker) carry a comment to pin to a specific release/digest before production. NOTE: the compose / quick_start openrag-image pins from the original commit are NOT ported — main reverted those to :latest in 64c3e722 (already the refactor state), so only the chart + metrics pins remain in main's final state. Forward-ported from d7fc3130 (main): openrag_metrics -> infra/compose/ monitoring.docker-compose.yaml; charts -> infra/charts. --- infra/charts/openrag-stack/values.yaml | 13 ++++++++++--- infra/compose/monitoring.docker-compose.yaml | 6 +++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/infra/charts/openrag-stack/values.yaml b/infra/charts/openrag-stack/values.yaml index 8ab6e0d37..a24f58f1f 100644 --- a/infra/charts/openrag-stack/values.yaml +++ b/infra/charts/openrag-stack/values.yaml @@ -32,7 +32,8 @@ ray: gpu-role: serving image: repository: ghcr.io/linagora/openrag-ray - tag: latest + # Pin to a release tag (ideally a digest) for reproducible deploys. + tag: "1.1.13" # === PostgreSQL (bitnami) === postgresql: @@ -100,6 +101,9 @@ vllm: llmModelName: &llmModel "RedHatAI/Mistral-Small-3.1-24B-Instruct-2503-quantized.w8a8" servingEngineSpec: enableEngine: true + # NOTE: the per-model `tag: "latest"` entries below are operator-supplied + # serving images — pin each to a specific vLLM release tag (or digest) + # before production use for reproducible deploys (see vlm, pinned to v0.11.2). modelSpec: - name: "embedder" repository: "vllm/vllm-openai" @@ -210,6 +214,8 @@ reranker: servicePort: &rerankerPort 7997 image: repository: michaelf34/infinity + # Operator-supplied serving image: pin to a specific infinity release tag + # (or digest) before production use rather than tracking latest. tag: latest nodeSelector: gpu-role: serving @@ -237,7 +243,7 @@ reranker: indexerUi: enabled: true - image: "linagoraai/indexer-ui:latest" + image: "linagoraai/indexer-ui:1.1.13" imagePullPolicy: IfNotPresent replicaCount: 1 @@ -260,7 +266,8 @@ indexerUi: openrag: image: repository: linagoraai/openrag - tag: latest + # Pin to a release tag (ideally a digest) for reproducible deploys. + tag: "1.1.13" service: type: ClusterIP port: 8080 diff --git a/infra/compose/monitoring.docker-compose.yaml b/infra/compose/monitoring.docker-compose.yaml index 901f98545..1e85a6b3b 100644 --- a/infra/compose/monitoring.docker-compose.yaml +++ b/infra/compose/monitoring.docker-compose.yaml @@ -1,6 +1,6 @@ services: prometheus: - image: prom/prometheus:latest + image: prom/prometheus:v2.54.1 container_name: openrag-prometheus ports: # Localhost only: the Prometheus API is unauthenticated. @@ -19,7 +19,7 @@ services: restart: unless-stopped grafana: - image: grafana/grafana:latest + image: grafana/grafana:11.2.2 container_name: openrag-grafana ports: - "3000:3000" @@ -36,7 +36,7 @@ services: restart: unless-stopped node-exporter: - image: prom/node-exporter:latest + image: prom/node-exporter:v1.8.2 container_name: openrag-node-exporter ports: # Localhost only: Prometheus scrapes it over the compose network by name, From f49d77f6360f18c9ece181c913a9581bc851ba1b Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:22:56 +0000 Subject: [PATCH 19/74] fix(security): require explicit ALLOW_NO_AUTH for the no-token admin bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When AUTH_MODE=token and AUTH_TOKEN was unset, the middleware silently treated every request as admin user 1 — a fail-open that turns a single missing env var into a world-open admin API. Gate this dev bypass behind an explicit ALLOW_NO_AUTH=true opt-in (with a loud warning); without it a missing AUTH_TOKEN no longer fails open and unauthenticated requests are denied. Tests added under tests/unit/api/middleware/test_bypass_config.py (the refactor has no equivalent of the legacy components/auth/test_middleware.py dev-bypass suite) using the existing dispatch harness. Forward-ported from 7bc46696 (main): openrag/components/auth/middleware.py -> openrag/api/middleware/auth.py (resolves admin via the injected auth service). --- infra/compose/.env.example | 4 ++ openrag/api/middleware/auth.py | 19 +++++- .../unit/api/middleware/test_bypass_config.py | 61 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/infra/compose/.env.example b/infra/compose/.env.example index 09c877326..1b6a4e3b0 100644 --- a/infra/compose/.env.example +++ b/infra/compose/.env.example @@ -16,6 +16,10 @@ VLM_MODEL= ## To enable API HTTP authentication via HTTPBearer # AUTH_TOKEN=sk-openrag-1234 +# If AUTH_MODE=token and AUTH_TOKEN is unset, authentication is DISABLED and +# every request is treated as admin user 1. This requires an explicit opt-in +# so it can never happen by accident in production: +# ALLOW_NO_AUTH=true # DEV ONLY — never set this in production # Optional: semicolon-separated extra allowed origins # CORS_EXTRA_ORIGINS='https://app.example.com;https://other.example.com' diff --git a/openrag/api/middleware/auth.py b/openrag/api/middleware/auth.py index b94fa748c..f8771486d 100644 --- a/openrag/api/middleware/auth.py +++ b/openrag/api/middleware/auth.py @@ -83,6 +83,14 @@ def is_bypass_path(path: str, *, bypass_config: AuthBypassConfig | None = None) return path in cfg.bypass_paths or path == "/chainlit" or path.startswith("/chainlit/") +def _allow_no_auth() -> bool: + """Whether the no-auth dev bypass (AUTH_TOKEN unset → admin) is allowed. + + Read lazily so it can be toggled per-test, mirroring the other env reads. + """ + return os.getenv("ALLOW_NO_AUTH", "false").strip().lower() == "true" + + class AuthMiddleware(BaseHTTPMiddleware): """FastAPI middleware enforcing authentication for both token and oidc modes. @@ -110,7 +118,16 @@ async def dispatch(self, request: Request, call_next): enc_key = os.getenv("OIDC_TOKEN_ENCRYPTION_KEY") or "" # --- Dev mode: AUTH_MODE=token + AUTH_TOKEN unset → user 1 bypass. - if auth_mode == "token" and auth_token is None: + # This disables authentication entirely (every request becomes the + # admin user), so it is gated behind an explicit ALLOW_NO_AUTH=true + # opt-in. Without that flag, an unset AUTH_TOKEN does not grant + # access: requests fall through to normal Bearer auth and + # unauthenticated callers get 401. + if auth_mode == "token" and auth_token is None and _allow_no_auth(): + logger.warning( + "ALLOW_NO_AUTH=true and AUTH_TOKEN is unset: authentication is DISABLED — " + "every request is treated as admin user 1. Never use this in production." + ) auth_service = self._get_auth_service(request) user = await auth_service.get_user_for_request(1) user_partitions = await auth_service.list_user_partitions_for_request(1) diff --git a/tests/unit/api/middleware/test_bypass_config.py b/tests/unit/api/middleware/test_bypass_config.py index 5af9bbcdf..bb886f12d 100644 --- a/tests/unit/api/middleware/test_bypass_config.py +++ b/tests/unit/api/middleware/test_bypass_config.py @@ -216,6 +216,67 @@ def test_is_bypass_path_bypass_config_is_keyword_only() -> None: is_bypass_path("/docs", AuthBypassConfig()) # type: ignore[misc] +# --------------------------------------------------------------------------- +# Dev bypass (AUTH_MODE=token, AUTH_TOKEN unset) requires ALLOW_NO_AUTH=true +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_dev_bypass_resolves_admin_when_allow_no_auth_set(monkeypatch) -> None: + """With ALLOW_NO_AUTH=true the no-token bypass resolves admin user 1.""" + from unittest.mock import AsyncMock + + monkeypatch.setenv("AUTH_MODE", "token") + monkeypatch.delenv("AUTH_TOKEN", raising=False) + monkeypatch.setenv("ALLOW_NO_AUTH", "true") + + svc = type("S", (), {})() + svc.get_user_for_request = AsyncMock(return_value={"id": 1, "display_name": "Admin"}) + svc.list_user_partitions_for_request = AsyncMock(return_value=[]) + + captured = {} + + async def call_next(req): + captured["user"] = req.state.user + return Response("ok") + + middleware = AuthMiddleware( + lambda scope, receive, send: None, get_auth_service=lambda _r: svc + ) + response = await middleware.dispatch(_request(), call_next) + + assert response.status_code == 200 + svc.get_user_for_request.assert_awaited_with(1) + assert captured["user"] == {"id": 1, "display_name": "Admin"} + + +@pytest.mark.asyncio +async def test_dev_bypass_does_not_fail_open_without_flag(monkeypatch) -> None: + """Without ALLOW_NO_AUTH a missing AUTH_TOKEN must NOT fail open to admin.""" + from unittest.mock import AsyncMock + + monkeypatch.setenv("AUTH_MODE", "token") + monkeypatch.delenv("AUTH_TOKEN", raising=False) + monkeypatch.delenv("ALLOW_NO_AUTH", raising=False) + + svc = type("S", (), {})() + svc.get_user_for_request = AsyncMock() + svc.list_user_partitions_for_request = AsyncMock() + svc.get_oidc_session_by_token_for_request = AsyncMock(return_value=None) + + async def call_next(req): + return Response("ok") + + middleware = AuthMiddleware( + lambda scope, receive, send: None, get_auth_service=lambda _r: svc + ) + response = await middleware.dispatch(_request(), call_next) + + # No token + no opt-in → not authenticated, never resolves to admin. + assert response.status_code in (401, 403) + svc.get_user_for_request.assert_not_awaited() + + def test_request_object_is_unused_by_helpers() -> None: """Sanity: ``is_ui_path`` / ``is_bypass_path`` are pure functions over the string path, so a FastAPI ``Request`` is never required From ed1fb2758fd04b65dc2cc8ac74654edc06c8933b Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:26:06 +0000 Subject: [PATCH 20/74] fix(security): whitelist writable fields in update_user (mass assignment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserUpdate used extra="allow" and the update payload flowed into the repo's broad column setter. A caller could set columns never meant to be writable via the update — e.g. token (overwrite a victim's auth token to a known value), file_count (bypass quota), or id. - core/models/user.py: switch UserCreate/UserUpdate to extra="ignore" so unknown fields are dropped at parse. - services/orchestrators/user_service.update_user: whitelist writable profile fields (display_name, external_user_id, email, is_admin, file_quota) before calling the repo. The repo's update_user still accepts token/file_count for internal callers (token rotation, quota), so the boundary guard lives in the service rather than tightening the shared repo method. Forward-ported from 202433d7 (main). Partial on arrival: the refactor already had a column whitelist in the repo, but it included token/file_count and the DTOs still allowed extra fields; this closes both. --- openrag/core/models/user.py | 6 +++-- .../services/orchestrators/user_service.py | 12 ++++++++++ .../orchestrators/test_user_service.py | 22 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/openrag/core/models/user.py b/openrag/core/models/user.py index 61d4f1de4..81bbeeb48 100644 --- a/openrag/core/models/user.py +++ b/openrag/core/models/user.py @@ -23,11 +23,13 @@ class UserBase(BaseModel): class UserCreate(UserBase): - model_config = ConfigDict(extra="allow") + # Reject unknown fields so callers cannot smuggle extra column names. + model_config = ConfigDict(extra="ignore") class UserUpdate(UserBase): - model_config = ConfigDict(extra="allow") + # Reject unknown fields; UserService additionally whitelists writable columns. + model_config = ConfigDict(extra="ignore") class UserPublic(UserBase): diff --git a/openrag/services/orchestrators/user_service.py b/openrag/services/orchestrators/user_service.py index 292e68567..59be84e46 100644 --- a/openrag/services/orchestrators/user_service.py +++ b/openrag/services/orchestrators/user_service.py @@ -42,6 +42,15 @@ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") _MAX_DISPLAY_NAME = 255 +# Fields a caller may set via update_user. Excludes identity/privilege and +# security-sensitive columns (token, file_count, id, created_at) so they can +# never be overwritten through the user-update payload (mass assignment). The +# repo's update_user accepts token/file_count for internal callers (token +# rotation, quota tracking), so the boundary whitelist lives here. +_UPDATABLE_USER_FIELDS = frozenset( + {"display_name", "external_user_id", "email", "is_admin", "file_quota"} +) + class UserService: """User account CRUD — validation + repo delegation.""" @@ -193,6 +202,9 @@ async def regenerate_token(self, user_id: int) -> dict: async def update_user(self, user_id: int, body: UserUpdate) -> dict: await self._ensure_exists(user_id) updates = body.model_dump(exclude_unset=True) + # Whitelist writable profile fields: never allow token, file_count or id + # to be set through the user-update payload (mass assignment). + updates = {k: v for k, v in updates.items() if k in _UPDATABLE_USER_FIELDS} self._validate_profile(updates.get("display_name"), updates.get("email")) user = await self._user_repo.update_user(user_id, **updates) diff --git a/tests/unit/services/orchestrators/test_user_service.py b/tests/unit/services/orchestrators/test_user_service.py index 4278b0029..0d7f7d8cf 100644 --- a/tests/unit/services/orchestrators/test_user_service.py +++ b/tests/unit/services/orchestrators/test_user_service.py @@ -257,6 +257,28 @@ async def test_update_user_validates_email(): await _svc(repo).update_user(2, UserUpdate(email="bogus")) +@pytest.mark.asyncio +async def test_update_user_drops_mass_assignment_fields(): + """token / file_count / other non-profile columns must never reach the repo + through the update payload (mass assignment).""" + repo = FakeUserRepo(existing={2}) + repo._users[2] = User(id=2, display_name="X", is_admin=False, file_quota=5, file_count=4) + # extra="ignore" drops unknown fields at parse; the service whitelist is the + # second line of defence. Both are exercised here. + body = UserUpdate.model_validate( + {"display_name": "X", "is_admin": True, "token": "evil", "file_count": 999, "id": 7} + ) + await _svc(repo).update_user(2, body) + assert repo.updated, "repo.update_user was not called" + _, fields = repo.updated[-1] + assert "token" not in fields + assert "file_count" not in fields + assert "id" not in fields + # whitelisted profile fields still pass through + assert fields.get("display_name") == "X" + assert fields.get("is_admin") is True + + # --------------------------------------------------------------------------- # # get_current_user_info — quota-usage block (8F: moved out of the router) # --------------------------------------------------------------------------- # From ce43f381dbe3a48da6ec3a899bed849fa01f544d Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:27:28 +0000 Subject: [PATCH 21/74] fix(users): coerce empty external_user_id to NULL to avoid unique-index collision (#121) An empty/whitespace external_user_id written to the unique index collides (Postgres permits many NULLs but only one empty string). Add a UserBase field_validator that coerces "" / whitespace to None. The refactor already normalized this on the create path in the repo (user_repo.create_user), but not on update; placing the validator on the shared UserBase DTO covers both UserCreate and UserUpdate. Forward-ported from edd2c7ce (main): openrag/models/user.py -> openrag/core/models/user.py. --- openrag/core/models/user.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openrag/core/models/user.py b/openrag/core/models/user.py index 81bbeeb48..c6fd37bf6 100644 --- a/openrag/core/models/user.py +++ b/openrag/core/models/user.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime from enum import Enum -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator # --------------------------------------------------------------------------- # Request DTOs — shared between the API layer (request bodies) and the @@ -21,6 +21,16 @@ class UserBase(BaseModel): is_admin: bool = False file_quota: int | None = Field(default=None) + @field_validator("external_user_id", mode="before") + @classmethod + def _empty_external_id_to_none(cls, v): + # Coerce "" / whitespace to NULL so it can't collide on the unique index + # (Postgres allows many NULLs but only one empty string). This covers the + # update path too, which the repo-level create normalization does not. + if isinstance(v, str) and not v.strip(): + return None + return v + class UserCreate(UserBase): # Reject unknown fields so callers cannot smuggle extra column names. From a4456ad0d3f159e802f2961461bb6ded33f078fc Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:30:06 +0000 Subject: [PATCH 22/74] fix(security): don't put session token in UI file URLs under OIDC (N13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source file links embedded the credential as ?token=, leaking it to browser history, proxy logs and Referer headers. In OIDC mode the browser already sends the openrag_session cookie on same-origin file fetches (the auth middleware checks the cookie first for /static), so the token query param is redundant — drop it there. Token mode keeps it (no cookie exists). Forward-ported from 229503b4 (main): openrag/app_front.py. Note: the sibling H7 commit 47b8cd32 (fail-fast on missing CHAINLIT_AUTH_SECRET) was NOT ported — the refactor already fail-fasts (stricter, #380/b63a9825) and guards it with a regression test; 47b8cd32 only re-adds an ALLOW_NO_AUTH-gated default-secret fallback, a loosening the refactor deliberately rejected. --- openrag/app_front.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openrag/app_front.py b/openrag/app_front.py index 9674d2f6a..462e573ac 100644 --- a/openrag/app_front.py +++ b/openrag/app_front.py @@ -262,7 +262,14 @@ async def _format_sources(metadata_sources, only_txt=False, api_key=None): filename = Path(s["filename"]) file_url = s["file_url"] file_url = file_url.replace(INTERNAL_BASE_URL, external_url) # put the correct base url - file_url = f"{file_url}?token={api_key}" # add token for authentication + # Avoid leaking the credential in the URL (browser history, proxy logs, + # Referer headers). In OIDC mode the browser already sends the + # openrag_session cookie on same-origin file fetches, which the auth + # middleware accepts — so the token query param is unnecessary. In token + # mode there is no such cookie, so it remains the only way for the + # browser to authenticate the fetch. + if AUTH_MODE != "oidc": + file_url = f"{file_url}?token={api_key}" page = s["page"] source_name = f"{filename}" + ( f" (page: {page})" if filename.suffix in [".pdf", ".pptx", ".docx", ".doc"] else "" From 1e927ee4d7035e68a475a771e73553b6fc977cda Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:36:24 +0000 Subject: [PATCH 23/74] test(infra): update compose-storage test for the N8 bind-mount removal The N8 fix (don't bind-mount source in prod) comments out the ../../openrag:/app/openrag dev mount, so the default compose no longer lists it. Assert it is absent rather than present. Follow-up to the forward-port of 645128dc (N8). --- tests/unit/infra/test_compose_storage.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/infra/test_compose_storage.py b/tests/unit/infra/test_compose_storage.py index 978309144..77f2abd33 100644 --- a/tests/unit/infra/test_compose_storage.py +++ b/tests/unit/infra/test_compose_storage.py @@ -35,7 +35,9 @@ def test_compose_defaults_preserve_existing_host_paths() -> None: assert "${DATA_VOLUME:-../../data}:/app/data" in openrag_volumes assert "${LOG_VOLUME:-../../logs}:/app/logs" in openrag_volumes assert "${MODEL_WEIGHTS_VOLUME:-~/.cache/huggingface}:/app/model_weights" in openrag_volumes - assert "../../openrag:/app/openrag" in openrag_volumes + # N8: the ../../openrag source bind-mount is commented out by default + # (dev-only) so production never lets host changes override the running code. + assert "../../openrag:/app/openrag" not in openrag_volumes assert "${DB_VOLUME:-../../db}:/var/lib/postgresql/data" in rdb_volumes assert {"appdata", "logs", "modelweights", "pgdata"} <= top_level_volumes From 89a3935e5674d16b2c74aecd9412216499162860 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:36:24 +0000 Subject: [PATCH 24/74] chore: sync uv.lock to version 1.1.13 and ruff-format user_service - uv.lock: bump the openrag package version pin to match pyproject (1.1.13). - user_service.py: ruff-format the _UPDATABLE_USER_FIELDS constant onto one line. --- openrag/services/orchestrators/user_service.py | 4 +--- uv.lock | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/openrag/services/orchestrators/user_service.py b/openrag/services/orchestrators/user_service.py index 59be84e46..d29eb0fd2 100644 --- a/openrag/services/orchestrators/user_service.py +++ b/openrag/services/orchestrators/user_service.py @@ -47,9 +47,7 @@ # never be overwritten through the user-update payload (mass assignment). The # repo's update_user accepts token/file_count for internal callers (token # rotation, quota tracking), so the boundary whitelist lives here. -_UPDATABLE_USER_FIELDS = frozenset( - {"display_name", "external_user_id", "email", "is_admin", "file_quota"} -) +_UPDATABLE_USER_FIELDS = frozenset({"display_name", "external_user_id", "email", "is_admin", "file_quota"}) class UserService: diff --git a/uv.lock b/uv.lock index dbb146d56..df22cdf1d 100644 --- a/uv.lock +++ b/uv.lock @@ -2641,7 +2641,7 @@ wheels = [ [[package]] name = "openrag" -version = "1.1.11" +version = "1.1.13" source = { editable = "." } dependencies = [ { name = "aiobreaker" }, From d04d8f286290b633af5c624790ca823405d8cd85 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:38:44 +0000 Subject: [PATCH 25/74] =?UTF-8?q?docs(forward-port):=20log=20the=20main?= =?UTF-8?q?=E2=86=92refactor=20security=20forward-port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record ported commits (source hash → new-layout target), skipped commits (already present / superseded / deliberately not loosened), and the remaining TODO queue (OIDC crypto, RAG/retrieval batch, deps, deferred compose non-root + docs) so the effort is auditable and resumable. --- FORWARD_PORT_LOG.md | 81 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 11 deletions(-) diff --git a/FORWARD_PORT_LOG.md b/FORWARD_PORT_LOG.md index e30c56c74..808536795 100644 --- a/FORWARD_PORT_LOG.md +++ b/FORWARD_PORT_LOG.md @@ -9,19 +9,78 @@ to the cutover re-implementation queue. --- -## Forward-ported (critical) +## `main` → `refactor/hexagonal` security forward-port (2026-06-24) + +Working branch: `forward-port/main-to-hexagonal` (off `origin/refactor/hexagonal`, +local only). Porting the 68 non-merge commits that landed on `main` after the +merge-base (`c9d53cc0`, 2026-05-21) — mostly a one-shot security-hardening audit +plus loader/OpenAI fixes, deploy hardening, deps and release chores. Each port is +one commit carrying the original subject/body + a `Forward-ported from ` +trailer. Order chosen by the user: package security code first, infra non-root + +docs last. + +### Ported + +| source (main) | subject | target location(s) | +|---|---|---| +| `c1079d7f` | Ray dashboard → localhost (compose) | `infra/compose/docker-compose.yaml` | +| `8914dfbb` | non-root Ray container (H4) | `infra/docker/ray.Dockerfile` | +| `8914dfbb`+`6860341c`+`26a70dbc`+`c0847d8f` | non-root OpenShift app image (consolidated — later commits rewrote earlier) | `infra/docker/api.Dockerfile` | +| `645128dc` | no prod bind-mount / gate --reload (N8) | `infra/compose/docker-compose.yaml`, `infra/scripts/entrypoint.sh` | +| `0e5687bc` | remove API_NUM_WORKERS footgun | `infra/scripts/entrypoint.sh`, `infra/compose/.env.example`, `infra/charts/.../values.yaml`, `docs/.../env_vars.md` | +| `319bc5cd` | drop API_NUM_WORKERS from doc env assets | `docs/assets/env_example.env`, `env_linux_gpu.env` | +| `c558dd1f` / `d69be1da` | version bump → 1.1.12 / 1.1.13 | `pyproject.toml` | +| `2af1d76f` | harden Ansible deploy (#488) | `infra/ansible/ansible.cfg`, `playbooks/openrag.yml` | +| `34dc3a2f` | Ray dashboard localhost default (C3) | `openrag/api/main.py`, `openrag/api/mcp/server.py`, `infra/cluster.yaml`, `infra/quick_start/docker-compose.yaml`, `docs/assets/compose_ollama_cpu.yaml` | +| `aa015bdd` | external Ray cluster via RAY_ADDRESS | `openrag/api/main.py`, `openrag/api/mcp/server.py`, `.env.example`, docs | +| `0515f705` | require MinIO creds (H3) | `infra/compose/milvus/milvus.yaml`, `infra/quick_start/vdb/milvus.yaml`, `.env.example` | +| `0164b829` | remove weak DB/AUTH defaults (M2/M3) | `conf/config.yaml`, compose stacks, `docs/assets/compose_ollama_cpu.yaml`, `.env.example` | +| `b8002ef0` | drop seccomp:unconfined (N11) | `infra/compose/milvus/milvus.yaml` + `.named-volumes.yaml`, `infra/quick_start/vdb/milvus.yaml` | +| `24efaa66` | Helm DB password → Secret (N7) | `infra/charts/openrag-stack/values.yaml` | +| `c8f2d47f` | default-deny NetworkPolicy (N12) | `infra/charts/.../templates/networkpolicy.yaml` (new), `values.yaml` | +| `5caec50b` | restrict metrics exposure (N9) | `infra/compose/monitoring.docker-compose.yaml` | +| `d7fc3130` | pin image tags (N10) | `infra/charts/.../values.yaml` (openrag-owned → 1.1.13), `infra/compose/monitoring.docker-compose.yaml` (third-party pins). Compose app-image pins skipped (reverted by 64c3e722). | +| `7bc46696` | require ALLOW_NO_AUTH for no-token admin bypass | `openrag/api/middleware/auth.py`, `.env.example`, `tests/unit/api/middleware/test_bypass_config.py` | +| `202433d7` | update_user mass-assignment whitelist | `openrag/core/models/user.py` (extra="ignore"), `openrag/services/orchestrators/user_service.py` (whitelist), tests | +| `edd2c7ce` | external_user_id empty→NULL (#121) | `openrag/core/models/user.py` (validator; covers update path the repo missed) | +| `229503b4` | no session token in UI file URLs (N13) | `openrag/app_front.py` | + +### Skipped (already present / superseded on refactor) + +| source (main) | reason | +|---|---| +| `f9e0c394` | /chainlit path-boundary anchor already in `api/middleware/auth.py` | +| `9b82996c` | azp on multi-aud tokens already in `services/auth/oidc_client.py` | +| `64c3e722` | compose `:latest` tags already the refactor state | +| `f9b8a776` | de-flake `test_cancel_*` superseded by refactor `cca5f415` | +| `47b8cd32` | refactor already fail-fasts on missing CHAINLIT_AUTH_SECRET (stricter, #380/`b63a9825`, with a regression test). 47b8cd32 only re-adds an ALLOW_NO_AUTH-gated default-secret fallback — a loosening intentionally NOT ported. | + +### Remaining (TODO — not yet ported) -_Security fixes, data-loss bugs, production outages re-implemented against -the new architecture. Each entry pairs the dev commit with the refactor -commit so a reviewer can audit equivalence._ +**Batch 3 — OIDC crypto (services/auth/oidc_client.py, api/routers/auth/oidc.py):** +- `2b34a0d1` clock-skew leeway + nbf check → `openrag/services/auth/oidc_client.py` +- `97c624ef` back-channel logout exp/jti + replay cache → `services/auth/oidc_client.py` + `api/routers/auth/oidc.py` (+ test `cdb3edc9`) +- `c2fde135` logout CSRF (Sec-Fetch / Fetch-Metadata) → `api/routers/auth/oidc.py` +- `73acb1c9` next-URL backslash/CRLF reject + userinfo-sub bind to ID token → `api/routers/auth/oidc.py`, `services/auth` +- `9a73200a` stop admin token rotation on startup + revoke OIDC sessions on token regen → `services/persistence/user_repo.py`, `services/auth` -(none yet) +**Batch 4 — RAG / retrieval / loaders / OpenAI (~21 commits):** +`714f2a84` streaming finish_reason → `core/utils/source_filtering.py`; `bf4ae134` + `f079efa5` Milvus filter-injection guards → `services/storage/*`, `api/routers/*` + `api/dependencies`; `db92875d` llm_override credential strip → `services/inference/vllm_client.py` + `api/schemas/user/chat.py`; `67ec4199` source-download authz (partial — `/static` mount already gone) → `api/routers/user/source_links.py`; `86c9b51d` ensure_partition_role fail-open → `api/dependencies/auth.py`; `8ecbc781` web-search SSRF/MITM → `services/websearch/content_fetcher.py`; `e3c7eac2` + `81bccf08` control-token neutralizer → `core/utils/` + sources-tag parser; `818d5446` stack-trace leak → `api/routers/admin/indexing.py`; `54165900` token-limit + n/best_of bounds → `api/schemas/user/chat.py` + `api/routers/user/chat.py`; `6bc898e9` surrounding-chunk partition scope → `services/storage/vector_store_searcher.py`; `63a857af` image-URL SSRF → parsers; `8ea723ca` SVG external-fetch guard → image parser; `70a2db36` CustomDocLoader page accumulation (#376) → `services/workers/parsers/legacy_loaders/`; `199424bf` empty-stream 502 (#363) → `api/routers/user/chat.py`; `0bc6157e` stop logging raw query (#481) → `api/routers/user/search.py`; `761f47a0` copy-endpoint source_file_id validation (#477); `221f8ed8` parser DoS caps (M8) → `core/config/indexation.py` + parsers; `d66cf029` cap partitions per user (M13); `52be26f1` non-empty RAG answer → `openrag/prompts/templates/`. -## Deferred to cutover (features) +**Batch 2 — deps:** `74de8232` Starlette/FastAPI bump, `4d8bca01` `limits` not slowapi, `0e6e7836` rate-limit module + tests (`pyproject.toml`, `uv.lock`, new `openrag/...rate_limit`, `.env.example`). + +**Deferred (user-requested, do last):** `4bbefd41` compose non-root rework (init-perms + per-service `user:` — needs careful adaptation to `infra/compose/` paths), `701fcf9e` + `8849fe7d` docs moves, `563907ad` doc comment trim, plus a final `ruff format`/`check` pass. + +> Note: the suite shows one pre-existing **false-alarm** failure, +> `test_seed_defaults_preserves_endpoint_api_keys` — `seed_defaults` reads +> `os.getenv("API_KEY", ...)` and this worktree is nested under the main checkout, +> so `load_dotenv()` walks up and picks up the real `.env`. Not caused by this +> port (passes from a checkout outside the main tree). Everything else: 1317 pass. + +--- + +## Forward-ported (critical) -_Non-critical changes that landed on `dev` during MODE 2. These will be -re-implemented directly in the new architecture during MODE 3 (Phases -10-12) or post-cutover. List the dev PR number / commit and the target -location in the new layout._ +_(legacy template section — superseded by the dated section above.)_ -(none yet) +(none recorded under the original Phase 5–9 process) From 1196e52fb64ff9be4066d7aac61f135fc58d8794 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:55:56 +0000 Subject: [PATCH 26/74] fix(security): require exp + jti and block replay on back-channel logout (M9) OIDC back-channel logout tokens are short-lived single-use tokens, but the verifier defaulted a missing exp to "now + 1" (so an absent exp never expired) and ignored jti entirely. - verify_logout_token now requires exp (and rejects expired tokens) and requires jti, both per the OIDC back-channel logout spec; exp is surfaced on LogoutTokenClaims. - AuthService.handle_backchannel_logout records consumed jti -> exp in a pruned in-process cache and rejects replays (defence-in-depth; logout is idempotent so this complements rather than replaces the DB revocation). The refactor routes the handler through the service, so the cache lives there (not the router as on main). Forward-ported from 97c624ef (main): openrag/components/auth/oidc_client.py -> services/auth/oidc_client.py; routers/auth.py replay cache -> AuthService. The test follow-up cdb3edc9 (exp on the logout test token) is subsumed: the refactor's service tests use a fake client, and the added replay test carries exp. --- openrag/services/auth/oidc_client.py | 11 +++++++- .../services/orchestrators/auth_service.py | 27 +++++++++++++++++++ .../orchestrators/test_auth_service.py | 26 ++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/openrag/services/auth/oidc_client.py b/openrag/services/auth/oidc_client.py index 0a7604a1c..2f7ce4974 100644 --- a/openrag/services/auth/oidc_client.py +++ b/openrag/services/auth/oidc_client.py @@ -50,6 +50,7 @@ class LogoutTokenClaims: sid: str | None iat: int jti: str | None + exp: int = 0 class OIDCClient: @@ -351,8 +352,15 @@ async def verify_logout_token(self, token: str) -> LogoutTokenClaims: if "iat" not in decoded: raise ValueError("logout_token missing iat claim") - if int(decoded.get("exp", now + 1)) < now: + # exp is REQUIRED by the OIDC back-channel logout spec; do not default + # it, or an absent exp would make the token never expire. + if "exp" not in decoded: + raise ValueError("logout_token missing exp claim") + if int(decoded["exp"]) < now: raise ValueError("logout_token has expired") + # jti is REQUIRED and is what enables replay detection by the caller. + if not decoded.get("jti"): + raise ValueError("logout_token missing jti claim") events = decoded.get("events") or {} if "http://schemas.openid.net/event/backchannel-logout" not in events: @@ -371,6 +379,7 @@ async def verify_logout_token(self, token: str) -> LogoutTokenClaims: sid=decoded.get("sid"), iat=int(decoded["iat"]), jti=decoded.get("jti"), + exp=int(decoded["exp"]), ) # ------------------------------------------------------------------ diff --git a/openrag/services/orchestrators/auth_service.py b/openrag/services/orchestrators/auth_service.py index 74ebc4dcd..edde94c97 100644 --- a/openrag/services/orchestrators/auth_service.py +++ b/openrag/services/orchestrators/auth_service.py @@ -18,6 +18,7 @@ from __future__ import annotations import os +import time from dataclasses import dataclass from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any @@ -68,6 +69,27 @@ def __init__( self.error_description = error_description +# In-process replay cache for back-channel logout token jti values, mapping +# jti -> token exp. A logout token may only be consumed once (OIDC spec). This +# is per-worker; with multiple workers a replay could still reach a different +# worker, but back-channel logout is idempotent (re-revoking sessions is a +# no-op), so this is a defence-in-depth guard, not the sole protection. +_seen_logout_jti: dict[str, int] = {} + + +def _logout_jti_is_replay(jti: str, exp: int) -> bool: + """Record a logout-token jti and report whether it was already seen.""" + now = int(time.time()) + # Prune expired entries to bound memory. + for old_jti, old_exp in list(_seen_logout_jti.items()): + if old_exp < now: + del _seen_logout_jti[old_jti] + if jti in _seen_logout_jti: + return True + _seen_logout_jti[jti] = exp + return False + + @dataclass class LoginRedirect: """Everything the router needs to start the Authorization Code flow.""" @@ -253,6 +275,11 @@ async def handle_backchannel_logout(self, logout_token: str) -> int: logger.warning(f"Back-channel logout token verification failed: {e}") raise OIDCFlowError("invalid_request") from e + # Reject replays: a given logout token (jti) must only be processed once. + if claims.jti and _logout_jti_is_replay(claims.jti, claims.exp): + logger.warning(f"Replayed back-channel logout token ignored — jti={claims.jti!r}") + raise OIDCFlowError("logout_token replayed", error_description="logout_token replayed") + if claims.sid: count = await self._oidc_session_repo.revoke_by_sid(claims.sid) logger.info(f"Back-channel logout revoked sessions — sid={claims.sid!r}, count={count}") diff --git a/tests/unit/services/orchestrators/test_auth_service.py b/tests/unit/services/orchestrators/test_auth_service.py index 5fe176f2d..db1ede326 100644 --- a/tests/unit/services/orchestrators/test_auth_service.py +++ b/tests/unit/services/orchestrators/test_auth_service.py @@ -443,6 +443,32 @@ async def test_backchannel_logout_sidless_is_noop_200(): assert srepo.revoked_sids == [] +@pytest.mark.asyncio +async def test_backchannel_logout_rejects_replayed_jti(): + import time as _time + + from services.orchestrators import auth_service as _as + + _as._seen_logout_jti.clear() + srepo = FakeSessionRepo() + claims = LogoutTokenClaims( + iss="i", + aud="openrag", + sub="s", + sid="sess-9", + iat=0, + jti="jti-replay-1", + exp=int(_time.time()) + 120, + ) + svc = _service(session_repo=srepo, client=FakeOIDCClient(logout_claims=claims)) + # First processing succeeds and revokes the session(s). + assert await svc.handle_backchannel_logout("tok") == 3 + # Re-processing the same jti is rejected as a replay. + with pytest.raises(OIDCFlowError) as ei: + await svc.handle_backchannel_logout("tok") + assert ei.value.error_description == "logout_token replayed" + + @pytest.mark.asyncio async def test_logout_revokes_and_builds_end_session_url(): # Seed a real session via the callback path so the stored id_token is From eea34bb7a3db07919ac48a8335288624685e0cb2 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:56:28 +0000 Subject: [PATCH 27/74] fix(security): add clock-skew leeway and nbf check to OIDC JWT verification (crypto) ID-token (and logout-token) exp was checked with zero tolerance and nbf was not validated at all. Add a 60s clock-skew leeway to the exp checks and honour nbf (reject tokens whose not-before is in the future beyond the leeway). Forward-ported from 2b34a0d1 (main): services/auth/oidc_client.py. --- openrag/services/auth/oidc_client.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/openrag/services/auth/oidc_client.py b/openrag/services/auth/oidc_client.py index 2f7ce4974..aa8478115 100644 --- a/openrag/services/auth/oidc_client.py +++ b/openrag/services/auth/oidc_client.py @@ -27,6 +27,10 @@ from authlib.jose import JsonWebKey, JsonWebToken from authlib.jose.errors import JoseError +# Clock-skew tolerance (seconds) applied to time-based JWT claims (exp/nbf) so +# small drift between the IdP and this host doesn't reject valid tokens. +_CLOCK_SKEW_LEEWAY = 60 + @dataclass class TokenBundle: @@ -298,9 +302,15 @@ async def _verify_id_token(self, token: str, *, expected_nonce: str | None) -> d if "exp" not in decoded: raise ValueError("ID token missing exp claim") - if int(decoded["exp"]) < now: + # Allow a small clock-skew leeway so a few seconds of drift between the + # IdP and this host doesn't spuriously reject otherwise-valid tokens. + if int(decoded["exp"]) < now - _CLOCK_SKEW_LEEWAY: raise ValueError("ID token has expired") + # Honour nbf (not-before) if present, with the same leeway. + if "nbf" in decoded and int(decoded["nbf"]) > now + _CLOCK_SKEW_LEEWAY: + raise ValueError("ID token not yet valid (nbf in the future)") + if "iat" not in decoded: raise ValueError("ID token missing iat claim") @@ -356,7 +366,7 @@ async def verify_logout_token(self, token: str) -> LogoutTokenClaims: # it, or an absent exp would make the token never expire. if "exp" not in decoded: raise ValueError("logout_token missing exp claim") - if int(decoded["exp"]) < now: + if int(decoded["exp"]) < now - _CLOCK_SKEW_LEEWAY: raise ValueError("logout_token has expired") # jti is REQUIRED and is what enables replay detection by the caller. if not decoded.get("jti"): From 8080944b29840ea689e3c8e28bbc71d2d2d01218 Mon Sep 17 00:00:00 2001 From: EnjoyBacon7 <59032058+EnjoyBacon7@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:57:42 +0000 Subject: [PATCH 28/74] fix(security): block cross-site logout CSRF (N3) GET /auth/logout is state-changing (revokes the session) and cookie-authed, so a forged request (e.g. ) could force-logout a user. It must stay a GET to support the OIDC RP-initiated logout redirect, so add a Fetch-Metadata guard that rejects cross-site non-navigation requests (the silent CSRF vector) while allowing genuine top-level navigations and same-origin calls. Forward-ported from c2fde135 (main): routers/auth.py -> api/routers/auth/oidc.py. --- openrag/api/routers/auth/oidc.py | 25 +++++++++++++++++++++++++ tests/unit/test_auth_router.py | 24 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/openrag/api/routers/auth/oidc.py b/openrag/api/routers/auth/oidc.py index ddae2109f..770829241 100644 --- a/openrag/api/routers/auth/oidc.py +++ b/openrag/api/routers/auth/oidc.py @@ -182,12 +182,37 @@ async def backchannel_logout( # --------------------------------------------------------------------------- +def _is_csrf_safe_navigation(request: Request) -> bool: + """Reject the silent logout-CSRF vector while keeping the redirect-based UX. + + Logout must remain reachable via top-level GET navigation (the OIDC + RP-initiated logout redirects the browser to the IdP), so we can't simply + require POST. Instead we use the Fetch Metadata headers: a forged request + from ````/``