Skip to content

Commit ef20a89

Browse files
Dan GardnerTurbo87
Dan Gardner
authored andcommitted
Add API endpoint for GitHub secret alerts
1 parent 59e4fd4 commit ef20a89

File tree

9 files changed

+405
-5
lines changed

9 files changed

+405
-5
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ tracing = "=0.1.37"
8585
tracing-logfmt = "=0.2.0"
8686
tracing-subscriber = { version = "=0.3.16", features = ["env-filter"] }
8787
url = "=2.3.1"
88+
ring = "=0.16.20"
8889

8990
[dev-dependencies]
9091
cargo-registry-index = { path = "cargo-registry-index", features = ["testing"] }

src/controllers/token.rs

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
use super::frontend_prelude::*;
22

3-
use crate::models::ApiToken;
3+
use crate::github::key_from_spki;
4+
use crate::models::{ApiToken, User};
45
use crate::schema::api_tokens;
56
use crate::util::read_fill;
67
use crate::views::EncodableApiTokenWithToken;
78

9+
use base64;
810
use conduit::{Body, Response};
11+
use ring::signature;
912
use serde_json as json;
1013

1114
/// Handles the `GET /me/tokens` route.
@@ -114,3 +117,133 @@ pub fn revoke_current(req: &mut dyn RequestExt) -> EndpointResult {
114117

115118
Ok(Response::builder().status(204).body(Body::empty()).unwrap())
116119
}
120+
121+
/// Verifies that the GitHub signature in request headers is valid
122+
fn verify_github_signature(req: &dyn RequestExt, json: &[u8]) -> Result<(), Box<dyn AppError>> {
123+
// Read and decode request headers
124+
let headers = req.headers();
125+
let req_key_id = headers
126+
.get("GITHUB-PUBLIC-KEY-IDENTIFIER")
127+
.ok_or_else(|| bad_request("missing HTTP header: GITHUB-PUBLIC-KEY-IDENTIFIER"))?
128+
.to_str()
129+
.map_err(|e| bad_request(&format!("failed to decode HTTP header: {e:?}")))?;
130+
let sig = headers
131+
.get("GITHUB-PUBLIC-KEY-SIGNATURE")
132+
.ok_or_else(|| bad_request("missing HTTP header: GITHUB-PUBLIC-KEY-SIGNATURE"))?;
133+
let sig = base64::decode(sig)
134+
.map_err(|e| bad_request(&format!("failed to decode signature as base64: {e:?}")))?;
135+
136+
// Fetch list of public keys from GitHub API
137+
let app = req.app();
138+
let public_keys = app
139+
.github
140+
.public_keys(&app.config.gh_client_id, &app.config.gh_client_secret)
141+
.map_err(|e| bad_request(&format!("failed to fetch GitHub public keys: {e:?}")))?;
142+
143+
for key in public_keys {
144+
if key.key_identifier == req_key_id {
145+
if !key.is_current {
146+
return Err(bad_request(&format!(
147+
"key id {req_key_id} is not a current key"
148+
)));
149+
}
150+
let key_bytes =
151+
key_from_spki(&key).map_err(|_| bad_request("cannot parse public key"))?;
152+
let gh_key =
153+
signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_ASN1, &key_bytes);
154+
155+
return match gh_key.verify(json, &sig) {
156+
Ok(v) => {
157+
info!(
158+
"GitHub secret alert request validated with key id {}",
159+
key.key_identifier
160+
);
161+
Ok(v)
162+
}
163+
Err(e) => Err(bad_request(&format!("invalid signature: {e:?}"))),
164+
};
165+
}
166+
}
167+
168+
return Err(bad_request(&format!("unknown key id {req_key_id}")));
169+
}
170+
171+
#[derive(Deserialize, Serialize)]
172+
struct GitHubSecretAlert {
173+
token: String,
174+
r#type: String,
175+
url: String,
176+
source: String,
177+
}
178+
179+
/// Revokes an API token and notifies the token owner
180+
fn alert_revoke_token(
181+
req: &dyn RequestExt,
182+
alert: &GitHubSecretAlert,
183+
) -> Result<(), Box<dyn AppError>> {
184+
let conn = req.db_write()?;
185+
// not using ApiToken::find_by_api_token in order to preserve last_used_at
186+
let token = api_tokens::table
187+
.filter(api_tokens::token.eq(alert.token.as_bytes()))
188+
.first::<ApiToken>(&*conn)?;
189+
190+
diesel::update(&token)
191+
.set(api_tokens::revoked.eq(true))
192+
.execute(&*conn)?;
193+
194+
// send email notification to the token owner
195+
let user = User::find(&conn, token.user_id)?;
196+
info!(
197+
"Revoked API token '{}' for user {} ({})",
198+
alert.token, user.gh_login, user.id
199+
);
200+
match user.email(&conn)? {
201+
None => {
202+
info!(
203+
"No email address for user {} ({}), cannot send email notification",
204+
user.gh_login, user.id
205+
);
206+
Ok(())
207+
}
208+
Some(email) => req.app().emails.send_token_exposed_notification(
209+
&email,
210+
&alert.url,
211+
"GitHub",
212+
&alert.source,
213+
&token.name,
214+
),
215+
}
216+
}
217+
218+
/// Handles the `POST /tokens/alert/github` route.
219+
pub fn alert_github(req: &mut dyn RequestExt) -> EndpointResult {
220+
let max_size = 8192;
221+
let length = req
222+
.content_length()
223+
.ok_or_else(|| bad_request("missing header: Content-Length"))?;
224+
225+
if length > max_size {
226+
return Err(bad_request(&format!("max content length is: {max_size}")));
227+
}
228+
229+
let mut json = vec![0; length as usize];
230+
read_fill(req.body(), &mut json)?;
231+
verify_github_signature(req, &json)
232+
.map_err(|e| bad_request(&format!("failed to verify request signature: {e:?}")))?;
233+
234+
let json = String::from_utf8(json)
235+
.map_err(|e| bad_request(&format!("failed to decode request body: {e:?}")))?;
236+
let alerts: Vec<GitHubSecretAlert> = json::from_str(&json)
237+
.map_err(|e| bad_request(&format!("invalid secret alert request: {e:?}")))?;
238+
239+
for alert in alerts {
240+
if let Err(e) = alert_revoke_token(req, &alert) {
241+
warn!(
242+
"Error revoking API token in GitHub secret alert: {} ({e:?})",
243+
alert.token
244+
);
245+
}
246+
}
247+
248+
Ok(req.json(&json!({})))
249+
}

src/email.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,33 @@ or go to https://{domain}/me/pending-invites to manage all of your crate ownersh
9191
self.send(email, subject, &body)
9292
}
9393

94+
/// Attempts to send an API token exposure notification email
95+
pub fn send_token_exposed_notification(
96+
&self,
97+
email: &str,
98+
url: &str,
99+
reporter: &str,
100+
source: &str,
101+
token_name: &str,
102+
) -> AppResult<()> {
103+
let subject = "Exposed API token found";
104+
let mut body = format!(
105+
"{reporter} has notified us that your crates.io API token {token_name}\n
106+
has been exposed publicly. We have revoked this token as a precaution.\n
107+
Please review your account at https://{domain} to confirm that no\n
108+
unexpected changes have been made to your settings or crates.\n
109+
\n
110+
Source type: {source}\n",
111+
domain = crate::config::domain_name()
112+
);
113+
if url.is_empty() {
114+
body.push_str("\nWe were not informed of the URL where the token was found.\n");
115+
} else {
116+
body.push_str(&format!("\nURL where the token was found: {url}\n"));
117+
}
118+
self.send(email, subject, &body)
119+
}
120+
94121
/// This is supposed to be used only during tests, to retrieve the messages stored in the
95122
/// "memory" backend. It's not cfg'd away because our integration tests need to access this.
96123
pub fn mails_in_memory(&self) -> Option<Vec<StoredEmail>> {

src/github.rs

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub trait GitHubClient: Send + Sync {
3232
username: &str,
3333
auth: &AccessToken,
3434
) -> AppResult<GitHubOrgMembership>;
35+
fn public_keys(&self, username: &str, password: &str) -> AppResult<Vec<GitHubPublicKey>>;
3536
}
3637

3738
#[derive(Debug)]
@@ -46,7 +47,7 @@ impl RealGitHubClient {
4647
}
4748

4849
/// Does all the nonsense for sending a GET to Github.
49-
pub fn request<T>(&self, url: &str, auth: &AccessToken) -> AppResult<T>
50+
fn _request<T>(&self, url: &str, auth: &str) -> AppResult<T>
5051
where
5152
T: DeserializeOwned,
5253
{
@@ -56,7 +57,7 @@ impl RealGitHubClient {
5657
self.client()
5758
.get(&url)
5859
.header(header::ACCEPT, "application/vnd.github.v3+json")
59-
.header(header::AUTHORIZATION, format!("token {}", auth.secret()))
60+
.header(header::AUTHORIZATION, auth)
6061
.header(header::USER_AGENT, "crates.io (https://crates.io)")
6162
.send()?
6263
.error_for_status()
@@ -65,6 +66,22 @@ impl RealGitHubClient {
6566
.map_err(Into::into)
6667
}
6768

69+
/// Sends a GET to GitHub using OAuth access token authentication
70+
pub fn request<T>(&self, url: &str, auth: &AccessToken) -> AppResult<T>
71+
where
72+
T: DeserializeOwned,
73+
{
74+
self._request(url, &format!("token {}", auth.secret()))
75+
}
76+
77+
/// Sends a GET to GitHub using basic authentication
78+
pub fn request_basic<T>(&self, url: &str, username: &str, password: &str) -> AppResult<T>
79+
where
80+
T: DeserializeOwned,
81+
{
82+
self._request(url, &format!("basic {}:{}", username, password))
83+
}
84+
6885
/// Returns a client for making HTTP requests to upload crate files.
6986
///
7087
/// The client will go through a proxy if the application was configured via
@@ -123,6 +140,18 @@ impl GitHubClient for RealGitHubClient {
123140
auth,
124141
)
125142
}
143+
144+
/// Returns the list of public keys that can be used to verify GitHub secret alert signatures
145+
fn public_keys(&self, username: &str, password: &str) -> AppResult<Vec<GitHubPublicKey>> {
146+
match self.request_basic::<GitHubPublicKeyList>(
147+
"/meta/public_keys/secret_scanning",
148+
username,
149+
password,
150+
) {
151+
Ok(v) => Ok(v.public_keys),
152+
Err(e) => Err(e),
153+
}
154+
}
126155
}
127156

128157
fn handle_error_response(error: &reqwest::Error) -> Box<dyn AppError> {
@@ -178,6 +207,18 @@ pub struct GitHubOrgMembership {
178207
pub role: String,
179208
}
180209

210+
#[derive(Debug, Deserialize)]
211+
pub struct GitHubPublicKey {
212+
pub key_identifier: String,
213+
pub key: String,
214+
pub is_current: bool,
215+
}
216+
217+
#[derive(Debug, Deserialize)]
218+
pub struct GitHubPublicKeyList {
219+
pub public_keys: Vec<GitHubPublicKey>,
220+
}
221+
181222
pub fn team_url(login: &str) -> String {
182223
let mut login_pieces = login.split(':');
183224
login_pieces.next();
@@ -186,3 +227,27 @@ pub fn team_url(login: &str) -> String {
186227
login_pieces.next().expect("org failed"),
187228
)
188229
}
230+
231+
static PEM_HEADER: &str = "-----BEGIN PUBLIC KEY-----\n";
232+
static PEM_FOOTER: &str = "\n-----END PUBLIC KEY-----";
233+
234+
/// Converts a PEM format ECDSA P-256 SHA-256 public key in SubjectPublicKeyInfo format into
235+
/// the Octet-String-to-Elliptic-Curve-Point format expected by ring::signature::verify
236+
pub fn key_from_spki(key: &GitHubPublicKey) -> Result<Vec<u8>, std::io::Error> {
237+
let start_idx = key
238+
.key
239+
.find(PEM_HEADER)
240+
.ok_or(std::io::ErrorKind::InvalidData)?;
241+
let gh_key = &key.key[(start_idx + PEM_HEADER.len())..];
242+
let end_idx = gh_key
243+
.find(PEM_FOOTER)
244+
.ok_or(std::io::ErrorKind::InvalidData)?;
245+
let gh_key = gh_key[..end_idx].replace('\n', "");
246+
let gh_key = base64::decode(gh_key)
247+
.map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidData))?;
248+
if gh_key.len() != 91 {
249+
return Err(std::io::Error::from(std::io::ErrorKind::InvalidData));
250+
}
251+
// extract the key bytes from the fixed position in the ASN.1 structure
252+
Ok(gh_key[26..91].to_vec())
253+
}

src/router.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ pub fn build_router(app: &App) -> RouteBuilder {
113113
router.put("/api/v1/me/tokens", C(token::new));
114114
router.delete("/api/v1/me/tokens/:id", C(token::revoke));
115115
router.delete("/api/v1/tokens/current", C(token::revoke_current));
116+
router.post("/api/v1/tokens/alert/github", C(token::alert_github));
116117
router.get(
117118
"/api/v1/me/crate_owner_invitations",
118119
C(crate_owner_invitation::list),

0 commit comments

Comments
 (0)