Skip to main content

gossan_cloud/
gcs.rs

1//! Google Cloud Storage bucket probe.
2//!
3//! Two URL forms are tried:
4//!   - `https://storage.googleapis.com/{name}/`  (path-style)
5//!   - `https://{name}.storage.googleapis.com/`  (vhost-style)
6//!
7//! Also probes for unauthenticated write access via an unsigned PUT,
8//! matching the depth of the S3 probe.
9
10use async_trait::async_trait;
11use gossan_core::Target;
12use secfinding::{Evidence, Finding, Severity};
13
14use crate::common::is_xml_listing;
15use crate::provider::CloudProvider;
16/// Google Cloud Storage bucket discovery.
17pub struct GcsProvider;
18
19#[async_trait]
20impl CloudProvider for GcsProvider {
21    fn name(&self) -> &'static str {
22        "gcs"
23    }
24
25    fn endpoint(&self, name: &str) -> String {
26        format!("https://{}.storage.googleapis.com/", name)
27    }
28
29    async fn probe(
30        &self,
31        client: &reqwest::Client,
32        name: &str,
33        target: &Target,
34    ) -> anyhow::Result<Vec<Finding>> {
35        let vhost = self.endpoint(name);
36        let path = format!("https://storage.googleapis.com/{}/", name);
37
38        let mut urls = vec![vhost.clone()];
39        if vhost.contains("googleapis.com") {
40            urls.push(path);
41        }
42
43        let mut findings = Vec::new();
44
45        for url in &urls {
46            let resp = match client.get(url).send().await {
47                Ok(r) => r,
48                Err(_) => continue,
49            };
50            let status = resp.status().as_u16();
51
52            match status {
53                200 => {
54                    let body = gossan_core::net::bounded_text(resp, 4 * 1024 * 1024).await.unwrap_or_default();
55                    gossan_core::try_push_finding(crate::finding_builder(
56                            target,
57                            Severity::Critical,
58                            format!("GCS bucket publicly listed: {}", name),
59                            format!(
60                                "gs://{} is publicly accessible and allows directory listing. \
61                                 Use `gsutil ls gs://{}` to enumerate objects without credentials.",
62                                name, name
63                            ),
64                        )
65                        .evidence(Evidence::HttpResponse {
66                            status,
67                            headers: vec![("url".into(), url.clone().into())],
68                            body_excerpt: if is_xml_listing(&body) {
69                                Some(body.chars().take(300).collect::<String>().into())
70                            } else {
71                                None
72                            },
73                        })
74                        .tag("gcs")
75                        .tag("cloud")
76                        .tag("exposure")
77                        .exploit_hint(format!(
78                            "# List objects:\ngsutil ls gs://{}\n\
79                             # Download everything:\ngsutil -m cp -r gs://{}/* .",
80                            name, name
81                        )), &mut findings);
82                    try_write(client, name, url, target, &mut findings).await;
83                    break; // found — no need to try second URL form
84                }
85                403 => {
86                    gossan_core::try_push_finding(crate::finding_builder(
87                            target,
88                            Severity::Low,
89                            format!("GCS bucket exists (access denied): {}", name),
90                            format!(
91                                "gs://{} exists but is not publicly accessible (HTTP 403).",
92                                name
93                            ),
94                        )
95                        .evidence(Evidence::HttpResponse {
96                            status,
97                            headers: vec![("url".into(), url.clone().into())],
98                            body_excerpt: None,
99                        })
100                        .tag("gcs")
101                        .tag("cloud"), &mut findings);
102                    try_write(client, name, url, target, &mut findings).await;
103                    break;
104                }
105                _ => {}
106            }
107        }
108
109        Ok(findings)
110    }
111}
112
113/// Attempt an unauthenticated PUT to GCS. On success: Critical finding + cleanup.
114async fn try_write(
115    client: &reqwest::Client,
116    bucket: &str,
117    base_url: &str,
118    target: &Target,
119    findings: &mut Vec<Finding>,
120) {
121    const PROBE_KEY: &str = "gossan-write-probe-delete-me.txt";
122    // GCS simple upload via XML API
123    let put_url = if base_url.contains("storage.googleapis.com/")
124        && !base_url.starts_with("https://storage")
125    {
126        format!("https://{}.storage.googleapis.com/{}", bucket, PROBE_KEY)
127    } else {
128        format!("https://storage.googleapis.com/{}/{}", bucket, PROBE_KEY)
129    };
130
131    let Ok(resp) = client
132        .put(&put_url)
133        .header("content-type", "text/plain")
134        .body("gossan-security-probe — safe to delete")
135        .send()
136        .await
137    else {
138        return;
139    };
140
141    let status = resp.status().as_u16();
142    if matches!(status, 200 | 204) {
143        let _ = client.delete(&put_url).send().await;
144        gossan_core::try_push_finding(crate::finding_builder(
145                target,
146                Severity::Critical,
147                format!("GCS bucket writable without authentication: {}", bucket),
148                format!(
149                    "An unauthenticated PUT to gs://{}/{} succeeded (HTTP {}). \
150                     The `allUsers: WRITER` IAM binding is set — any attacker can upload files. \
151                     Probe object deleted immediately after confirmation.",
152                    bucket, PROBE_KEY, status
153                ),
154            )
155            .evidence(Evidence::HttpResponse {
156                status,
157                headers: vec![("url".into(), put_url.into())],
158                body_excerpt: None,
159            })
160            .tag("gcs")
161            .tag("cloud")
162            .tag("file-upload")
163            .tag("exposure"), findings);
164    }
165}