Prevent source files from being modified by the Django user (#10575)

Currently, all source files are located in `/home/django`, and owned by
`django`. This means that if there's any vulnerability that lets an
attacker overwrite files in the server, they can replace source files
with their own code, and potentially get that code executed. That's
pretty bad, so I want to harden against that.

Make all source files owned by root, and move them to `/opt/cvat`. Add a
`manage.py` symlink in `/home/django` for backwards compatibility. It
happens that if a script is a symlink, Python does not add the symlink's
directory to `sys.path`, which is great for us, since that lets us avoid
a writable directory on there.

Still, even though `/home/django/manage.py` is owned by root, an
attacker could potentially be able to delete it and replace it with
their own malicious file. To be a bit more safe, replace `~/manage.py`
calls in backend scripts with `django-admin`.

To make sure CVAT can still find the data directory, add a new
environment variable, `CVAT_BASE_DIR` and set it in the Docker image.

This also fixes a minor bug: we no longer override the `HOME`
environment variable in the `Dockerfile`, so now it's automatically set
by `Docker` depending on the current user.
This commit is contained in:
Roman Donchenko
2026-07-23 13:22:24 +03:00
committed by GitHub
parent 69cdda3e75
commit 4aa0be3df5
18 changed files with 63 additions and 47 deletions
+2 -2
View File
@@ -2,10 +2,10 @@
branch = true
source =
cvat/apps/
cvat.apps
utils.dataset_manifest
cvat-sdk/
cvat-cli/
utils/dataset_manifest
omit =
cvat/settings/*
+1 -1
View File
@@ -221,7 +221,7 @@ jobs:
docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.ci.yml \
run --env PYTHONDEVMODE=1 cvat_ci /bin/bash \
-c 'python manage.py test cvat/apps -v 2'
-c 'python manage.py test cvat.apps -v 2'
- name: Creating a log file from cvat containers
if: failure()
+1 -1
View File
@@ -288,7 +288,7 @@ jobs:
docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.ci.yml \
run --env PYTHONDEVMODE=1 cvat_ci /bin/bash \
-c 'coverage run -a manage.py test -v 2 cvat/apps && coverage json && mv coverage.json ${CONTAINER_COVERAGE_DATA_DIR}/unit_tests_coverage.json'
-c 'coverage run -a manage.py test -v 2 cvat.apps && coverage json && mv coverage.json ${CONTAINER_COVERAGE_DATA_DIR}/unit_tests_coverage.json'
- name: Uploading code coverage results as an artifact
uses: actions/upload-artifact@v7
+1 -1
View File
@@ -170,7 +170,7 @@ jobs:
docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.ci.yml \
run --env PYTHONDEVMODE=1 cvat_ci /bin/bash \
-c 'python manage.py test cvat/apps -v 2'
-c 'python manage.py test cvat.apps -v 2'
docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.ci.yml down -v
+20 -9
View File
@@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1
ARG BASE_IMAGE=ubuntu:24.04
FROM ${BASE_IMAGE} AS build-image-base
@@ -144,9 +146,9 @@ COPY --from=build-smokescreen /tmp/smokescreen /usr/local/bin/smokescreen
# Add a non-root user
ENV USER=${USER}
ENV HOME /home/${USER}
RUN deluser --remove-home ubuntu && \
adduser --uid=1000 --shell /bin/bash --disabled-password --gecos "" ${USER}
ENV CVAT_BASE_DIR=/home/${USER}
ARG CLAM_AV="no"
RUN if [ "$CLAM_AV" = "yes" ]; then \
@@ -186,12 +188,21 @@ RUN python -m pip uninstall -y pip
# Install and initialize CVAT, copy all necessary files
COPY cvat/nginx.conf /etc/nginx/nginx.conf
COPY --chown=${USER} supervisord/ ${HOME}/supervisord
COPY --chown=${USER} backend_entrypoint.d/ ${HOME}/backend_entrypoint.d
COPY --chown=${USER} manage.py rqscheduler.py backend_entrypoint.sh wait_for_deps.sh ${HOME}/
COPY --chown=${USER} utils/ ${HOME}/utils
COPY --chown=${USER} cvat/ ${HOME}/cvat
COPY --chown=${USER} components/analytics/clickhouse/init.py ${HOME}/components/analytics/clickhouse/init.py
COPY --parents \
backend_entrypoint.d cvat supervisord utils \
backend_entrypoint.sh \
components/analytics/clickhouse/init.py \
manage.py \
rqscheduler.py \
wait_for_deps.sh \
/opt/cvat/
RUN python -m compileall -q /opt/cvat
# Link manage.py to the home directory for backwards compatibility.
RUN ln -s /opt/cvat/manage.py ${CVAT_BASE_DIR}/manage.py
RUN echo "/opt/cvat" > /opt/venv/lib/python3.12/site-packages/cvat.pth
ARG COVERAGE_PROCESS_START
RUN if [ "${COVERAGE_PROCESS_START}" ]; then \
@@ -201,9 +212,9 @@ RUN if [ "${COVERAGE_PROCESS_START}" ]; then \
# RUN all commands below as 'django' user.
# Use numeric UID/GID so that the image is compatible with the Kubernetes runAsNonRoot setting.
USER 1000:1000
WORKDIR ${HOME}
WORKDIR ${CVAT_BASE_DIR}
RUN mkdir -p data share keys logs /tmp/supervisord /tmp/cvat static
EXPOSE 8080
ENTRYPOINT ["./backend_entrypoint.sh"]
ENTRYPOINT ["/opt/cvat/backend_entrypoint.sh"]
+13 -11
View File
@@ -2,6 +2,8 @@
set -eu
SCRIPT_DIR="$(dirname "$0")"
fail() {
printf >&2 "%s: %s\n" "$0" "$1"
exit 1
@@ -37,21 +39,21 @@ cmd_bash() {
cmd_init() {
wait_for_db
~/manage.py migrate
django-admin migrate
wait_for_redis_inmem
~/manage.py migrateredis
~/manage.py syncperiodicjobs
django-admin migrateredis
django-admin syncperiodicjobs
if [[ "${CVAT_ANALYTICS:-0}" == "1" ]]; then
wait_for_clickhouse
python components/analytics/clickhouse/init.py
python "$SCRIPT_DIR/components/analytics/clickhouse/init.py"
fi
}
_load_component_config() {
declare -gA merged_config=()
for config_file in ~/backend_entrypoint.d/*.conf; do
for config_file in "$SCRIPT_DIR/backend_entrypoint.d/"*.conf; do
declare -A config=$(cat $config_file)
for key in "${!config[@]}"; do
if [[ -n ${merged_config[$key]+_} ]]; then
@@ -86,7 +88,7 @@ _get_includes() {
_get_reusable_includes() {
extra_configs=()
for include in "$@"; do
if ! [ -r "$HOME/supervisord/reusable/$include.conf" ]; then
if ! [ -r "$SCRIPT_DIR/supervisord/reusable/$include.conf" ]; then
fail "Unexpected supervisor include: $include"
fi
@@ -114,24 +116,24 @@ cmd_run() {
esac
if [ "$component" = "nginx" ]; then
exec supervisord -c "supervisord/nginx.conf"
exec supervisord -c "$SCRIPT_DIR/supervisord/nginx.conf"
fi
if [ "$component" = "server" ]; then
account_for_internal_proxy
~/manage.py collectstatic --no-input
django-admin collectstatic --no-input
fi
wait_for_db
echo "waiting for migrations to complete..."
while ! ~/manage.py migrate --check; do
while ! django-admin migrate --check; do
sleep 10
done
wait_for_redis_inmem
echo "waiting for Redis migrations to complete..."
while ! ~/manage.py migrateredis --check; do
while ! django-admin migrateredis --check; do
sleep 10
done
@@ -175,7 +177,7 @@ cmd_run() {
export CVAT_POSTGRES_APPLICATION_NAME=$postgres_app_name
export CVAT_SUPERVISORD_INCLUDES=$supervisord_includes
exec supervisord -c "supervisord/$component.conf"
exec supervisord -c "$SCRIPT_DIR/supervisord/$component.conf"
}
if [ $# -eq 0 ]; then
@@ -0,0 +1,5 @@
### Changed
- In the backend server image, CVAT source files are now installed in
`/opt/cvat` rather than `/home/django`, and owned by root
(<https://github.com/cvat-ai/cvat/pull/10575>)
+1 -1
View File
@@ -32,7 +32,7 @@ from cvat import __version__
from cvat.apps.iam.password_validation import DEFAULT_MIN_PASSWORD_LENGTH
# Build paths inside the project like this: BASE_DIR / ...
BASE_DIR = Path(__file__).parents[2]
BASE_DIR = Path(os.environ.get("CVAT_BASE_DIR", Path(__file__).parents[2]))
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
INTERNAL_IPS = ["127.0.0.1"]
+1 -2
View File
@@ -218,8 +218,7 @@ The name of the service account to use for backend pods
livenessProbe:
exec:
command:
- python
- manage.py
- django-admin
- workerprobe
{{- range .args }}
- {{ . | quote }}
@@ -29,7 +29,7 @@ services:
environment:
DJANGO_SETTINGS_MODULE: settings
volumes:
- ./settings.py:/home/django/settings.py:ro
- ./settings.py:/opt/cvat/settings.py:ro
```
### Active Directory Example
@@ -82,7 +82,7 @@ docker run -it --rm -u "$(id -u)":"$(id -g)" \
-v "${PWD}":"/local" \
--entrypoint python3 \
cvat/server \
utils/dataset_manifest/create.py --output-dir /local /local/<path/to/sources>
/opt/cvat/utils/dataset_manifest/create.py --output-dir /local /local/<path/to/sources>
```
Make sure to adapt the command to your file locations.
@@ -151,10 +151,10 @@ Create a dataset manifest using Docker image:
```bash
docker run -it --rm -u "$(id -u)":"$(id -g)" \
-v ~/Documents/data/:${HOME}/manifest/:rw \
--entrypoint '/usr/bin/bash' \
-v ~/Documents/data/:/mnt/manifest/:rw \
--entrypoint python3 \
cvat/server \
utils/dataset_manifest/create.py --output-dir ~/manifest/ ~/manifest/images/
/opt/cvat/utils/dataset_manifest/create.py --output-dir /mnt/manifest/ /mnt/manifest/images/
```
### File format
+2 -3
View File
@@ -1,11 +1,10 @@
[program:rqscheduler]
command=%(ENV_HOME)s/wait_for_deps.sh
python3 %(ENV_HOME)s/rqscheduler.py
command=/opt/cvat/wait_for_deps.sh
python3 /opt/cvat/rqscheduler.py
--host "%(ENV_CVAT_REDIS_INMEM_HOST)s"
--port "%(ENV_CVAT_REDIS_INMEM_PORT)s"
--password "%(ENV_CVAT_REDIS_INMEM_PASSWORD)s"
-i 30
--path %(ENV_HOME)s
environment=VECTOR_EVENT_HANDLER="SynchronousLogstashHandler"
numprocs=1
autorestart=true
+2 -2
View File
@@ -3,8 +3,8 @@ files = reusable/supervisord.conf %(ENV_CVAT_SUPERVISORD_INCLUDES)s
[fcgi-program:uvicorn]
socket=unix:///tmp/cvat/uvicorn.sock
command=%(ENV_HOME)s/wait_for_deps.sh
python3 -m uvicorn
command=/opt/cvat/wait_for_deps.sh
uvicorn
--fd 0
--forwarded-allow-ips='*'
cvat.asgi:application
+2 -2
View File
@@ -2,8 +2,8 @@
files = reusable/supervisord.conf %(ENV_CVAT_SUPERVISORD_INCLUDES)s
[program:rqworker-pool]
command=%(ENV_HOME)s/wait_for_deps.sh
python3 %(ENV_HOME)s/manage.py rqworker-pool -v 3 %(ENV_CVAT_QUEUES)s
command=/opt/cvat/wait_for_deps.sh
django-admin rqworker-pool -v 3 %(ENV_CVAT_QUEUES)s
--worker-class cvat.rqworker.DefaultWorker --num-workers %(ENV_NUMWORKERS)s %(ENV_CVAT_RQWORKER_EXTRA_FLAGS)s
environment=VECTOR_EVENT_HANDLER="SynchronousLogstashHandler"
numprocs=%(ENV_NUMPROCS)s
+2 -2
View File
@@ -2,8 +2,8 @@
files = reusable/supervisord.conf %(ENV_CVAT_SUPERVISORD_INCLUDES)s
[program:rqworker]
command=%(ENV_HOME)s/wait_for_deps.sh
python3 %(ENV_HOME)s/manage.py rqworker -v 3 %(ENV_CVAT_QUEUES)s
command=/opt/cvat/wait_for_deps.sh
django-admin rqworker -v 3 %(ENV_CVAT_QUEUES)s
--worker-class cvat.rqworker.DefaultWorker %(ENV_CVAT_RQWORKER_EXTRA_FLAGS)s
environment=VECTOR_EVENT_HANDLER="SynchronousLogstashHandler"
numprocs=%(ENV_NUMPROCS)s
+2 -2
View File
@@ -5,10 +5,10 @@ sigterm = true
concurrency=thread
source =
cvat/apps/
cvat.apps
utils.dataset_manifest
cvat-sdk/
cvat-cli/
utils/dataset_manifest
omit =
cvat/settings/*
+2 -2
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
CVAT_ROOT_DIR = next(dir.parent for dir in Path(__file__).parents if dir.name == "tests")
CVAT_DB_DIR = ASSETS_DIR / "cvat_db"
CLICKHOUSE_INIT_SCRIPT = "components/analytics/clickhouse/init.py"
CLICKHOUSE_INIT_SCRIPT = "/opt/cvat/components/analytics/clickhouse/init.py"
PREFIX = "test"
CONTAINER_NAME_FILES = ["docker-compose.tests.yml"]
@@ -579,7 +579,7 @@ def session_finish(session):
def collect_code_coverage_from_containers():
for container in Container.covered():
process_command = "python3"
process_command = "python"
# find process with code coverage
pid = docker_exec(container, f"pidof {process_command} -o 1")
+1 -1
View File
@@ -113,7 +113,7 @@ def generate_manifest(path: str) -> None:
"--entrypoint",
"python3",
get_server_image_tag(),
"utils/dataset_manifest/create.py",
"/opt/cvat/utils/dataset_manifest/create.py",
"--output-dir",
"/local",
"/local",