Merge pull request #1838 from RinZ27/fix-nginx-header-inheritance

Ensure security headers are preserved in /files location
This commit is contained in:
RocketQuack 2026-03-18 10:04:26 +01:00 committed by GitHub
commit 3ce80020b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 64 additions and 21 deletions

View file

@ -63,6 +63,7 @@ RUN useradd -ms /bin/bash frappe \
# Clean up # Clean up
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& rm -fr /etc/nginx/sites-enabled/default \ && rm -fr /etc/nginx/sites-enabled/default \
&& mkdir -p /etc/nginx/snippets \
&& pip3 install frappe-bench \ && pip3 install frappe-bench \
# Fixes for non-root nginx and logs to stdout # Fixes for non-root nginx and logs to stdout
&& sed -i '/user www-data/d' /etc/nginx/nginx.conf \ && sed -i '/user www-data/d' /etc/nginx/nginx.conf \
@ -70,12 +71,15 @@ RUN useradd -ms /bin/bash frappe \
&& touch /run/nginx.pid \ && touch /run/nginx.pid \
&& chown -R frappe:frappe /etc/nginx/conf.d \ && chown -R frappe:frappe /etc/nginx/conf.d \
&& chown -R frappe:frappe /etc/nginx/nginx.conf \ && chown -R frappe:frappe /etc/nginx/nginx.conf \
&& chown -R frappe:frappe /etc/nginx/snippets \
&& chown -R frappe:frappe /var/log/nginx \ && chown -R frappe:frappe /var/log/nginx \
&& chown -R frappe:frappe /var/lib/nginx \ && chown -R frappe:frappe /var/lib/nginx \
&& chown -R frappe:frappe /run/nginx.pid \ && chown -R frappe:frappe /run/nginx.pid \
&& chmod 755 /usr/local/bin/nginx-entrypoint.sh \ && chmod 755 /usr/local/bin/nginx-entrypoint.sh \
&& chmod 644 /templates/nginx/frappe.conf.template && chmod 644 /templates/nginx/frappe.conf.template
COPY resources/core/nginx/security_headers.conf /etc/nginx/snippets/security_headers.conf
FROM base AS builder FROM base AS builder
RUN apt-get update \ RUN apt-get update \

View file

@ -60,6 +60,7 @@ RUN useradd -ms /bin/bash frappe \
# Clean up # Clean up
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& rm -fr /etc/nginx/sites-enabled/default \ && rm -fr /etc/nginx/sites-enabled/default \
&& mkdir -p /etc/nginx/snippets \
&& pip3 install frappe-bench \ && pip3 install frappe-bench \
# Fixes for non-root nginx and logs to stdout # Fixes for non-root nginx and logs to stdout
&& sed -i '/user www-data/d' /etc/nginx/nginx.conf \ && sed -i '/user www-data/d' /etc/nginx/nginx.conf \
@ -67,12 +68,14 @@ RUN useradd -ms /bin/bash frappe \
&& touch /run/nginx.pid \ && touch /run/nginx.pid \
&& chown -R frappe:frappe /etc/nginx/conf.d \ && chown -R frappe:frappe /etc/nginx/conf.d \
&& chown -R frappe:frappe /etc/nginx/nginx.conf \ && chown -R frappe:frappe /etc/nginx/nginx.conf \
&& chown -R frappe:frappe /etc/nginx/snippets \
&& chown -R frappe:frappe /var/log/nginx \ && chown -R frappe:frappe /var/log/nginx \
&& chown -R frappe:frappe /var/lib/nginx \ && chown -R frappe:frappe /var/lib/nginx \
&& chown -R frappe:frappe /run/nginx.pid && chown -R frappe:frappe /run/nginx.pid
COPY resources/core/nginx/nginx-template.conf /templates/nginx/frappe.conf.template COPY resources/core/nginx/nginx-template.conf /templates/nginx/frappe.conf.template
COPY resources/core/nginx/nginx-entrypoint.sh /usr/local/bin/nginx-entrypoint.sh COPY resources/core/nginx/nginx-entrypoint.sh /usr/local/bin/nginx-entrypoint.sh
COPY resources/core/nginx/security_headers.conf /etc/nginx/snippets/security_headers.conf
FROM base AS build FROM base AS build

View file

@ -21,11 +21,7 @@ server {
proxy_buffers 4 256k; proxy_buffers 4 256k;
proxy_busy_buffers_size 256k; proxy_busy_buffers_size 256k;
add_header X-Frame-Options "SAMEORIGIN"; include /etc/nginx/snippets/security_headers.conf;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "same-origin, strict-origin-when-cross-origin";
set_real_ip_from ${UPSTREAM_REAL_IP_ADDRESS}; set_real_ip_from ${UPSTREAM_REAL_IP_ADDRESS};
real_ip_header ${UPSTREAM_REAL_IP_HEADER}; real_ip_header ${UPSTREAM_REAL_IP_HEADER};
@ -59,6 +55,7 @@ server {
rewrite ^(.+)\.html$ $1 permanent; rewrite ^(.+)\.html$ $1 permanent;
location ~ ^/files/.*.(htm|html|svg|xml) { location ~ ^/files/.*.(htm|html|svg|xml) {
include /etc/nginx/snippets/security_headers.conf;
add_header Content-disposition "attachment"; add_header Content-disposition "attachment";
try_files /${FRAPPE_SITE_NAME_HEADER}/public/$uri @webserver; try_files /${FRAPPE_SITE_NAME_HEADER}/public/$uri @webserver;
} }

View file

@ -0,0 +1,5 @@
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "same-origin, strict-origin-when-cross-origin" always;

View file

@ -5,7 +5,7 @@ from typing import Any
import pytest import pytest
from tests.conftest import S3ServiceResult from tests.conftest import S3ServiceResult
from tests.utils import Compose, check_url_content from tests.utils import Compose, check_url_content, wait_for_url
BACKEND_SERVICES = ( BACKEND_SERVICES = (
"backend", "backend",
@ -81,6 +81,30 @@ def test_files_reachable(frappe_site: str, tmp_path: Path, compose: Compose):
) )
def test_files_html_security_headers(
frappe_site: str, tmp_path: Path, compose: Compose
):
file_path = tmp_path / "testfile.html"
file_path.write_text(
"<html><body>This is a Frappe Docker test html file</body></html>"
)
compose(
"cp",
str(file_path),
f"backend:/home/frappe/frappe-bench/sites/{frappe_site}/public/files/",
)
response = wait_for_url(
url=f"http://127.0.0.1/files/{file_path.name}",
site_name=frappe_site,
)
assert response.headers["Content-Disposition"] == "attachment"
assert response.headers["X-Frame-Options"] == "SAMEORIGIN"
assert response.headers["X-Content-Type-Options"] == "nosniff"
@pytest.mark.parametrize("service", BACKEND_SERVICES) @pytest.mark.parametrize("service", BACKEND_SERVICES)
@pytest.mark.usefixtures("frappe_site") @pytest.mark.usefixtures("frappe_site")
def test_frappe_connections_in_backends( def test_frappe_connections_in_backends(

View file

@ -4,6 +4,7 @@ import subprocess
import sys import sys
import time import time
from contextlib import suppress from contextlib import suppress
from http.client import HTTPResponse
from typing import Callable, Optional from typing import Callable, Optional
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@ -59,24 +60,11 @@ class Compose:
def check_url_content( def check_url_content(
url: str, callback: Callable[[str], Optional[str]], site_name: str url: str, callback: Callable[[str], Optional[str]], site_name: str
): ):
request = Request(url, headers={"Host": site_name})
# This is needed to check https override
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for _ in range(100): for _ in range(100):
try: try:
response = urlopen(request, context=ctx) response = wait_for_url(url=url, site_name=site_name, attempts=1)
except RuntimeError:
except HTTPError as exc:
if exc.code not in (404, 502):
raise
except URLError:
pass pass
else: else:
text: str = response.read().decode() text: str = response.read().decode()
ret = callback(text) ret = callback(text)
@ -86,4 +74,26 @@ def check_url_content(
time.sleep(0.1) time.sleep(0.1)
raise RuntimeError(f"Couldn't verify expected content from {url}")
def wait_for_url(url: str, site_name: str, attempts: int = 100) -> HTTPResponse:
request = Request(url, headers={"Host": site_name})
# This is needed to check https override
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for _ in range(attempts):
try:
return urlopen(request, context=ctx)
except HTTPError as exc:
if exc.code not in (404, 502):
raise
except URLError:
pass
time.sleep(0.1)
raise RuntimeError(f"Couldn't ping {url}") raise RuntimeError(f"Couldn't ping {url}")