Skip to main content

max / makenotwork

2.9 KB · 70 lines History Blame Raw
1 //! Going the other way: a stored URL back to a key, and a key forward to a
2 //! public URL.
3
4 /// Extract the S3 key from a CDN or presigned URL.
5 ///
6 /// Accepts two URL shapes:
7 /// - **CDN**: `https://cdn.example.com/{s3_key}`, caller supplies the
8 /// CDN base; the function strips it verbatim.
9 /// - **Path-style S3**: `https://{host}/{bucket}/{s3_key}?...`, caller
10 /// supplies the bucket name; the function strips host + bucket prefix.
11 ///
12 /// Returns `None` if neither prefix matches. Query strings (presigned URL
13 /// signatures) are stripped before returning.
14 ///
15 /// **Why explicit prefixes**: the prior implementation used
16 /// `find("projects/")` as a heuristic, which would silently mis-key any URL
17 /// whose path happened to contain the literal substring (e.g. a key with a
18 /// `projects/` suffix inside a user folder). Passing the known CDN base and
19 /// bucket eliminates the heuristic entirely.
20 pub fn extract_s3_key_from_url(
21 url: &str,
22 cdn_base: &str,
23 bucket: Option<&str>,
24 s3_endpoint: Option<&str>,
25 ) -> Option<String> {
26 let no_query = url.split('?').next()?;
27
28 // Try CDN-base prefix first. An empty base matches nothing rather than
29 // matching everything: `strip_prefix("")` succeeds on any input, so the
30 // guard is what keeps a caller that passes "" from harvesting a key out of
31 // an arbitrary host.
32 if !cdn_base.is_empty() {
33 let base = cdn_base.trim_end_matches('/');
34 if let Some(rest) = no_query.strip_prefix(base)
35 && let Some(key) = rest.strip_prefix('/')
36 && !key.is_empty()
37 {
38 return Some(key.to_string());
39 }
40 }
41
42 // Path-style S3: must match the configured `{endpoint}/{bucket}/` exactly.
43 // Without the endpoint pin, the prior implementation accepted any
44 // `https://{any-host}/{bucket}/{key}`, so an attacker-controlled URL like
45 // `https://attacker.example/my-bucket/poisoned` would extract a real-looking
46 // key and direct downstream code at attacker-chosen storage paths.
47 if let (Some(bucket), Some(endpoint)) = (bucket, s3_endpoint) {
48 let endpoint = endpoint.trim_end_matches('/');
49 let prefix = format!("{endpoint}/{bucket}/");
50 if let Some(key) = no_query.strip_prefix(&prefix)
51 && !key.is_empty()
52 {
53 return Some(key.to_string());
54 }
55 }
56
57 None
58 }
59
60 /// Build a permanent URL for a project image.
61 ///
62 /// Permanent is the whole contract: callers persist the result into
63 /// `projects.cover_image_url`, which is read forever. There is deliberately no
64 /// presigned fallback — an expiring URL in a durable column is the bug this
65 /// signature exists to make unrepresentable. `cdn_base` is required config
66 /// (`Config::cdn_base_url`), so there is nothing to fall back to.
67 pub fn build_project_image_url(cdn_base: &str, s3_key: &str) -> String {
68 format!("{cdn_base}/{s3_key}")
69 }
70