Skip to content

Stop connection extras from overriding the computed connection - #725

Open
chrisclark wants to merge 1 commit into
masterfrom
fix/extras-override-connection
Open

chrisclark wants to merge 1 commit into
masterfrom
fix/extras-override-connection

Conversation

@chrisclark

Copy link
Copy Markdown
Collaborator

Summary

Fixes cross-tenant database access through connection extras. High.

DatabaseConnection.extras is free-form JSON entered by whoever manages the connection, and it was merged into the connection settings after the computed ones:

connection_settings = {
    "NAME": self.name if not self.is_upload else self.local_name,
    "USER": self.user,          # decrypted
    "PASSWORD": self.password,  # decrypted
    "HOST": self.host if not self.is_upload else None,
    ...
}
if self.extras:
    connection_settings.update(extras_dict)   # <- wins

For an upload-backed connection that means extras["NAME"] overrode the tenant's own local copy of their file, so one user could point an upload connection at another user's database by naming its path in user_dbs/. HOST, USER and PASSWORD were replaceable the same way — and those are the values that had just been decrypted from the encrypted model fields. ENGINE could be swapped too, which changes which driver is loaded.

I confirmed the override was reachable by watching a ClientError: HeadObject come back from the real bucket for the injected path before this fix.

Fix

Keys Explorer manages are dropped from extras instead of merged:

  • ENGINE always;
  • NAME/HOST/USER/PASSWORD additionally for upload-backed connections.

Everything else keeps working (OPTIONS, TIME_ZONE, custom driver kwargs …). The upload NAME stays pinned to uploaded_db_local_path(name), derived from the connection's own name in the shared user_dbs directory.

I put the filter on the model rather than in DatabaseConnectionValidateView (which builds an unsaved instance from form data) because this is per-connection policy, not a single caller's concern — a second caller would silently reopen the hole. Ignored keys are logged at warning level so a legitimate-but-mistaken extras entry is visible rather than mysteriously ignored.

Deliberately not restricted: NAME for non-upload connections. For a normal server-side connection it is just the database name the admin chose, and pinning it to alias would be wrong for Postgres/Oracle setups — happy to tighten further if you want uploads-only stricter.

Tests

New, in TestDjangoStyleConnection (exits via mocked load_backend, so the driver need not be installed):

  • upload NAME cannot be overridden; resolves to conn.local_name;
  • upload USER/PASSWORD/HOST cannot be overridden;
  • OPTIONS in extras still applies to non-upload connections (guards against over-restricting).

Full suite: 294 tests, OK. ruff check explorer/: clean.

Note: I had to remove stale tst1/tst2/tst3 files in the working tree first — the SQLite test DBs persisted from an interrupted run and manage.py test was blocking on the interactive "delete the test database?" prompt rather than running.

DatabaseConnection.extras is free-form JSON entered by whoever manages the
connection, and it was merged into the connection settings after the
computed ones. For an upload-backed connection that meant extras["NAME"]
won over the tenant's own local copy of their uploaded SQLite file:

    NAME -> user_dbs/<their filename>

so one user could point an upload connection at another user's database
by naming its path. HOST, USER and PASSWORD could be replaced the same
way, and those are decrypted from the encrypted model fields that had just
been used to compute the settings. ENGINE could be swapped too, which
changes which driver is loaded.

Keys that Explorer manages are now dropped from extras rather than merged:
ENGINE always, and NAME/HOST/USER/PASSWORD additionally for uploads. The
upload path stays pinned to uploaded_db_local_path(name), which is derived
from the connection's own name in the shared user_dbs directory.

Everything else in extras keeps working (OPTIONS, TIME_ZONE, ...). The
keys are per-connection policy, so the filter lives on the model rather
than in any single caller.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant