Skip to content

feat: ClickHouse bring-your-own-warehouse connections#8020

Draft
Zaimwa9 wants to merge 17 commits into
mainfrom
feat/clickhouse-byo-warehouse-backend
Draft

feat: ClickHouse bring-your-own-warehouse connections#8020
Zaimwa9 wants to merge 17 commits into
mainfrom
feat/clickhouse-byo-warehouse-backend

Conversation

@Zaimwa9

@Zaimwa9 Zaimwa9 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Thanks for submitting a PR! Please check the boxes below:

  • I have read the Contributing Guide.
  • I have added information to docs/ if required so people know about the feature.
  • I have filled in the "Changes" section below.
  • I have filled in the "How did you test this code" section below.

Changes

Adds the clickhouse warehouse connection type so customers can bring their own ClickHouse instance for experimentation. This PR covers connection setup and credentials management; event routing is a follow-up in flagsmith-analytics-pipeline, and the frontend ships separately behind the clickhouse_warehouse flag.

  • New EncryptedJSONField stores connection credentials encrypted at rest, keyed off SECRET_KEY — no new configuration required.
  • The serializer accepts config (host required, sensible defaults for port/database/username/TLS) and a write-only credentials: {password} that is never returned by the API. Omitting credentials on PATCH keeps the stored password; switching the connection to another type clears it.
  • Connections are verified with SELECT 1 on create, on config or credential change, and via the existing test-connection endpoint, setting the status to connected or errored.
  • New verification counter metric and structlog events.

How did you test this code?

Unit tests (100% diff coverage), make lint, make typecheck.

Manually: POST /api/v1/environments/<api_key>/warehouse-connections/ with a clickhouse payload returns 201 with "status": "connected" or "errored"; re-verify via POST …/test-warehouse-connection/.

@vercel

vercel Bot commented Jul 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Jul 16, 2026 12:09pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
flagsmith-frontend-preview Ignored Ignored Preview Jul 16, 2026 12:09pm
flagsmith-frontend-staging Ignored Ignored Preview Jul 16, 2026 12:09pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e0a64d00-a508-45c1-a3f5-85f7fa87558b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added api Issue related to the REST API docs Documentation updates labels Jul 15, 2026
@github-actions github-actions Bot added feature New feature or request and removed docs Documentation updates labels Jul 15, 2026
Zaimwa9 added 2 commits July 16, 2026 11:26
…arehouse-backend

# Conflicts:
#	docs/docs/deployment-self-hosting/observability/_events-catalogue.md
Comment thread openapi.yaml
allOf:
- $ref: '#/components/schemas/WarehouseConnectionStatusEnum'
readOnly: true
status_detail:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nice to have, I believe especially with other warehouses that we don't know so well, it will be helpful to keep track of the error

@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.65%. Comparing base (2c000a2) to head (87861f0).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff            @@
##             main    #8020    +/-   ##
========================================
  Coverage   98.64%   98.65%            
========================================
  Files        1501     1508     +7     
  Lines       59474    59846   +372     
========================================
+ Hits        58668    59040   +372     
  Misses        806      806            

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Zaimwa9
Zaimwa9 marked this pull request as ready for review July 16, 2026 10:27
@Zaimwa9
Zaimwa9 requested review from a team as code owners July 16, 2026 10:27
@Zaimwa9
Zaimwa9 requested review from matthewelwell and removed request for a team July 16, 2026 10:27
@github-actions github-actions Bot added feature New feature or request and removed feature New feature or request labels Jul 16, 2026
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Docker builds report

Image Build Status Security report
ghcr.io/flagsmith/flagsmith-e2e:pr-8020 Finished ✅ Skipped
ghcr.io/flagsmith/flagsmith-api-test:pr-8020 Finished ✅ Skipped
ghcr.io/flagsmith/flagsmith-frontend:pr-8020 Finished ✅ Results
ghcr.io/flagsmith/flagsmith-api:pr-8020 Finished ✅ Results
ghcr.io/flagsmith/flagsmith:pr-8020 Finished ✅ Results
ghcr.io/flagsmith/flagsmith-private-cloud:pr-8020 Finished ✅ Results

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Playwright Test Results (oss - depot-ubuntu-latest-16)

passed  4 passed

Details

stats  4 tests across 3 suites
duration  41.8 seconds
commit  f7a302b
info  🔄 Run: #18346 (attempt 1)

Playwright Test Results (oss - depot-ubuntu-latest-arm-16)

passed  5 passed

Details

stats  5 tests across 3 suites
duration  41.4 seconds
commit  f7a302b
info  🔄 Run: #18346 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-16)

passed  6 passed

Details

stats  6 tests across 5 suites
duration  18.1 seconds
commit  f7a302b
info  🔄 Run: #18346 (attempt 1)

Playwright Test Results (private-cloud - depot-ubuntu-latest-arm-16)

passed  6 passed

Details

stats  6 tests across 5 suites
duration  12 seconds
commit  f7a302b
info  🔄 Run: #18346 (attempt 1)

@github-actions

Copy link
Copy Markdown
Contributor

Visual Regression

19 screenshots compared. See report for details.
View full report

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
api/experimentation/serializers.py (1)

66-87: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fully normalise state when changing warehouse type.

The transition currently clears credentials but can retain configuration and verification state belonging to the previous backend.

  • api/experimentation/serializers.py#L66-L87: preserve omitted config only when the warehouse type is unchanged; otherwise validate or clear it for the new type.
  • api/experimentation/views.py#L110-L119: reset status and status_detail when the type changes.
  • api/tests/unit/experimentation/test_views.py#L1686-L1709: assert configuration and status reset alongside credential clearing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2a79b4d5-56fe-458d-83ce-a1457dc09356

📥 Commits

Reviewing files that changed from the base of the PR and between 2c000a2 and f7a302b.

📒 Files selected for processing (21)
  • api/app/settings/common.py
  • api/app/settings/test.py
  • api/core/fields.py
  • api/experimentation/metrics.py
  • api/experimentation/migrations/0010_warehouse_connection_credentials.py
  • api/experimentation/migrations/0011_warehouse_connection_status_detail.py
  • api/experimentation/models.py
  • api/experimentation/serializers.py
  • api/experimentation/services.py
  • api/experimentation/types.py
  • api/experimentation/views.py
  • api/experimentation/warehouse_validation.py
  • api/tests/unit/core/test_fields.py
  • api/tests/unit/experimentation/conftest.py
  • api/tests/unit/experimentation/test_models.py
  • api/tests/unit/experimentation/test_services.py
  • api/tests/unit/experimentation/test_views.py
  • api/webhooks/fields.py
  • docs/docs/deployment-self-hosting/observability/_events-catalogue.md
  • docs/docs/deployment-self-hosting/observability/_metrics-catalogue.md
  • openapi.yaml

Comment thread api/core/fields.py
Comment on lines +14 to +16
def _get_fernet() -> Fernet:
digest = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
return Fernet(base64.urlsafe_b64encode(digest))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## api/core/fields.py\n'
cat -n api/core/fields.py | sed -n '1,120p'

printf '\n## Search for _get_fernet and related encryption settings/tests\n'
rg -n "_get_fernet|Fernet|MultiFernet|SECRET_KEY|FIELD_ENCRYPTION|decrypt|from_db_value|Stored connection details are incomplete" api -S

printf '\n## api/core/services.py (relevant slices)\n'
if [ -f api/core/services.py ]; then
  cat -n api/core/services.py | sed -n '1,240p'
fi

printf '\n## tests mentioning SECRET_KEY rotation or from_db_value\n'
rg -n "secret_key changed|SECRET_KEY changed|from_db_value__secret_key_changed|returns none and logs|incomplete" tests api -S

Repository: Flagsmith/flagsmith

Length of output: 7390


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path('api/core/fields.py')
print(p.exists(), p)
if p.exists():
    for i, line in enumerate(p.read_text().splitlines(), 1):
        if 1 <= i <= 80:
            print(f"{i:4d}: {line}")
PY

Repository: Flagsmith/flagsmith

Length of output: 1754


Use a dedicated key for encrypted fields. Deriving the Fernet key from SECRET_KEY couples credential encryption to Django’s global signing secret, and rotating SECRET_KEY will make stored values undecryptable while from_db_value returns None, which downstream surfaces as “Stored connection details are incomplete.” Add a separate encryption setting and a rotation path instead of reusing SECRET_KEY.

Comment on lines +811 to +849
def verify_clickhouse_connection(connection: WarehouseConnection) -> None:
"""Run SELECT 1 against the customer's ClickHouse and set the status to
connected or errored; never raises."""
log = logger.bind(environment__id=connection.environment_id)
try:
log = log.bind(organisation__id=connection.environment.project.organisation_id)
config = typing.cast(ClickHouseConfig, connection.config or {})
credentials = typing.cast(ClickHouseCredentials, connection.credentials or {})
client = Client(
config["host"],
port=config["port"],
user=config["username"],
password=credentials["password"],
database=config["database"],
secure=config["secure"],
connect_timeout=CLICKHOUSE_CONNECT_TIMEOUT_SECONDS,
send_receive_timeout=CLICKHOUSE_QUERY_TIMEOUT_SECONDS,
)
try:
client.execute("SELECT 1")
finally:
client.disconnect()
except Exception as error:
connection.status = WarehouseConnectionStatus.ERRORED
connection.status_detail = _describe_verification_error(error)
connection.save(update_fields=["status", "status_detail"])
flagsmith_experimentation_warehouse_connection_verifications_total.labels(
result="failure"
).inc()
log.warning("connection.verification_failed", exc_info=True)
return

connection.status = WarehouseConnectionStatus.CONNECTED
connection.status_detail = None
connection.save(update_fields=["status", "status_detail"])
flagsmith_experimentation_warehouse_connection_verifications_total.labels(
result="success"
).inc()
log.info("connection.verification_succeeded")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the verifier and its call sites.
printf '\n== services.py around verify_clickhouse_connection ==\n'
sed -n '780,900p' api/experimentation/services.py

printf '\n== views.py references ==\n'
rg -n "verify_clickhouse_connection|write_environment_ingestion_keys\.delay|AFTER_CREATE|perform_create|perform_update|test connection|connection.verification" api/experimentation -n

printf '\n== views.py relevant slices ==\n'
for f in api/experimentation/views.py api/experimentation/*.py; do
  if [ -f "$f" ] && rg -n "verify_clickhouse_connection|write_environment_ingestion_keys\.delay|perform_create|perform_update|AFTER_CREATE" "$f" >/dev/null; then
    echo "--- $f ---"
    sed -n '1,260p' "$f" | rg -n -A40 -B20 "verify_clickhouse_connection|write_environment_ingestion_keys\.delay|perform_create|perform_update|AFTER_CREATE"
  fi
done

Repository: Flagsmith/flagsmith

Length of output: 18677


Move ClickHouse verification off the request thread Up to ~35s of network I/O still runs inline in perform_create/perform_update, so a slow or unreachable ClickHouse host can block API workers on every create/update. Queue this asynchronously and update status/status_detail out of band.

Comment thread api/experimentation/views.py
Comment thread api/experimentation/warehouse_validation.py Outdated
Comment thread api/experimentation/warehouse_validation.py
Comment thread api/experimentation/warehouse_validation.py
Comment thread api/tests/unit/experimentation/test_views.py Outdated
Comment thread api/webhooks/fields.py Outdated
Comment on lines +8 to +27
def is_internal_address(hostname: str) -> bool:
try:
ips = [ipaddress.ip_address(hostname)]
except ValueError:
# hostname is a name rather than a literal IP — resolve it.
try:
results = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC)
ips = [ipaddress.ip_address(str(r[4][0]).split("%")[0]) for r in results]
except socket.gaierror:
# Unresolvable hostname; leave it to the URL validator.
return False

return any(
ip.is_loopback
or ip.is_private
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
for ip in ips
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Prevent DNS rebinding from bypassing the SSRF check.

Validation resolves the hostname once, but the later outbound connection resolves the original hostname again. An attacker-controlled domain can return a public address first and an internal address second. Connect to a validated address while preserving the hostname for TLS, verify the actual peer address, or enforce network-level egress controls.

Also applies to: 48-53

@Zaimwa9
Zaimwa9 removed the request for review from matthewelwell July 16, 2026 10:58
@Zaimwa9
Zaimwa9 marked this pull request as draft July 16, 2026 10:58
@github-actions github-actions Bot added the docs Documentation updates label Jul 16, 2026
@github-actions github-actions Bot added feature New feature or request and removed feature New feature or request docs Documentation updates labels Jul 16, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
api/experimentation/warehouse_validation.py (1)

66-78: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Snowflake config validator still accepts and merges unvalidated arbitrary keys.

Unlike validate_clickhouse_config (which now rejects unknown keys and validates every field's type), validate_snowflake_config merges **config unchanged. A caller can inject unexpected keys (or wrong types for warehouse/database/schema/role/user) and they will be persisted and returned via the API, the same class of problem raised previously for ClickHouse but not carried over here.

🔒 Suggested allowlist + type check
 def validate_snowflake_config(config: dict[str, Any]) -> SnowflakeConfig:
     if not isinstance(config, dict):
         raise serializers.ValidationError({"config": "Must be an object."})
+    if unknown_keys := set(config) - set(SNOWFLAKE_DEFAULTS) - {"account_identifier"}:
+        raise serializers.ValidationError(
+            {"config": {key: "Unknown field." for key in sorted(unknown_keys)}}
+        )
     account_identifier = config.get("account_identifier", "")
     if not account_identifier:
         raise serializers.ValidationError(
             {"config": {"account_identifier": "This field is required."}}
         )
+    for key, value in config.items():
+        if key != "account_identifier" and not isinstance(value, str):
+            raise serializers.ValidationError({"config": {key: "Must be a string."}})
     merged: SnowflakeConfig = {
         **SNOWFLAKE_DEFAULTS,
         **config,  # type: ignore[typeddict-item]
     }
     return merged
api/experimentation/serializers.py (1)

44-47: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Drop default=None on these JSON fields. WarehouseConnectionViewSet exposes PUT, so omitted config/credentials are still inserted into attrs as None and the "not in attrs" preservation logic in validate()/validate_credentials() never runs. That turns a partial omission into an explicit clear and can reject updates that should keep the existing values.

api/experimentation/services.py (1)

845-857: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Prevent stale verification results overwriting newer configuration state.

Concurrent PATCH/test requests can verify different configuration snapshots; whichever finishes last overwrites status and status_detail, even when its configuration is no longer current. Persist a configuration revision or fingerprint and update the result conditionally.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 609b19f1-40b7-4181-8628-960b4584dafe

📥 Commits

Reviewing files that changed from the base of the PR and between f7a302b and 87861f0.

📒 Files selected for processing (14)
  • api/app/settings/common.py
  • api/app/settings/test.py
  • api/core/network.py
  • api/experimentation/serializers.py
  • api/experimentation/services.py
  • api/experimentation/views.py
  • api/experimentation/warehouse_validation.py
  • api/tests/unit/core/test_fields.py
  • api/tests/unit/experimentation/test_services.py
  • api/tests/unit/experimentation/test_views.py
  • api/tests/unit/webhooks/test_unit_webhooks.py
  • api/tests/unit/webhooks/test_webhooks_fields.py
  • api/webhooks/fields.py
  • docs/docs/deployment-self-hosting/observability/_events-catalogue.md

Comment thread api/core/network.py
Comment on lines +9 to +17
try:
ips = [ipaddress.ip_address(hostname)]
except ValueError:
# hostname is a name rather than a literal IP — resolve it.
try:
results = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC)
ips = [ipaddress.ip_address(str(r[4][0]).split("%")[0]) for r in results]
except socket.gaierror:
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files:\n'
git ls-files 'api/core/*' 'api/*webhooks*' 'api/*warehouse*' | sed -n '1,200p'

printf '\nOutline of api/core/network.py:\n'
ast-grep outline api/core/network.py --view expanded || true

printf '\nRelevant call sites for is_internal_address:\n'
rg -n "is_internal_address\(" api -S

printf '\nRead api/core/network.py with line numbers:\n'
cat -n api/core/network.py | sed -n '1,220p'

printf '\nRead the call-site files around matches (if any):\n'
for f in $(rg -l "is_internal_address\(" api -S || true); do
  printf '\n### %s ###\n' "$f"
  cat -n "$f" | sed -n '1,240p'
done

Repository: Flagsmith/flagsmith

Length of output: 20160


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Search for validate_clickhouse_config / validate_credentials usage:\n'
rg -n "validate_clickhouse_config|validate_credentials|warehouse_validation" api -S

printf '\nRead experimentation serializers/models around the matches:\n'
for f in $(rg -l "validate_clickhouse_config|validate_credentials|warehouse_validation" api -S || true); do
  printf '\n### %s ###\n' "$f"
  cat -n "$f" | sed -n '1,260p'
done

Repository: Flagsmith/flagsmith

Length of output: 16963


Bound DNS resolution in this validator. socket.getaddrinfo has no timeout, so a hostile or broken resolver can hold this request path open for a long time. This runs synchronously during webhook URL and warehouse host validation, so a single hostname can tie up workers; use a bounded, fail-closed lookup strategy instead.

Comment thread api/core/network.py
Comment on lines +19 to +26
return any(
ip.is_loopback
or ip.is_private
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
for ip in ips
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== api/core/network.py ==\n'
wc -l api/core/network.py
cat -n api/core/network.py

printf '\n== usages of the helper ==\n'
rg -n "is_loopback|is_private|is_link_local|is_reserved|is_multicast|ip\.ipv4_mapped|100\.64\.0\.0/10|shared address" api -S

printf '\n== python ipaddress probe ==\n'
python3 - <<'PY'
import ipaddress

tests = [
    "100.64.0.1",
    "100.127.255.254",
    "100.128.0.1",
    "::ffff:192.168.1.1",
    "::ffff:127.0.0.1",
    "::ffff:100.64.0.1",
    "192.168.1.1",
    "127.0.0.1",
    "169.254.1.1",
    "8.8.8.8",
]

for s in tests:
    ip = ipaddress.ip_address(s)
    attrs = {
        "is_global": ip.is_global,
        "is_private": ip.is_private,
        "is_loopback": ip.is_loopback,
        "is_link_local": ip.is_link_local,
        "is_reserved": ip.is_reserved,
        "is_multicast": ip.is_multicast,
    }
    if isinstance(ip, ipaddress.IPv6Address):
        attrs["ipv4_mapped"] = str(ip.ipv4_mapped) if ip.ipv4_mapped else None
    print(s, attrs)
PY

Repository: Flagsmith/flagsmith

Length of output: 3074


🌐 Web query:

Python ipaddress IPv4-mapped IPv6 is_private loopback behaviour shared address space 100.64.0.0/10 docs

💡 Result:

In Python's ipaddress module, the is_private and is_global attributes follow specific logic concerning the shared address space (100.64.0.0/10) and IPv4-mapped IPv6 addresses [1][2]. Shared Address Space (100.64.0.0/10) The 100.64.0.0/10 range is treated as a special case in the ipaddress module [1][2]. Both is_private and is_global return False for this network [2][3]. This is distinct from most other address ranges where is_private is the logical opposite of is_global [1][4]. IPv4-mapped IPv6 Addresses For IPv4-mapped IPv6 addresses (addresses starting with::FFFF/96), the is_private and is_global attributes are determined by the semantics of the underlying embedded IPv4 address [2][3]. The library enforces that the boolean value of the mapped address matches the value of the IPv4 address it contains [2]: address.is_private == address.ipv4_mapped.is_private address.is_global == address.ipv4_mapped.is_global This ensures consistency between the IPv6 representation and the underlying IPv4 address rules, including the special treatment of the 100.64.0.0/10 range [1][4]. General Behavior The is_private property is defined as True if an address is not globally reachable according to the IANA IPv4 or IPv6 special-purpose address registries, with the specified exceptions [1][5]. Conversely, is_global is True if the address is defined as globally reachable, again with the exception of the 100.64.0.0/10 range [2][3]. Developers should be aware that these properties reflect these IANA-based definitions rather than purely local or enterprise network configuration [5][6].

Citations:


Cover RFC 6598 shared address space

100.64.0.0/10 is neither is_private nor caught by the other predicates, so it still bypasses this internal-address check. Add an explicit shared-address-space check if these hosts should be treated as internal.

Comment on lines +827 to +832
# Re-check right before connecting: DNS may resolve differently than it
# did at validation time, and rows may predate host validation.
if is_internal_address(config["host"]):
raise InternalAddressError(config["host"])
client = Client(
config["host"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Close the DNS-rebinding gap before opening the socket.

is_internal_address(config["host"]) and Client(config["host"], ...) resolve the hostname independently. An attacker-controlled hostname can return a public address for the check and an internal address for the client connection. Pin the validated address or enforce network-level egress denial for private/link-local destinations.

Comment on lines +94 to +103
def get_throttles(self) -> list[BaseThrottle]:
if self.action in (
"create",
"update",
"partial_update",
"test_warehouse_connection",
):
self.throttle_scope = "warehouse_connection_write"
return [ScopedRateThrottle()]
return super().get_throttles()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== views.py ==\n'
sed -n '1,180p' api/experimentation/views.py

printf '\n== test_views.py around throttle test ==\n'
sed -n '1020,1100p' api/tests/unit/experimentation/test_views.py

printf '\n== search for get_throttles definitions/usages ==\n'
rg -n "def get_throttles|throttle_scope|ScopedRateThrottle|throttle_classes" api/experimentation api/tests/unit/experimentation -S

Repository: Flagsmith/flagsmith

Length of output: 10305


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,180p' api/experimentation/views.py
sed -n '1020,1100p' api/tests/unit/experimentation/test_views.py
rg -n "def get_throttles|throttle_scope|ScopedRateThrottle|throttle_classes" api/experimentation api/tests/unit/experimentation -S

Repository: Flagsmith/flagsmith

Length of output: 10196


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "DEFAULT_THROTTLE_CLASSES|throttle_classes\s*=" -S .

Repository: Flagsmith/flagsmith

Length of output: 1775


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== NestedEnvironmentViewSet definition ==\n'
rg -n "class NestedEnvironmentViewSet|throttle_classes|get_throttles" api environments core -S

printf '\n== likely file for NestedEnvironmentViewSet ==\n'
fd -a "views.py" api environments core | rg "environments/views.py|NestedEnvironmentViewSet|views.py$"

Repository: Flagsmith/flagsmith

Length of output: 2911


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
for p in Path('.').rglob('*.py'):
    try:
        text = p.read_text()
    except Exception:
        continue
    if 'class NestedEnvironmentViewSet' in text:
        print(p)
PY

Repository: Flagsmith/flagsmith

Length of output: 183


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '300,380p' api/environments/views.py

Repository: Flagsmith/flagsmith

Length of output: 3267


Preserve the existing throttle stack for write actions. In api/experimentation/views.py#L94-L103, append ScopedRateThrottle to super().get_throttles() instead of returning it on its own, so any configured throttles still apply. In api/tests/unit/experimentation/test_views.py#L1054-L1069, assert the warehouse scope is present without requiring it to be the only throttle.

📍 Affects 2 files
  • api/experimentation/views.py#L94-L103 (this comment)
  • api/tests/unit/experimentation/test_views.py#L1054-L1069

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Issue related to the REST API feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant