Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 38 additions & 18 deletions Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@
{$BASEROW_CADDY_GLOBAL_CONF}
}

(baserow_media_files) {
handle_path /media/* {
@downloads {
query dl=*
}
header @downloads Content-Disposition "attachment; filename={query.dl}"
header X-Content-Type-Options "nosniff"
header Content-Security-Policy "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'"

# Allow to call head from public url to get size
header {
Access-Control-Allow-Origin {$BASEROW_PUBLIC_URL:http://localhost}
Access-Control-Allow-Methods "GET, HEAD, OPTIONS"
Access-Control-Allow-Headers "*"
Access-Control-Expose-Headers "Content-Length, Content-Type"
}

file_server {
root {$MEDIA_ROOT:/baserow/media/}
}
}
}

{$BASEROW_CADDY_ADDRESSES} {
tls {
on_demand
Expand All @@ -20,30 +43,27 @@
`
}

@is_baserow_media {
path /media/*
expression `
"{$MEDIA_URL:}".startsWith("http://" + {http.request.host} + "/") ||
"{$MEDIA_URL:}".startsWith("https://" + {http.request.host} + "/") ||
"{$MEDIA_URL:}".startsWith("http://" + {http.request.host} + ":") ||
"{$MEDIA_URL:}".startsWith("https://" + {http.request.host} + ":")
`
}

handle @is_baserow_media {
import baserow_media_files
}

handle @is_baserow_tool {
@backend_routes path /api/* /ws/* /mcp/* /assistant/* {$BASEROW_CADDY_BACKEND_EXTRA_ROUTES:}
handle @backend_routes {
reverse_proxy {$PRIVATE_BACKEND_URL:localhost:8000}
}

handle_path /media/* {
@downloads {
query dl=*
}
header @downloads Content-disposition "attachment; filename={query.dl}"

# Allow to call head from public url to get size
header {
Access-Control-Allow-Origin {$BASEROW_PUBLIC_URL:http://localhost}
Access-Control-Allow-Methods "GET, HEAD, OPTIONS"
Access-Control-Allow-Headers "*"
Access-Control-Expose-Headers "Content-Length, Content-Type"
}

file_server {
root {$MEDIA_ROOT:/baserow/media/}
}
}
import baserow_media_files

handle_path /static/* {
file_server {
Expand Down
4 changes: 3 additions & 1 deletion Caddyfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
@downloads {
query dl=*
}
header @downloads Content-disposition "attachment; filename={query.dl}"
header @downloads Content-Disposition "attachment; filename={query.dl}"
header X-Content-Type-Options "nosniff"
header Content-Security-Policy "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'"

header {
Access-Control-Allow-Origin {$PUBLIC_WEB_FRONTEND_URL:http://localhost:3000}
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ existing tools and performs at any scale.
[![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://www.heroku.com/deploy/?template=https://github.com/baserow/baserow/tree/master)

```bash
docker run -v baserow_data:/baserow/data -p 80:80 -p 443:443 baserow/baserow:2.2.1
docker run -v baserow_data:/baserow/data -p 80:80 -p 443:443 baserow/baserow:2.2.2
```

![Baserow database screenshot](docs/assets/screenshot.png "Baserow database screenshot")
Expand Down Expand Up @@ -108,7 +108,7 @@ Created by Baserow B.V. - bram@baserow.io.

Distributes under the MIT license. See `LICENSE` for more information.

Version: 2.2.1
Version: 2.2.2

The official repository can be found at https://github.com/baserow/baserow.

Expand Down
2 changes: 1 addition & 1 deletion backend/docker/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ set -euo pipefail
# ENVIRONMENT VARIABLES USED DIRECTLY BY THIS ENTRYPOINT
# ======================================================

export BASEROW_VERSION="2.2.1"
export BASEROW_VERSION="2.2.2"

# Used by docker-entrypoint.sh to start the dev server
# If not configured you'll receive this: CommandError: "0.0.0.0:" is not a valid port number or address:port pair.
Expand Down
3 changes: 3 additions & 0 deletions backend/src/baserow/api/user_files/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from baserow.api.schemas import get_error_schema
from baserow.contrib.database.api.tokens.authentications import TokenAuthentication
from baserow.core.user_files.exceptions import (
ActiveContentBlockedUserFileError,
FileSizeTooLargeError,
FileURLCouldNotBeReached,
InvalidFileStreamError,
Expand Down Expand Up @@ -49,6 +50,7 @@ class UploadFileView(APIView):
{
InvalidFileStreamError: ERROR_INVALID_FILE,
FileSizeTooLargeError: ERROR_FILE_SIZE_TOO_LARGE,
ActiveContentBlockedUserFileError: ERROR_INVALID_FILE,
}
)
def post(self, request):
Expand Down Expand Up @@ -93,6 +95,7 @@ class UploadViaURLView(APIView):
FileSizeTooLargeError: ERROR_FILE_SIZE_TOO_LARGE,
FileURLCouldNotBeReached: ERROR_FILE_URL_COULD_NOT_BE_REACHED,
InvalidFileURLError: ERROR_INVALID_FILE_URL,
ActiveContentBlockedUserFileError: ERROR_INVALID_FILE,
}
)
@validate_body(UserFileUploadViaURLRequestSerializer)
Expand Down
20 changes: 17 additions & 3 deletions backend/src/baserow/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@
"name": "MIT",
"url": "https://github.com/baserow/baserow/blob/develop/LICENSE",
},
"VERSION": "2.2.1",
"VERSION": "2.2.2",
"SERVE_INCLUDE_SCHEMA": False,
"TAGS": [
{"name": "Settings"},
Expand Down Expand Up @@ -611,6 +611,15 @@
Decimal(os.getenv("BASEROW_FILE_UPLOAD_SIZE_LIMIT_MB", 1024 * 1024)) * 1024 * 1024
) # ~1TB by default

FILE_UPLOAD_ACTIVE_CONTENT_POLICY = os.getenv(
"BASEROW_FILE_UPLOAD_ACTIVE_CONTENT_POLICY", "download"
).lower()
if FILE_UPLOAD_ACTIVE_CONTENT_POLICY not in ("download", "block"):
raise ImproperlyConfigured(
"BASEROW_FILE_UPLOAD_ACTIVE_CONTENT_POLICY must be set to "
"'download' or 'block'."
)

BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB = int(
os.getenv("BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB", 512)
)
Expand Down Expand Up @@ -778,15 +787,22 @@ def __setitem__(self, key, value):
if not BASEROW_EMBEDDED_SHARE_URL:
BASEROW_EMBEDDED_SHARE_URL = PUBLIC_WEB_FRONTEND_URL

MEDIA_URL_PATH = "/media/"
MEDIA_URL = os.getenv("MEDIA_URL", urljoin(PUBLIC_BACKEND_URL, MEDIA_URL_PATH))

PRIVATE_BACKEND_URL = os.getenv("PRIVATE_BACKEND_URL", "http://backend:8000")
PUBLIC_BACKEND_HOSTNAME = urlparse(PUBLIC_BACKEND_URL).hostname
PUBLIC_WEB_FRONTEND_HOSTNAME = urlparse(PUBLIC_WEB_FRONTEND_URL).hostname
BASEROW_EMBEDDED_SHARE_HOSTNAME = urlparse(BASEROW_EMBEDDED_SHARE_URL).hostname
MEDIA_URL_HOSTNAME = urlparse(MEDIA_URL).hostname
PRIVATE_BACKEND_HOSTNAME = urlparse(PRIVATE_BACKEND_URL).hostname

if PUBLIC_BACKEND_HOSTNAME:
ALLOWED_HOSTS.append(PUBLIC_BACKEND_HOSTNAME)

if MEDIA_URL_HOSTNAME:
ALLOWED_HOSTS.append(MEDIA_URL_HOSTNAME)

if PRIVATE_BACKEND_HOSTNAME:
ALLOWED_HOSTS.append(PRIVATE_BACKEND_HOSTNAME)

Expand Down Expand Up @@ -951,8 +967,6 @@ def __setitem__(self, key, value):
os.getenv("BASEROW_INITIAL_CREATE_SYNC_TABLE_DATA_LIMIT", 5000)
)

MEDIA_URL_PATH = "/media/"
MEDIA_URL = os.getenv("MEDIA_URL", urljoin(PUBLIC_BACKEND_URL, MEDIA_URL_PATH))
MEDIA_ROOT = os.getenv("MEDIA_ROOT", "/baserow/media")

# Indicates the directory where the user files and user thumbnails are stored.
Expand Down
2 changes: 2 additions & 0 deletions backend/src/baserow/contrib/builder/api/domains/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,8 @@ def get(self, request):
+ settings.EXTRA_PUBLIC_BACKEND_HOSTNAMES
+ settings.EXTRA_PUBLIC_WEB_FRONTEND_HOSTNAMES
)
if settings.MEDIA_URL_HOSTNAME:
allowed_domain.add(settings.MEDIA_URL_HOSTNAME)

if domain_name in allowed_domain:
return Response(None, status=200)
Expand Down
4 changes: 4 additions & 0 deletions backend/src/baserow/core/user_files/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ def __init__(self, max_size_bytes, *args, **kwargs):
super().__init__(*args, **kwargs)


class ActiveContentBlockedUserFileError(Exception):
"""Raised when the file upload active content policy blocks a file."""


class FileURLCouldNotBeReached(Exception):
"""
Raised when the provided URL could not be reached or points to an internal
Expand Down
127 changes: 101 additions & 26 deletions backend/src/baserow/core/user_files/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from baserow.core.utils import random_string, sha256_hash, stream_size, truncate_middle

from .exceptions import (
ActiveContentBlockedUserFileError,
FileSizeTooLargeError,
FileURLCouldNotBeReached,
InvalidFileStreamError,
Expand All @@ -43,9 +44,66 @@
from PIL import Image

MIME_TYPE_UNKNOWN = "application/octet-stream"
ACTIVE_CONTENT_EXTENSIONS = {"html", "htm", "xhtml", "xml", "svg", "svgz"}
ACTIVE_CONTENT_MIME_TYPES = {
"application/xhtml+xml",
"application/xml",
"image/svg+xml",
"text/html",
"text/xml",
}


class UserFileHandler:
def _is_active_content_extension(self, extension: str) -> bool:
return extension.lower() in ACTIVE_CONTENT_EXTENSIONS

def _is_active_content_mime_type(self, mime_type: str) -> bool:
return mime_type.lower() in ACTIVE_CONTENT_MIME_TYPES

def _resolve_mime_type_and_active_content(
self, file_name: str, extension: str, stream
) -> tuple[str, bool]:
"""
Resolves the MIME type for an uploaded file and decides whether it should be
treated as active content.

Both the filename-derived MIME type and the client-supplied `content_type` are
checked independently against the active-content blocklist so that a malicious
client cannot bypass the gate by pairing an innocuous extension with a dangerous
Content-Type header (or vice versa).

:param file_name: The name of the uploaded file.
:param extension: The file extension of the uploaded file.
:param stream: The file content stream of the uploaded file, which may have a
`content_type` attribute.
:return: A tuple of the resolved MIME type and whether the file is considered
active content.
"""

guessed_mime_type = mimetypes.guess_type(file_name)[0]
uploaded_mime_type = getattr(stream, "content_type", None)
mime_type = guessed_mime_type or uploaded_mime_type or MIME_TYPE_UNKNOWN
is_active_content = (
self._is_active_content_extension(extension)
or (
guessed_mime_type is not None
and self._is_active_content_mime_type(guessed_mime_type)
)
or (
uploaded_mime_type is not None
and self._is_active_content_mime_type(uploaded_mime_type)
)
)
return mime_type, is_active_content

def _neutralize_active_content(self, user_file: UserFile) -> UserFile:
user_file.mime_type = MIME_TYPE_UNKNOWN
user_file.is_image = False
user_file.image_width = None
user_file.image_height = None
return user_file

def is_user_file_name(self, user_file_name: str) -> bool:
"""
Checks if the given name is a user file name.
Expand Down Expand Up @@ -266,6 +324,15 @@ def upload_user_file(self, user, file_name, stream, storage=None):
storage = storage or get_default_storage()
stream_hash = sha256_hash(stream)
file_name = truncate_middle(file_name, 64)
extension = pathlib.Path(file_name).suffix[1:].lower()
mime_type, is_active_content = self._resolve_mime_type_and_active_content(
file_name, extension, stream
)

if is_active_content and settings.FILE_UPLOAD_ACTIVE_CONTENT_POLICY == "block":
raise ActiveContentBlockedUserFileError(
"The provided file type is not allowed."
)

existing_user_file = UserFile.objects.filter(
original_name=file_name,
Expand All @@ -274,14 +341,18 @@ def upload_user_file(self, user, file_name, stream, storage=None):
).first()

if existing_user_file:
if is_active_content:
self._neutralize_active_content(existing_user_file)
existing_user_file.save(
update_fields=[
"mime_type",
"is_image",
"image_width",
"image_height",
]
)
return existing_user_file

extension = pathlib.Path(file_name).suffix[1:].lower()
mime_type = (
mimetypes.guess_type(file_name)[0]
or getattr(stream, "content_type", None)
or MIME_TYPE_UNKNOWN
)
unique = self.generate_unique(stream_hash, extension)
user_file = UserFile(
original_name=file_name,
Expand All @@ -293,26 +364,30 @@ def upload_user_file(self, user, file_name, stream, storage=None):
sha256_hash=stream_hash,
)

image = None
try:
image = Image.open(stream)
user_file.mime_type = f"image/{image.format}".lower()
self.generate_and_save_image_thumbnails(
image, user_file.name, storage=storage
)
# Skip marking as images if thumbnails cannot be generated (i.e. PSD files).
user_file.is_image = True
user_file.image_width = image.width
user_file.image_height = image.height
except IOError:
pass # Not an image
except Exception as exc:
logger.warning(
f"Failed to generate thumbnails for user file of type {mime_type}: {exc}"
)
finally:
if image is not None:
del image
if is_active_content:
self._neutralize_active_content(user_file)
else:
image = None
try:
image = Image.open(stream)
user_file.mime_type = f"image/{image.format}".lower()
self.generate_and_save_image_thumbnails(
image, user_file.name, storage=storage
)
# Skip marking as images if thumbnails cannot be generated (i.e. PSD files).
user_file.is_image = True
user_file.image_width = image.width
user_file.image_height = image.height
except IOError:
pass # Not an image
except Exception as exc:
logger.warning(
f"Failed to generate thumbnails for user file of type "
f"{mime_type}: {exc}"
)
finally:
if image is not None:
del image

user_file.save()

Expand Down
2 changes: 1 addition & 1 deletion backend/src/baserow/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = "2.2.1"
VERSION = "2.2.2"
Loading
Loading