Skip to content

Commit 8861155

Browse files
authored
[SS-188] Check the token_uri in Google Cloud Service Account keys (#36997)
This PR adds a simple framework for checking the Secrets used by a Connection when either the Secret or the Connection changes. So far, it's just a hook to check GCP secrets for a bad `token_uri` field. This is a followup to #36694. _Just for fun: [Opus vs Fable](https://github.com/ublubu/materialize/compare/f1cc6f85bd0e70bfafc6aec843e54b64087fc762..ublubu:materialize:3eb3a022489401038eab4f84cbdff0a184ff2063?diff=split&w) code comparison_
1 parent 704f7f8 commit 8861155

4 files changed

Lines changed: 115 additions & 15 deletions

File tree

src/adapter/src/coord/sequencer/inner.rs

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ use mz_sql::plan::{
6464
StatementContext,
6565
};
6666
use mz_sql::pure::{PurifiedSourceExport, generate_subsource_statements};
67-
use mz_storage_types::connections::gcp::GcpServiceAccountKeyTokenUri;
6867
use mz_storage_types::sinks::StorageSinkDesc;
6968
// Import `plan` module, but only import select elements to avoid merge conflicts on use statements.
7069
use mz_sql::plan::{
@@ -625,6 +624,41 @@ impl Coordinator {
625624
}
626625
}
627626

627+
/// Applies `details.secret_content_guards()` by reading each guarded
628+
/// secret's current contents. Must be called whenever a connection is
629+
/// created or its options altered, before the change takes effect.
630+
async fn check_connection_secret_content_guards(
631+
&self,
632+
details: &ConnectionDetails,
633+
) -> Result<(), AdapterError> {
634+
for (secret_id, guard) in details.secret_content_guards() {
635+
let contents = self.caching_secrets_reader.read_string(secret_id).await?;
636+
guard(&contents)?;
637+
}
638+
Ok(())
639+
}
640+
641+
/// Applies the secret-content guards of every connection that uses
642+
/// `secret_id` against proposed new `contents` for that secret. Must be
643+
/// called whenever a secret's contents change, before the new value is
644+
/// persisted.
645+
pub(super) fn check_secret_content_guards_of_dependents(
646+
&self,
647+
secret_id: CatalogItemId,
648+
contents: &str,
649+
) -> Result<(), AdapterError> {
650+
for dependent_id in self.catalog().get_entry(&secret_id).used_by() {
651+
if let CatalogItem::Connection(conn) = self.catalog().get_entry(dependent_id).item() {
652+
for (guarded_id, guard) in conn.details.secret_content_guards() {
653+
if guarded_id == secret_id {
654+
guard(contents)?;
655+
}
656+
}
657+
}
658+
}
659+
Ok(())
660+
}
661+
628662
#[instrument]
629663
pub(super) async fn sequence_create_connection(
630664
&mut self,
@@ -664,22 +698,18 @@ impl Coordinator {
664698
}
665699
self.caching_secrets_reader.invalidate(connection_id);
666700
}
667-
ConnectionDetails::Gcp(gcp) => {
668-
// A service account key defines its own OAuth2 token URI.
669-
// We only want to send requests to the actual Google OAuth2 token API,
670-
// so we inspect the key as early as we can.
671-
if let Err(err) = self
672-
.caching_secrets_reader
673-
.read_string(gcp.credentials_json)
674-
.await
675-
.and_then(|json| GcpServiceAccountKeyTokenUri::validate_json(&json))
676-
{
677-
return ctx.retire(Err(err.into()));
678-
}
679-
}
680701
_ => (),
681702
};
682703

704+
// Inspect guarded secrets as early as we can, before the connection is
705+
// installed in the catalog.
706+
if let Err(err) = self
707+
.check_connection_secret_content_guards(&plan.connection.details)
708+
.await
709+
{
710+
return ctx.retire(Err(err));
711+
}
712+
683713
if plan.validate {
684714
let internal_cmd_tx = self.internal_cmd_tx.clone();
685715
let transient_revision = self.catalog().transient_revision();
@@ -3646,6 +3676,15 @@ impl Coordinator {
36463676
}
36473677
};
36483678

3679+
// Inspect guarded secrets whether or not validation was requested,
3680+
// before the altered connection is installed in the catalog.
3681+
if let Err(err) = self
3682+
.check_connection_secret_content_guards(&conn.details)
3683+
.await
3684+
{
3685+
return ctx.retire(Err(err));
3686+
}
3687+
36493688
if validate {
36503689
let connection = conn
36513690
.details

src/adapter/src/coord/sequencer/inner/secret.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,8 @@ impl Coordinator {
282282
let caching_secrets_reader = self.caching_secrets_reader.clone();
283283
let secrets_controller = Arc::clone(&self.secrets_controller);
284284
let payload = self.extract_secret(session, &mut secret_as)?;
285+
let contents = std::str::from_utf8(&payload).expect("validated as UTF-8 by extract_secret");
286+
self.check_secret_content_guards_of_dependents(id, contents)?;
285287
let span = Span::current();
286288
Ok(StageResult::HandleRetire(mz_ore::task::spawn(
287289
|| "alter secret ensure",

src/sql/src/plan.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ use mz_sql_parser::ast::{
5757
};
5858
use mz_ssh_util::keys::SshKeyPair;
5959
use mz_storage_types::connections::aws::AwsConnection;
60-
use mz_storage_types::connections::gcp::GcpConnection;
60+
use mz_storage_types::connections::gcp::{GcpConnection, GcpServiceAccountKeyTokenUri};
6161
use mz_storage_types::connections::inline::ReferencedConnection;
6262
use mz_storage_types::connections::{
6363
AwsPrivatelinkConnection, CsrConnection, GlueSchemaRegistryConnection,
@@ -1715,6 +1715,31 @@ impl ConnectionDetails {
17151715
}
17161716
}
17171717
}
1718+
1719+
/// Secrets whose *contents* this connection places requirements on, paired
1720+
/// with the check to apply. Callers must re-apply these checks whenever the
1721+
/// connection is created or altered, and whenever the contents of one of
1722+
/// the returned secrets change (e.g. `ALTER SECRET`).
1723+
///
1724+
/// We rely on the caller to actually execute these checks because we don't know:
1725+
/// - which secrets the caller cares about
1726+
/// - which secrets require an async operation to fetch
1727+
///
1728+
/// For example, the ALTER SECRET caller should only perform checks on its own secret,
1729+
/// while the ALTER CONNECTION caller fetches and checks every secret from its connection.
1730+
pub fn secret_content_guards(
1731+
&self,
1732+
) -> Vec<(CatalogItemId, fn(&str) -> Result<(), anyhow::Error>)> {
1733+
match self {
1734+
// A service-account key defines its own OAuth2 token URI. We only
1735+
// want to send requests to the actual Google OAuth2 token API.
1736+
ConnectionDetails::Gcp(gcp) => vec![(
1737+
gcp.credentials_json,
1738+
GcpServiceAccountKeyTokenUri::validate_json,
1739+
)],
1740+
_ => vec![],
1741+
}
1742+
}
17181743
}
17191744

17201745
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Ord, PartialOrd, Hash)]

test/testdrive/connection-create-external.td

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,37 @@ contains:token_uri must be https://oauth2.googleapis.com/token
263263
# In fact, the GCP allowlist should even apply when VALIDATE = false.
264264
! CREATE CONNECTION gcp_ssrf_private_conn TO GCP (SERVICE ACCOUNT KEY = SECRET gcp_ssrf_private) WITH (VALIDATE = false);
265265
contains:token_uri must be https://oauth2.googleapis.com/token
266+
267+
# The CREATE-time check is not enough: ALTER CONNECTION and ALTER SECRET can both
268+
# install a malicious token_uri into an already-created GCP connection, so the
269+
# allowlist must guard those paths too.
270+
271+
# A benign service-account key whose token_uri is the real Google endpoint. It
272+
# passes the allowlist, so the connection and secret below are created cleanly
273+
# and give the ALTER paths a valid starting point to subvert.
274+
> CREATE SECRET gcp_good AS '{"type":"service_account","project_id":"x","client_email":"a@b.com","private_key":"${gcp-ssrf-key}","token_uri":"https://oauth2.googleapis.com/token"}'
275+
> CREATE CONNECTION gcp_alter_conn TO GCP (SERVICE ACCOUNT KEY = SECRET gcp_good) WITH (VALIDATE = false);
276+
277+
# ALTER CONNECTION: swapping in a SERVICE ACCOUNT KEY that points at a private
278+
# token_uri is the same SSRF primitive as CREATE and must be rejected, whether or
279+
# not validation is requested.
280+
! ALTER CONNECTION gcp_alter_conn SET (SERVICE ACCOUNT KEY = SECRET gcp_ssrf_private) WITH (VALIDATE = true);
281+
contains:token_uri must be https://oauth2.googleapis.com/token
282+
283+
! ALTER CONNECTION gcp_alter_conn SET (SERVICE ACCOUNT KEY = SECRET gcp_ssrf_private) WITH (VALIDATE = false);
284+
contains:token_uri must be https://oauth2.googleapis.com/token
285+
286+
# ALTER SECRET: rewriting the secret behind a GCP connection slips a malicious
287+
# token_uri past the CREATE-time check entirely, since the connection was already
288+
# validated against the benign key above. This must be rejected as well.
289+
! ALTER SECRET gcp_good AS '{"type":"service_account","project_id":"x","client_email":"a@b.com","private_key":"${gcp-ssrf-key}","token_uri":"http://127.0.0.1:1/token"}'
290+
contains:token_uri must be https://oauth2.googleapis.com/token
291+
292+
# Benign changes must still go through: a guard that rejected every ALTER would
293+
# also pass the tests above. ALTER SECRET first, while gcp_alter_conn still
294+
# depends on gcp_good -- afterwards the connection moves off it and the
295+
# dependent-based guard no longer applies.
296+
> ALTER SECRET gcp_good AS '{"type":"service_account","project_id":"y","client_email":"a@b.com","private_key":"${gcp-ssrf-key}","token_uri":"https://oauth2.googleapis.com/token"}'
297+
298+
> CREATE SECRET gcp_good_2 AS '{"type":"service_account","project_id":"x","client_email":"a@b.com","private_key":"${gcp-ssrf-key}","token_uri":"https://oauth2.googleapis.com/token"}'
299+
> ALTER CONNECTION gcp_alter_conn SET (SERVICE ACCOUNT KEY = SECRET gcp_good_2) WITH (VALIDATE = false);

0 commit comments

Comments
 (0)