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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ DATABASE_NAME=baserow

# BASEROW_INTEGRATIONS_ALLOW_PRIVATE_ADDRESS=
# BASEROW_INTEGRATIONS_PERIODIC_MINUTE_MIN=
# BASEROW_DATA_SYNC_ALLOW_PRIVATE_ADDRESS=
# BASEROW_SSO_ALLOW_PRIVATE_ADDRESS=

# BASEROW_AIRTABLE_IMPORT_SOFT_TIME_LIMIT=
# HOURS_UNTIL_TRASH_PERMANENTLY_DELETED=
Expand Down
3 changes: 3 additions & 0 deletions backend/src/baserow/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1217,6 +1217,9 @@ def __setitem__(self, key, value):
INTEGRATIONS_ALLOW_PRIVATE_ADDRESS = bool(
os.getenv("BASEROW_INTEGRATIONS_ALLOW_PRIVATE_ADDRESS", False)
)
BASEROW_DATA_SYNC_ALLOW_PRIVATE_ADDRESS = str_to_bool(
os.getenv("BASEROW_DATA_SYNC_ALLOW_PRIVATE_ADDRESS") or "true"
)
INTEGRATIONS_PERIODIC_TASK_CRONTAB = crontab(minute="*")
# The minimum amount of minutes the periodic task's "minute" interval
# supports. Self-hosters can run every minute, if they choose to.
Expand Down
2 changes: 2 additions & 0 deletions backend/src/baserow/config/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ def getenv_for_tests(key: str, default: str = "") -> str:

BASEROW_WEBHOOKS_ALLOW_PRIVATE_ADDRESS = False
INTEGRATIONS_ALLOW_PRIVATE_ADDRESS = False
BASEROW_DATA_SYNC_ALLOW_PRIVATE_ADDRESS = False
BASEROW_SSO_ALLOW_PRIVATE_ADDRESS = False

CACHALOT_ENABLED = str_to_bool(os.getenv("CACHALOT_ENABLED", "false"))
if CACHALOT_ENABLED:
Expand Down
5 changes: 3 additions & 2 deletions backend/src/baserow/contrib/automation/nodes/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@
from baserow.core.services.handler import ServiceHandler
from baserow.core.services.models import Service
from baserow.core.storage import ExportZipFile
from baserow.core.telemetry.utils import baserow_trace_methods
from baserow.core.telemetry.utils import baserow_trace
from baserow.core.utils import ChildProgressBuilder, MirrorDict, extract_allowed

tracer = trace.get_tracer(__name__)


class AutomationNodeHandler(metaclass=baserow_trace_methods(tracer)):
class AutomationNodeHandler:
allowed_fields = [
"label",
"service",
Expand Down Expand Up @@ -441,6 +441,7 @@ def _after_node_dispatch(
node_history=node_history,
)

@baserow_trace(tracer)
def dispatch_node(
self,
node_id: int,
Expand Down
6 changes: 4 additions & 2 deletions backend/src/baserow/contrib/automation/workflows/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
from baserow.core.exceptions import IdDoesNotExist
from baserow.core.registries import ImportExportConfig
from baserow.core.storage import ExportZipFile, get_default_storage
from baserow.core.telemetry.utils import baserow_trace_methods
from baserow.core.telemetry.utils import baserow_trace
from baserow.core.trash.handler import TrashHandler
from baserow.core.utils import (
ChildProgressBuilder,
Expand All @@ -78,7 +78,7 @@
tracer = trace.get_tracer(__name__)


class AutomationWorkflowHandler(metaclass=baserow_trace_methods(tracer)):
class AutomationWorkflowHandler:
allowed_fields = [
"name",
"allow_test_run_until",
Expand Down Expand Up @@ -696,6 +696,7 @@ def _clone_workflow(

return cloned_automation, id_mapping

@baserow_trace(tracer)
def publish(
self,
workflow: AutomationWorkflow,
Expand Down Expand Up @@ -1230,6 +1231,7 @@ def async_start_workflow(
lambda: start_workflow_celery_task.delay(workflow.id, history.id)
)

@baserow_trace(tracer)
def start_workflow(
self,
workflow: AutomationWorkflow,
Expand Down
6 changes: 6 additions & 0 deletions backend/src/baserow/contrib/builder/domains/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from django.db.models import QuerySet
from django.db.utils import IntegrityError

from opentelemetry import trace

from baserow.contrib.builder.domains.exceptions import (
DomainDoesNotExist,
DomainNameNotUniqueError,
Expand All @@ -20,9 +22,12 @@
from baserow.core.psycopg import is_unique_violation_error
from baserow.core.registries import ImportExportConfig, application_type_registry
from baserow.core.storage import get_default_storage
from baserow.core.telemetry.utils import baserow_trace
from baserow.core.trash.handler import TrashHandler
from baserow.core.utils import Progress, extract_allowed

tracer = trace.get_tracer(__name__)


class DomainHandler:
allowed_fields_create = ["domain_name"]
Expand Down Expand Up @@ -230,6 +235,7 @@ def get_published_domain_applications(
else applications
)

@baserow_trace(tracer)
def publish(self, domain: Domain, progress: Progress | None = None):
"""
Publishes a builder for the given domain object. If the builder was
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from django.core.files.storage import Storage
from django.db.models import QuerySet

from opentelemetry import trace

from baserow.contrib.builder.data_providers.registries import (
builder_data_provider_type_registry,
)
Expand All @@ -25,6 +27,7 @@
)
from baserow.core.exceptions import IdDoesNotExist
from baserow.core.services.types import DispatchResult
from baserow.core.telemetry.utils import baserow_trace
from baserow.core.workflow_actions.handler import WorkflowActionHandler
from baserow.core.workflow_actions.models import WorkflowAction
from baserow.core.workflow_actions.registries import WorkflowActionType
Expand All @@ -33,6 +36,9 @@
from baserow.contrib.builder.models import Builder


tracer = trace.get_tracer(__name__)


class BuilderWorkflowActionHandler(WorkflowActionHandler):
model = BuilderWorkflowAction
registry = builder_workflow_action_type_registry
Expand Down Expand Up @@ -170,6 +176,7 @@ def create_workflow_action(

return super().create_workflow_action(workflow_action_type, **kwargs).specific

@baserow_trace(tracer)
def dispatch_workflow_action(
self,
workflow_action: BuilderWorkflowServiceAction,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
from icalendar import Calendar
from requests.exceptions import RequestException

import advocate
from advocate.exceptions import UnacceptableAddressException
from baserow.contrib.database.fields.models import DateField, TextField
from baserow.core.utils import ChildProgressBuilder

from .exceptions import SyncError
from .models import ICalCalendarDataSync
from .registries import DataSyncProperty, DataSyncType
from .utils import compare_date
from .utils import (
DATA_SYNC_BLOCKED_URL_ERROR,
compare_date,
get_data_sync_request_function,
)


class UIDICalCalendarDataSyncProperty(DataSyncProperty):
Expand Down Expand Up @@ -87,8 +90,12 @@ def get_all_rows(
progress = ChildProgressBuilder.build(progress_builder, child_total=3)

try:
response = advocate.get(instance.ical_url, timeout=60)
except (RequestException, UnacceptableAddressException, ConnectionError):
response = get_data_sync_request_function()(
"GET", instance.ical_url, timeout=60
)
except UnacceptableAddressException:
raise SyncError(DATA_SYNC_BLOCKED_URL_ERROR)
except (RequestException, ConnectionError):
raise SyncError("The provided URL could not be reached.")

if not response.ok:
Expand Down
42 changes: 41 additions & 1 deletion backend/src/baserow/contrib/database/data_sync/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,49 @@
from dataclasses import dataclass
from datetime import date, datetime, timezone
from typing import List
from typing import Callable, List

from django.conf import settings

import requests

import advocate
from baserow.contrib.database.fields.models import Field, SelectOption

# User facing error when advocate blocks a request. Advocate rejects private
# addresses and only allows ports 80, 443, 8000, 8080 and 8443.
DATA_SYNC_BLOCKED_URL_ERROR = (
"The provided URL is not allowed because it resolves to a private network "
"address, or uses a port other than 80, 443, 8000, 8080 or 8443."
)


def get_data_sync_request_function() -> Callable:
"""
Returns the request function that must be used for outgoing data sync HTTP
requests. When the `BASEROW_DATA_SYNC_ALLOW_PRIVATE_ADDRESS` Django setting is
`False`, the advocate library is used so that user configured URLs (e.g. a
self-hosted GitLab instance) can't be used to reach Baserow's internal network
(SSRF protection). The setting defaults to `True` for backwards compatibility.
"""

if settings.BASEROW_DATA_SYNC_ALLOW_PRIVATE_ADDRESS is True:
return requests.request
else:
return advocate.request


def get_data_sync_session() -> requests.Session:
"""
Returns the HTTP session that must be used for outgoing data sync requests made
through client libraries that accept a `requests` session (e.g. the Jira client).
Works like `get_data_sync_request_function`, but for session based clients.
"""

if settings.BASEROW_DATA_SYNC_ALLOW_PRIVATE_ADDRESS is True:
return requests.Session()
else:
return advocate.Session()


def normalize_datetime(d):
if d.tzinfo is None:
Expand Down
18 changes: 16 additions & 2 deletions backend/src/baserow/core/import_export/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@

WORKSPACE_EXPORTS_LIMIT = 5
EXPORT_FORMAT_VERSION = "1.0.0"
# Manifest versions that have a schema file in `import_export/schema`.
SUPPORTED_MANIFEST_VERSIONS = frozenset({EXPORT_FORMAT_VERSION})
MANIFEST_NAME = "manifest.json"
SIGNATURE_NAME = "manifest_signature.json"
INDENT = settings.DEBUG and 4 or None
Expand Down Expand Up @@ -644,10 +646,22 @@ def validate_manifest(self, zip_file):
raise ImportExportResourceInvalidFile("Manifest file is corrupted.")

manifest_version = manifest_data.get("version")
# The version comes from the untrusted uploaded archive and is used to
# build a file path, so only explicitly supported versions are accepted.
if manifest_version not in SUPPORTED_MANIFEST_VERSIONS:
raise ImportExportResourceInvalidFile(
"Manifest file is corrupted: unsupported version."
)
manifest_schema_file = f"schema_v{manifest_version}.json"

with open(f"{schema_dir}/{manifest_schema_file}") as schema_file:
schema = json.load(schema_file)
schema_path = os.path.join(schema_dir, manifest_schema_file)
try:
with open(schema_path) as schema_file:
schema = json.load(schema_file)
except FileNotFoundError:
raise ImportExportResourceInvalidFile(
"Manifest file is corrupted: unsupported version."
)

try:
validate(instance=manifest_data, schema=schema)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo

from django.test.utils import override_settings

import pytest
import responses
from freezegun import freeze_time

from baserow.contrib.database.data_sync.handler import DataSyncHandler
from baserow.contrib.database.data_sync.models import DataSyncSyncedProperty
from baserow.contrib.database.data_sync.utils import compare_date
from baserow.contrib.database.data_sync.utils import (
DATA_SYNC_BLOCKED_URL_ERROR,
compare_date,
)

ICAL_FEED_WITH_ONE_ITEMS_WITHOUT_DTEND = """BEGIN:VCALENDAR
VERSION:2.0
Expand Down Expand Up @@ -73,6 +78,59 @@ def test_ical_sync_data_sync_table_without_dtend(data_fixture):
assert getattr(sync_1_rows[0], f"field_{fields['dtend'].id}") is None


@pytest.mark.django_db
def test_ical_sync_private_address_blocked_when_not_allowed(data_fixture):
# When private addresses are not allowed the request goes through advocate, so
# an iCal URL pointing to a private address must fail with a clear sync error.
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)

handler = DataSyncHandler()

data_sync = handler.create_data_sync_table(
user=user,
database=database,
table_name="Test",
type_name="ical_calendar",
synced_properties=["uid"],
# Port 80 is in advocate's whitelist, so the private IP check itself is hit.
ical_url="http://127.0.0.1:80/ical.ics",
)
data_sync = handler.sync_data_sync_table(user=user, data_sync=data_sync)
assert data_sync.last_error == DATA_SYNC_BLOCKED_URL_ERROR


@pytest.mark.django_db
@override_settings(BASEROW_DATA_SYNC_ALLOW_PRIVATE_ADDRESS=True)
@responses.activate
def test_ical_sync_private_address_allowed_when_setting_enabled(data_fixture):
responses.add(
responses.GET,
"http://127.0.0.1/ical.ics",
status=200,
body=ICAL_FEED_WITH_ONE_ITEMS_WITHOUT_DTEND,
)

user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)

handler = DataSyncHandler()

data_sync = handler.create_data_sync_table(
user=user,
database=database,
table_name="Test",
type_name="ical_calendar",
synced_properties=["uid"],
ical_url="http://127.0.0.1/ical.ics",
)
data_sync = handler.sync_data_sync_table(user=user, data_sync=data_sync)

assert data_sync.last_error is None
model = data_sync.table.get_model()
assert model.objects.count() == 1


@pytest.mark.django_db
def test_ical_sync_date_equal(data_fixture):
amsterdam = ZoneInfo("Europe/Amsterdam")
Expand Down
Loading
Loading