Skip to content

fix(data-warehouse): gate url_pattern writes on node-role tables - #81598

Open
Piccirello wants to merge 2 commits into
masterfrom
security/lock-node-role-table-url-pattern
Open

fix(data-warehouse): gate url_pattern writes on node-role tables#81598
Piccirello wants to merge 2 commits into
masterfrom
security/lock-node-role-table-url-pattern

Conversation

@Piccirello

@Piccirello Piccirello commented Aug 11, 2026

Copy link
Copy Markdown
Member

Problem

A future write path to DataWarehouseTable could reopen the cross-tenant S3 read that #81559 closed, because the credential requirement lived only in the DRF serializer.

A table with no credential is read by ClickHouse under the cluster's own S3 role instead of a team-supplied key, so its url_pattern is only safe because PostHog chose it. #81559 enforces that on the REST update path. Nothing enforced it anywhere else: a new endpoint, an admin action, or a management command could set url_pattern on a credential-less table and nothing would catch it.

Changes

  • DataWarehouseTable.save() refuses to change url_pattern on a credential-less table unless the caller passes internally_computed_url_pattern=True. The check reads the row's prior state, so re-saving the same value never raises, and a save scoped away from url_pattern via update_fields skips the read entirely.
  • Four internal writers legitimately rewrite url_pattern on credential-less rows as normal operation: the pipeline sync activity, saved-query materialization, the direct-connection upsert helpers (Snowflake/ClickHouse/Redshift/MySQL/Postgres), and the legacy file-upload action. Each computes the value itself from trusted state (a job's destination folder, a fixed sentinel, or the caller's own team_id plus a sanitized filename), never from a raw request field, so each now passes the flag explicitly.
  • Every other writer, present or future, is refused by default rather than trusted to have remembered a check that lives elsewhere.

I considered scoping the guard by table kind (e.g. skip it for external_data_source-backed rows) instead of an explicit flag, but two of the four legitimate writers - pipeline sync and saved-query materialization - operate on exactly the credential-less, non-external_data_source rows the guard needs to protect. Table kind doesn't correlate with trust here; only the caller knows whether the value it's writing came from its own computation or from a request body.

How did you test this code?

Added tests in products/warehouse_sources/backend/tests/test_table.py covering: a credential-less table's url_pattern change is rejected without the flag and accepted with it, a credentialed table is unaffected, creating a new credential-less table needs no flag, a same-value re-save doesn't raise, an update_fields-scoped save that excludes url_pattern skips the check, and soft_delete() doesn't trip it.

Ran the full existing suites for every internal writer this touches to check for regressions: products/data_warehouse/backend/test/test_direct_query_engines.py, test_direct_virtual_tables.py, products/warehouse_sources/backend/temporal/data_imports/pipelines/test_pipeline_sync.py, products/data_warehouse/backend/logic/data_load/test/test_create_table.py, products/data_warehouse/backend/tests/api/test_table.py, test_warehouse_table_file_upload.py, and products/warehouse_sources/backend/tests/test_util.py (81559's own suite) - all pass alongside the new tests. Also ran uv run mypy --cache-fine-grained . repo-wide (clean).

Not run: hogli build:openapi doesn't apply here - this PR changes model/business-logic behavior, not any serializer field, endpoint signature, or response schema.

Automatic notifications

  • Publish to changelog?

Docs update

None - internal model invariant, no user-facing or documented behavior changes.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Tom asked for a more holistic follow-up to #81559 after I traced through the original vulnerability report and confirmed it as a true positive: instead of only the REST serializer enforcing "credential-less tables can't change their read location," push the same invariant down to the model so any future writer inherits it by default. Skills invoked: /writing-code-comments, /writing-pr-descriptions.

The first design I considered was a blanket save() guard with no opt-in, but investigating the codebase found three production Temporal paths (pipeline sync, direct-connection upserts, saved-query materialization) that legitimately rewrite url_pattern on credential-less rows - a blanket guard would have broken them. The explicit internally_computed_url_pattern flag is the result of that finding: it lets trusted internal writers declare themselves explicitly instead of the model trying to infer trust from row shape.

A table with no credential is read by ClickHouse under the cluster's own
S3 role, so its url_pattern is only safe to trust because PostHog chose
it. That invariant was enforced only in the DRF serializer (#81559), so
any other writer of DataWarehouseTable - a future endpoint, an admin
action, a management command - could reintroduce the same class of bug
without anyone noticing.

- DataWarehouseTable.save() now refuses to change url_pattern on a
  credential-less table unless the caller passes
  internally_computed_url_pattern=True, checked against the row's prior
  state so a same-value re-save is never blocked.
- The pipeline sync activity, saved-query materialization, the direct-
  connection upsert helpers, and the legacy file-upload action all
  compute url_pattern themselves rather than taking it from request
  input, so each now declares that trust explicitly.
- Every other caller is refused by default instead of relying on a check
  living only in the API layer.
@trunk-io

trunk-io Bot commented Aug 11, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@Piccirello Piccirello self-assigned this Aug 11, 2026
@trunk-io

trunk-io Bot commented Aug 11, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

@Piccirello
Piccirello marked this pull request as ready for review August 12, 2026 01:05
@Piccirello Piccirello added the reviewhog ($$$) Reviews pull requests before humans do label Aug 12, 2026
@posthog

posthog Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 0 should fix, 2 consider.

Published 2 findings (view the review).

@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested review from a team August 12, 2026 01:06
@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "fix(data-warehouse): gate url_pattern wr..." | Re-trigger Greptile

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Backend coverage — 96.0% of changed backend lines covered — 3 uncovered

🧪 Backend test coverage

Patch coverage — changed backend lines (products + core): ███████████████████░ 96.0% (94 / 97)

File Patch Uncovered changed lines
products/engineering_analytics/backend/management/commands/seed_engineering_analytics.py 0.0% 1152
products/warehouse_sources/backend/models/table.py 90.5% 362, 1035

🤖 Agents: add a test covering the lines above, or note why under "How did you test this code?". Machine-readable gap list: the patch-coverage artifact on this run (gh run download 31555939012 -n patch-coverage), or the coverage-data block at the end of this comment.

Per-product line coverage (touched products)
Product Coverage Lines
platform_features ██░░░░░░░░░░░░░░░░░░ 12.1% 7 / 58
batch_exports ████████░░░░░░░░░░░░ 39.5% 8,852 / 22,431
warehouse_sources_queue █████████░░░░░░░░░░░ 45.9% 90 / 196
demo ███████████░░░░░░░░░ 56.3% 1,497 / 2,661
data_tools █████████████░░░░░░░ 66.2% 90 / 136
tasks ██████████████░░░░░░ 70.5% 37,026 / 52,507
ai_gateway ███████████████░░░░░ 75.0% 9 / 12
signals █████████████████░░░ 82.6% 27,456 / 33,231
apm █████████████████░░░ 83.8% 1,220 / 1,455
cdp █████████████████░░░ 84.2% 3,914 / 4,649
data_modeling █████████████████░░░ 86.5% 8,459 / 9,782
actions █████████████████░░░ 86.6% 717 / 828
wizard █████████████████░░░ 86.7% 1,090 / 1,257
notebooks █████████████████░░░ 86.8% 8,222 / 9,471
cohorts ██████████████████░░ 87.6% 6,482 / 7,400
product_tours ██████████████████░░ 87.9% 1,303 / 1,482
managed_warehouse ██████████████████░░ 88.1% 6,057 / 6,876
data_warehouse ██████████████████░░ 88.6% 11,762 / 13,277
business_knowledge ██████████████████░░ 89.0% 4,384 / 4,928
exports ██████████████████░░ 89.2% 7,528 / 8,444
engineering_analytics ██████████████████░░ 89.3% 6,759 / 7,570
visual_review ██████████████████░░ 89.5% 5,870 / 6,558
dashboards ██████████████████░░ 89.5% 6,195 / 6,920
alerts ██████████████████░░ 90.3% 4,507 / 4,992
mcp_analytics ██████████████████░░ 90.4% 3,968 / 4,388
conversations ██████████████████░░ 90.5% 18,390 / 20,315
links ██████████████████░░ 90.6% 183 / 202
streamlit_apps ██████████████████░░ 90.7% 2,630 / 2,901
error_tracking ██████████████████░░ 91.1% 11,214 / 12,304
stamphog ██████████████████░░ 91.3% 4,505 / 4,936
canvas ██████████████████░░ 92.0% 2,596 / 2,823
slack_app ██████████████████░░ 92.4% 11,379 / 12,313
managed_migrations ███████████████████░ 92.6% 1,556 / 1,681
early_access_features ███████████████████░ 92.9% 1,347 / 1,450
notifications ███████████████████░ 92.9% 1,060 / 1,141
ai_observability ███████████████████░ 92.9% 17,217 / 18,527
web_analytics ███████████████████░ 92.9% 16,337 / 17,578
mcp_store ███████████████████░ 93.0% 6,983 / 7,512
surveys ███████████████████░ 93.2% 5,919 / 6,349
posthog_ai ███████████████████░ 93.2% 1,326 / 1,422
marketing_analytics ███████████████████░ 93.4% 16,364 / 17,518
reminders ███████████████████░ 93.4% 468 / 501
approvals ███████████████████░ 93.5% 3,491 / 3,734
product_analytics ███████████████████░ 93.6% 7,115 / 7,602
legal_documents ███████████████████░ 93.8% 1,628 / 1,736
workflows ███████████████████░ 94.3% 7,969 / 8,454
endpoints ███████████████████░ 94.3% 8,814 / 9,349
tracing ███████████████████░ 94.4% 2,678 / 2,837
review_hog ███████████████████░ 94.6% 8,246 / 8,715
skills ███████████████████░ 94.7% 3,862 / 4,077
replay_vision ███████████████████░ 95.6% 19,114 / 20,004
logs ███████████████████░ 95.6% 11,834 / 12,382
messaging ███████████████████░ 95.6% 3,424 / 3,581
experiments ███████████████████░ 95.8% 28,704 / 29,965
annotations ███████████████████░ 96.2% 732 / 761
revenue_analytics ███████████████████░ 96.3% 1,887 / 1,960
data_quality ███████████████████░ 96.4% 747 / 775
user_interviews ███████████████████░ 96.5% 2,638 / 2,734
customer_analytics ███████████████████░ 96.5% 12,361 / 12,805
feature_flags ███████████████████░ 96.5% 18,301 / 18,958
growth ███████████████████░ 96.7% 5,992 / 6,198
access_control ███████████████████░ 96.7% 873 / 903
warehouse_sources ████████████████████ 97.5% 390,187 / 400,167
data_catalog ████████████████████ 97.9% 2,676 / 2,734
analytics_platform ████████████████████ 98.1% 2,254 / 2,298
metrics ████████████████████ 98.2% 2,491 / 2,536
pulse ████████████████████ 98.4% 2,017 / 2,049
live_debugger ████████████████████ 99.2% 613 / 618
field_notes ████████████████████ 99.4% 158 / 159

Report-only. Patch coverage = changed backend lines covered vs origin/master. Sorted lowest first.
Known gaps: lines covered only by Temporal tests show as uncovered; core line numbers may drift if master changed the same file.

@posthog

posthog Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ReviewHog Report

Changes

Issues: 2 issues

Files (8)
  • products/data_warehouse/backend/direct_clickhouse.py
  • products/data_warehouse/backend/direct_mysql.py
  • products/data_warehouse/backend/direct_postgres.py
  • products/data_warehouse/backend/direct_redshift.py
  • products/data_warehouse/backend/direct_snowflake.py
  • products/data_warehouse/backend/presentation/views/table.py
  • products/warehouse_sources/backend/models/table.py
  • products/warehouse_sources/backend/temporal/data_imports/pipelines/pipeline_sync.py

Comment thread products/warehouse_sources/backend/models/table.py
Comment thread products/warehouse_sources/backend/models/table.py
Two gaps in the prior commit's writer audit:

- DataWarehouseTable is registered in Django admin with url_pattern as
  an editable field. Admin validates via full_clean() (form.is_valid()
  -> clean()) before ModelAdmin.save_model() ever calls save(), and
  save_model doesn't translate a save()-raised ValidationError into a
  form error the way DRF's perform_update does - so a staff edit that
  trips the guard surfaced as an unhandled 500 instead of a normal
  field error. Added clean(), calling the same check, so admin's
  existing full_clean() step catches it. save() remains the
  enforcement of record for every other caller, since nothing but
  ModelForm calls full_clean().
- matrix.py's demo-table registration and seed_engineering_analytics's
  schema-table upsert both attach a real credential and rewrite
  url_pattern in the same save() call, but the guard reads the row's
  prior DB state rather than the value being assigned - so a
  pre-existing credential-less row (predating either of these credential-
  attaching code paths) would still trip it. Both now declare
  internally_computed_url_pattern=True, matching the other four writers:
  each computes url_pattern from team/table_name, never from request
  input.
@Piccirello
Piccirello requested a review from a team August 12, 2026 06:20
@posthog-security-review-bot

posthog-security-review-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

Security review completed2d10655f3da

1 finding posted as a review, in 10 min.

Re-request a team-security review to run again on the latest commit.

@posthog-security-review-bot posthog-security-review-bot 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.

Agent-driven security review - findings inline.

``save(internally_computed_url_pattern=True)`` to say so. Every other caller, present or
future, is refused by default rather than trusted to have remembered a check elsewhere.
"""
if self._state.adding:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium - tenant isolation (confidence 4/5)

Django admin creation bypasses the credential-less URL guard

DataWarehouseTable._reject_client_supplied_url_pattern_change returns for every new instance. DataWarehouseTableAdmin leaves url_pattern and team editable while making credential read-only, so a staff account with the model's add permission can submit POST /admin/warehouse_sources/datawarehousetable/add/ with team=<its project>, format=CSVWithNames, and url_pattern=https://s3.us-east-1.amazonaws.com/ph-warehouse/file_uploads/team_42/*.csv. Both the admin form's full_clean() and the subsequent save() take this early return and persist the attacker-selected URL with credential_id = NULL. hogql_definition then passes that URL to ClickHouse without an access key or secret, so reads use the cluster's S3 role—the exact cross-tenant condition this guard is intended to prevent.

Impact — a compromised or delegated staff account that can add warehouse tables can expose another tenant's PostHog-hosted files by creating the table in a project it can query.

Fix — enforce the credential-less invariant on inserts as well as updates. Require credential-less creation sites to use an explicit trusted factory/flag (and update the server-computed pipeline/upload call sites), or disable DataWarehouseTableAdmin add permission / require a credential in its add form; do not blanket-return solely because _state.adding is true.

Agent-driven security review. Confidence is 4/5 because Django is not installed in the review runtime, so the admin POST could not be executed; the registered admin, editable/read-only fields, validation path, persisted state, and ClickHouse sink were verified statically.

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

Labels

reviewhog ($$$) Reviews pull requests before humans do

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant