cred: blind RSA signatures and stage T28 security regressions (WIP)
This commit is contained in:
Generated
+1
@@ -7843,6 +7843,7 @@ version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_yaml_ng = "0.10.0"
|
||||
rustls = "0.23"
|
||||
chacha20poly1305 = "0.10"
|
||||
zeroize = "1"
|
||||
zeroize = { version = "1", features = ["serde"] }
|
||||
hex = "0.4"
|
||||
http = "1"
|
||||
url = "2"
|
||||
|
||||
+63
-6
@@ -190,12 +190,7 @@ pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result<Vec<u8
|
||||
let s: p256::ecdsa::Signature = k.sign(payload);
|
||||
s.to_bytes().to_vec()
|
||||
}
|
||||
Key::Rsa(k) => if format == "rsa-sha2-512" {
|
||||
k.sign(rsa::Pkcs1v15Sign::new::<Sha512>(), &Sha512::digest(payload))
|
||||
} else {
|
||||
k.sign(rsa::Pkcs1v15Sign::new::<Sha256>(), &Sha256::digest(payload))
|
||||
}
|
||||
.map_err(|_| invalid_key())?,
|
||||
Key::Rsa(k) => rsa_signature(k, format, payload, &mut rsa::rand_core::OsRng)?,
|
||||
Key::Mac(k) => {
|
||||
macro_rules! mac {
|
||||
($h:ty) => {{
|
||||
@@ -212,6 +207,28 @@ pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result<Vec<u8
|
||||
}
|
||||
})
|
||||
}
|
||||
fn rsa_signature(
|
||||
key: &rsa::RsaPrivateKey,
|
||||
format: &str,
|
||||
payload: &[u8],
|
||||
rng: &mut impl rsa::rand_core::CryptoRngCore,
|
||||
) -> Result<Vec<u8>> {
|
||||
if format == "rsa-sha2-512" {
|
||||
key.sign_with_rng(
|
||||
rng,
|
||||
rsa::Pkcs1v15Sign::new::<Sha512>(),
|
||||
&Sha512::digest(payload),
|
||||
)
|
||||
} else {
|
||||
key.sign_with_rng(
|
||||
rng,
|
||||
rsa::Pkcs1v15Sign::new::<Sha256>(),
|
||||
&Sha256::digest(payload),
|
||||
)
|
||||
}
|
||||
.map_err(|_| invalid_key())
|
||||
}
|
||||
|
||||
fn string(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(bytes);
|
||||
@@ -303,3 +320,43 @@ pub(super) fn public(key: &Key, output: &str) -> Result<String> {
|
||||
_ => return Err(error("invalid_params", "unknown public key output")),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod blinding_tests {
|
||||
use super::*;
|
||||
use rsa::rand_core::{CryptoRng, OsRng, RngCore};
|
||||
|
||||
struct Counting(usize);
|
||||
impl CryptoRng for Counting {}
|
||||
impl RngCore for Counting {
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
self.0 += 4;
|
||||
OsRng.next_u32()
|
||||
}
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.0 += 8;
|
||||
OsRng.next_u64()
|
||||
}
|
||||
fn fill_bytes(&mut self, dest: &mut [u8]) {
|
||||
self.0 += dest.len();
|
||||
OsRng.fill_bytes(dest);
|
||||
}
|
||||
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> std::result::Result<(), rsa::rand_core::Error> {
|
||||
self.0 += dest.len();
|
||||
OsRng.try_fill_bytes(dest)
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rsa_uses_blinding_randomness() {
|
||||
let key = rsa::RsaPrivateKey::new(&mut OsRng, 1024).unwrap();
|
||||
let mut rng = Counting(0);
|
||||
for format in ["rs256", "rsa-sha2-256", "rsa-sha2-512"] {
|
||||
let before = rng.0;
|
||||
rsa_signature(&key, format, b"synthetic", &mut rng).unwrap();
|
||||
assert!(
|
||||
rng.0 > before,
|
||||
"RSA signing must consume blinding randomness"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+139
-29
@@ -1,6 +1,11 @@
|
||||
//! OAuth2 refresh exchange and a process-local cache keyed by item and version.
|
||||
//! OAuth2 refresh exchange, endpoint policy, and a revision-aware process-local cache.
|
||||
use super::{
|
||||
items::{Item, rule_matches},
|
||||
types::FieldType,
|
||||
};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
@@ -14,13 +19,18 @@ impl std::fmt::Display for Error {
|
||||
}
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
/// HTTP adapter. Implementations must reject unsuccessful HTTP status codes.
|
||||
/// A single HTTP exchange. Adapters must honor the deadline and never follow redirects.
|
||||
pub trait Http: Sync {
|
||||
fn send(
|
||||
fn send_once(
|
||||
&self,
|
||||
request: &Request,
|
||||
deadline: Instant,
|
||||
) -> impl std::future::Future<Output = Result<Zeroizing<Vec<u8>>, Error>> + Send;
|
||||
) -> impl std::future::Future<Output = Result<Response, Error>> + Send;
|
||||
}
|
||||
/// The status and body of one response, without any redirect exchange.
|
||||
pub struct Response {
|
||||
pub status: http::StatusCode,
|
||||
pub body: Zeroizing<Vec<u8>>,
|
||||
}
|
||||
/// Contains credentials. Deliberately does not implement Debug or Serialize.
|
||||
pub struct Request {
|
||||
@@ -44,22 +54,39 @@ pub fn request(
|
||||
{
|
||||
return Err(Error);
|
||||
}
|
||||
let body = url::form_urlencoded::Serializer::new(String::new())
|
||||
.append_pair("grant_type", "refresh_token")
|
||||
.append_pair("refresh_token", refresh_token)
|
||||
.append_pair("client_id", client_id)
|
||||
.append_pair("client_secret", client_secret)
|
||||
.finish();
|
||||
let body = Zeroizing::new(
|
||||
url::form_urlencoded::Serializer::new(String::new())
|
||||
.append_pair("grant_type", "refresh_token")
|
||||
.append_pair("refresh_token", refresh_token)
|
||||
.append_pair("client_id", client_id)
|
||||
.append_pair("client_secret", client_secret)
|
||||
.finish(),
|
||||
);
|
||||
Ok(Request {
|
||||
url: token_url.into(),
|
||||
url: target.into(),
|
||||
method: "POST",
|
||||
headers: vec![
|
||||
("Content-Type", "application/x-www-form-urlencoded".into()),
|
||||
("Content-Length", body.len().to_string()),
|
||||
],
|
||||
body: Zeroizing::new(body),
|
||||
body,
|
||||
})
|
||||
}
|
||||
fn allowed(item: &Item, request: &Request) -> bool {
|
||||
let Ok(target) = url::Url::parse(&request.url) else {
|
||||
return false;
|
||||
};
|
||||
target.scheme() == "https"
|
||||
&& target.username().is_empty()
|
||||
&& target.password().is_none()
|
||||
&& target.fragment().is_none()
|
||||
&& request.method == "POST"
|
||||
&& target.host_str().is_some_and(|host| {
|
||||
item.rules
|
||||
.iter()
|
||||
.any(|rule| rule_matches(rule, target.scheme(), host, target.path(), "POST"))
|
||||
})
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct Token {
|
||||
pub value: Zeroizing<String>,
|
||||
@@ -69,51 +96,134 @@ pub struct Token {
|
||||
pub fn decode(bytes: &[u8]) -> Result<Token, Error> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Response {
|
||||
access_token: String,
|
||||
access_token: Zeroizing<String>,
|
||||
token_type: String,
|
||||
expires_in: u64,
|
||||
scope: Option<String>,
|
||||
}
|
||||
let r: Response = serde_json::from_slice(bytes).map_err(|_| Error)?;
|
||||
// Validate header bytes without a second, non-zeroizing token allocation.
|
||||
if r.access_token.is_empty()
|
||||
|| !r.token_type.eq_ignore_ascii_case("bearer")
|
||||
|| r.expires_in == 0
|
||||
|| http::header::HeaderValue::from_str(&format!("Bearer {}", r.access_token)).is_err()
|
||||
|| r.access_token
|
||||
.bytes()
|
||||
.any(|b| b != b'\t' && (b < 32 || b == 127))
|
||||
{
|
||||
return Err(Error);
|
||||
}
|
||||
Ok(Token {
|
||||
value: Zeroizing::new(r.access_token),
|
||||
value: r.access_token,
|
||||
scopes: r
|
||||
.scope
|
||||
.map(|s| s.split_whitespace().map(str::to_owned).collect()),
|
||||
expires: Instant::now() + Duration::from_secs(r.expires_in.min(86400).saturating_sub(30)),
|
||||
})
|
||||
}
|
||||
/// The caller owns synchronization and endpoint policy. No lock crosses an HTTP call here.
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
tokens: BTreeMap<(String, i64), Token>,
|
||||
busy: BTreeSet<String>,
|
||||
epoch: u64,
|
||||
}
|
||||
/// No mutex guard crosses HTTP await. Clear this cache on vault lock or item changes.
|
||||
#[derive(Default)]
|
||||
pub struct Cache {
|
||||
tokens: BTreeMap<(String, i64), Token>,
|
||||
state: Mutex<State>,
|
||||
}
|
||||
struct Flight<'a> {
|
||||
cache: &'a Cache,
|
||||
item: String,
|
||||
}
|
||||
impl Drop for Flight<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut state) = self.cache.state.lock() {
|
||||
state.busy.remove(&self.item);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Cache {
|
||||
pub fn clear(&mut self) {
|
||||
self.tokens.clear();
|
||||
pub fn clear(&self) -> Result<(), Error> {
|
||||
let mut state = self.state.lock().map_err(|_| Error)?;
|
||||
state.tokens.clear();
|
||||
state.epoch += 1;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn refresh(
|
||||
&mut self,
|
||||
item: &str,
|
||||
version: i64,
|
||||
&self,
|
||||
item: &Item,
|
||||
request: &Request,
|
||||
http: &impl Http,
|
||||
deadline: Instant,
|
||||
) -> Result<Token, Error> {
|
||||
let key = (item.to_owned(), version);
|
||||
if let Some(t) = self.tokens.get(&key).filter(|t| t.expires > Instant::now()) {
|
||||
return Ok(t.clone());
|
||||
// Baseline preserves the old unchecked policy until the regression runs.
|
||||
let _ = allowed(item, request);
|
||||
if Instant::now() >= deadline {
|
||||
return Err(Error);
|
||||
}
|
||||
self.tokens.retain(|(id, _), _| id != item);
|
||||
let token = decode(&http.send(request, deadline).await?)?;
|
||||
self.tokens.insert(key, token.clone());
|
||||
let key = (item.id.clone(), item.version);
|
||||
let epoch;
|
||||
{
|
||||
let mut state = self.state.lock().map_err(|_| Error)?;
|
||||
if let Some(t) = state
|
||||
.tokens
|
||||
.get(&key)
|
||||
.filter(|t| t.expires > Instant::now())
|
||||
{
|
||||
return Ok(t.clone());
|
||||
}
|
||||
state.tokens.retain(|(id, _), _| id != &item.id);
|
||||
if !state.busy.insert(item.id.clone()) {
|
||||
return Err(Error);
|
||||
}
|
||||
epoch = state.epoch;
|
||||
}
|
||||
let _flight = Flight {
|
||||
cache: self,
|
||||
item: item.id.clone(),
|
||||
};
|
||||
let response = http.send_once(request, deadline).await?;
|
||||
let token = decode(&response.body)?;
|
||||
let mut state = self.state.lock().map_err(|_| Error)?;
|
||||
if state.epoch != epoch || Instant::now() >= deadline {
|
||||
return Err(Error);
|
||||
}
|
||||
state.tokens.insert(key, token.clone());
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Injection entry point. Replace the refresh field before any transform sees these values.
|
||||
/// The caller must reserve any additional grant use before this exchange.
|
||||
pub async fn fields_for_injection(
|
||||
&self,
|
||||
item: &Item,
|
||||
mut fields: BTreeMap<String, Zeroizing<String>>,
|
||||
http: &impl Http,
|
||||
deadline: Instant,
|
||||
) -> Result<BTreeMap<String, Zeroizing<String>>, Error> {
|
||||
let mut refresh = item
|
||||
.fields
|
||||
.iter()
|
||||
.filter(|f| f.field_type == FieldType::Oauth2RefreshToken);
|
||||
let field = refresh.next().ok_or(Error)?;
|
||||
if field.public || refresh.next().is_some() {
|
||||
return Err(Error);
|
||||
}
|
||||
let value = |name: &str| {
|
||||
fields
|
||||
.get(name)
|
||||
.map(|s| s.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or(Error)
|
||||
};
|
||||
let req = request(
|
||||
value("token_url")?,
|
||||
value(&field.name)?,
|
||||
value("client_id")?,
|
||||
value("client_secret")?,
|
||||
)?;
|
||||
let token = self.refresh(item, &req, http, deadline).await?;
|
||||
fields.insert(field.name.clone(), token.value);
|
||||
Ok(fields)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-8
@@ -1,6 +1,7 @@
|
||||
//! Fresh, bounded V8 isolates for credential scripts. Each call owns a dedicated worker thread.
|
||||
use super::script::{Error, ErrorKind, Limits, Script};
|
||||
use deno_core::{JsRuntime, RuntimeOptions, v8};
|
||||
use zeroize::Zeroizing;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
@@ -34,7 +35,7 @@ impl Script for V8 {
|
||||
limits: Limits,
|
||||
) -> Result<String, Error> {
|
||||
let source = source.to_owned();
|
||||
let input = input.to_owned();
|
||||
let input = Zeroizing::new(input.to_owned());
|
||||
let export = export.unwrap_or("default").to_owned();
|
||||
std::thread::spawn(move || evaluate(source, input, export, limits))
|
||||
.join()
|
||||
@@ -43,7 +44,7 @@ impl Script for V8 {
|
||||
}
|
||||
fn evaluate(
|
||||
source: String,
|
||||
input: String,
|
||||
input: Zeroizing<String>,
|
||||
export: String,
|
||||
limits: Limits,
|
||||
) -> Result<String, Error> {
|
||||
@@ -96,12 +97,13 @@ fn evaluate(
|
||||
runtime
|
||||
.execute_script("sandbox.js", BOOTSTRAP)
|
||||
.map_err(|_| failure(ErrorKind::Engine))?;
|
||||
let input = runtime
|
||||
.execute_script(
|
||||
"input.js",
|
||||
format!("JSON.parse({})", serde_json::to_string(&input).unwrap()),
|
||||
)
|
||||
.map_err(|_| failure(ErrorKind::Syntax))?;
|
||||
let input = {
|
||||
deno_core::scope!(scope, &mut runtime);
|
||||
v8::tc_scope!(let scope, scope);
|
||||
let text = v8::String::new(scope, &input).ok_or_else(|| failure(ErrorKind::Limit))?;
|
||||
let value = v8::json::parse(scope, text).ok_or_else(|| failure(ErrorKind::Syntax))?;
|
||||
v8::Global::new(scope, value)
|
||||
};
|
||||
let spec = deno_core::ModuleSpecifier::parse("file:///credential.js").unwrap();
|
||||
let module = runtime
|
||||
.load_main_es_module_from_code(&spec, source)
|
||||
@@ -197,3 +199,14 @@ globalThis.atob = value => {
|
||||
return out;
|
||||
};
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn script_input_zeroizes_on_drop() {
|
||||
let _: fn(String, zeroize::Zeroizing<String>, String, Limits) -> Result<String, Error> =
|
||||
evaluate;
|
||||
}
|
||||
}
|
||||
|
||||
+222
-23
@@ -1,16 +1,36 @@
|
||||
//! Refresh form encoding, response validation, and revision-aware cache behavior.
|
||||
use sausage::cred::oauth2::*;
|
||||
//! OAuth endpoint policy, single-hop exchanges, cache ownership, and safe injection fields.
|
||||
use sausage::cred::{items::Item, oauth2::*};
|
||||
use std::{
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
collections::BTreeMap,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
fn item(version: i64) -> Item {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id":"id", "name":"OAuth", "version":version,
|
||||
"rules":[{"scheme":"https","hosts":["tokens.example.test"],"path_prefixes":["/token"],"methods":["POST"]}],
|
||||
"transform_js":"", "fields":[{"field_id":"refresh","name":"refresh_token","public":false,"value":null,"field_type":"oauth2_refresh_token","key_type":null}]
|
||||
})).unwrap()
|
||||
}
|
||||
fn deadline() -> Instant {
|
||||
Instant::now() + Duration::from_secs(1)
|
||||
}
|
||||
fn ok(bytes: &[u8]) -> Response {
|
||||
Response {
|
||||
status: http::StatusCode::OK,
|
||||
body: Zeroizing::new(bytes.to_vec()),
|
||||
}
|
||||
}
|
||||
struct Fake(AtomicUsize);
|
||||
impl Http for Fake {
|
||||
async fn send(&self, request: &Request, _: Instant) -> Result<Zeroizing<Vec<u8>>, Error> {
|
||||
async fn send_once(&self, request: &Request, _: Instant) -> Result<Response, Error> {
|
||||
assert_eq!(request.method, "POST");
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Zeroizing::new(br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600,"scope":"one two"}"#.to_vec()))
|
||||
Ok(ok(br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600,"scope":"one two"}"#))
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
@@ -28,16 +48,27 @@ async fn encoding_and_cache() {
|
||||
);
|
||||
assert_eq!(req.headers[1].1, req.body.len().to_string());
|
||||
let fake = Fake(AtomicUsize::new(0));
|
||||
let mut cache = Cache::default();
|
||||
let deadline = Instant::now() + Duration::from_secs(1);
|
||||
let t = cache.refresh("id", 1, &req, &fake, deadline).await.unwrap();
|
||||
let cache = Cache::default();
|
||||
let t = cache
|
||||
.refresh(&item(1), &req, &fake, deadline())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(t.scopes.unwrap(), vec!["one", "two"]);
|
||||
cache.refresh("id", 1, &req, &fake, deadline).await.unwrap();
|
||||
cache
|
||||
.refresh(&item(1), &req, &fake, deadline())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fake.0.load(Ordering::SeqCst), 1);
|
||||
cache.refresh("id", 2, &req, &fake, deadline).await.unwrap();
|
||||
cache
|
||||
.refresh(&item(2), &req, &fake, deadline())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fake.0.load(Ordering::SeqCst), 2);
|
||||
cache.clear();
|
||||
cache.refresh("id", 2, &req, &fake, deadline).await.unwrap();
|
||||
cache.clear().unwrap();
|
||||
cache
|
||||
.refresh(&item(2), &req, &fake, deadline())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fake.0.load(Ordering::SeqCst), 3);
|
||||
for url in [
|
||||
"http://tokens.example.test/token",
|
||||
@@ -51,36 +82,204 @@ async fn encoding_and_cache() {
|
||||
async fn expiry_and_failure() {
|
||||
struct Expired(AtomicUsize);
|
||||
impl Http for Expired {
|
||||
async fn send(&self, _: &Request, _: Instant) -> Result<Zeroizing<Vec<u8>>, Error> {
|
||||
async fn send_once(&self, _: &Request, _: Instant) -> Result<Response, Error> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Zeroizing::new(
|
||||
br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":1}"#.to_vec(),
|
||||
Ok(ok(
|
||||
br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":1}"#,
|
||||
))
|
||||
}
|
||||
}
|
||||
struct Failed;
|
||||
impl Http for Failed {
|
||||
async fn send(&self, _: &Request, _: Instant) -> Result<Zeroizing<Vec<u8>>, Error> {
|
||||
async fn send_once(&self, _: &Request, _: Instant) -> Result<Response, Error> {
|
||||
Err(Error)
|
||||
}
|
||||
}
|
||||
let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
|
||||
let mut cache = Cache::default();
|
||||
let cache = Cache::default();
|
||||
let http = Expired(AtomicUsize::new(0));
|
||||
let deadline = Instant::now() + Duration::from_secs(1);
|
||||
cache.refresh("id", 1, &req, &http, deadline).await.unwrap();
|
||||
cache.refresh("id", 1, &req, &http, deadline).await.unwrap();
|
||||
cache
|
||||
.refresh(&item(1), &req, &http, deadline())
|
||||
.await
|
||||
.unwrap();
|
||||
cache
|
||||
.refresh(&item(1), &req, &http, deadline())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(http.0.load(Ordering::SeqCst), 2);
|
||||
assert!(
|
||||
cache
|
||||
.refresh("id", 2, &req, &Failed, deadline)
|
||||
.refresh(&item(2), &req, &Failed, deadline())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
cache.refresh("id", 2, &req, &http, deadline).await.unwrap();
|
||||
cache
|
||||
.refresh(&item(2), &req, &http, deadline())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(http.0.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn endpoint_policy_applies_before_http_and_cache_hits() {
|
||||
let cache = Cache::default();
|
||||
let fake = Fake(AtomicUsize::new(0));
|
||||
let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
|
||||
cache
|
||||
.refresh(&item(1), &req, &fake, deadline())
|
||||
.await
|
||||
.unwrap();
|
||||
for rule in [
|
||||
serde_json::json!({"scheme":"https","hosts":["wrong.example.test"],"path_prefixes":["/token"],"methods":["POST"]}),
|
||||
serde_json::json!({"scheme":"http","hosts":["tokens.example.test"],"path_prefixes":["/token"],"methods":["POST"]}),
|
||||
serde_json::json!({"scheme":"https","hosts":["tokens.example.test"],"path_prefixes":["/elsewhere"],"methods":["POST"]}),
|
||||
serde_json::json!({"scheme":"https","hosts":["tokens.example.test"],"path_prefixes":["/token"],"methods":["GET"]}),
|
||||
] {
|
||||
let mut denied = item(1);
|
||||
denied.rules = vec![serde_json::from_value(rule).unwrap()];
|
||||
assert!(
|
||||
cache
|
||||
.refresh(&denied, &req, &fake, deadline())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
denied.version = 2;
|
||||
assert!(
|
||||
cache
|
||||
.refresh(&denied, &req, &fake, deadline())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for url in [
|
||||
"http://tokens.example.test/token",
|
||||
"https://user@tokens.example.test/token",
|
||||
"https://tokens.example.test/token#x",
|
||||
"https://wrong.example.test/token",
|
||||
] {
|
||||
let mut altered = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
|
||||
altered.url = url.into();
|
||||
assert!(
|
||||
cache
|
||||
.refresh(&item(1), &altered, &fake, deadline())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let mut altered = req;
|
||||
altered.method = "GET";
|
||||
assert!(
|
||||
cache
|
||||
.refresh(&item(1), &altered, &fake, deadline())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(fake.0.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn rejects_redirects_without_a_second_exchange() {
|
||||
struct Redirect(AtomicUsize);
|
||||
impl Http for Redirect {
|
||||
async fn send_once(&self, _: &Request, _: Instant) -> Result<Response, Error> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
let mut response =
|
||||
ok(br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600}"#);
|
||||
response.status = http::StatusCode::FOUND;
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
let http = Redirect(AtomicUsize::new(0));
|
||||
let cache = Cache::default();
|
||||
let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
|
||||
assert!(
|
||||
cache
|
||||
.refresh(&item(1), &req, &http, deadline())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(http.0.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn injection_replaces_the_refresh_field() {
|
||||
let fields: BTreeMap<_, _> = [
|
||||
("token_url", "https://tokens.example.test/token"),
|
||||
("refresh_token", "REFRESH"),
|
||||
("client_id", "CLIENT"),
|
||||
("client_secret", "SECRET"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.into(), Zeroizing::new(v.into())))
|
||||
.collect();
|
||||
let fake = Fake(AtomicUsize::new(0));
|
||||
let result = Cache::default()
|
||||
.fields_for_injection(&item(1), fields, &fake, deadline())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result["refresh_token"].as_str() == "SYNTHETIC");
|
||||
assert!(result["client_secret"].as_str() == "SECRET");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn inflight_releases_the_lock_and_cleans_up() {
|
||||
struct Blocking {
|
||||
started: tokio::sync::Notify,
|
||||
resume: tokio::sync::Notify,
|
||||
}
|
||||
impl Http for Blocking {
|
||||
async fn send_once(&self, _: &Request, _: Instant) -> Result<Response, Error> {
|
||||
self.started.notify_one();
|
||||
self.resume.notified().await;
|
||||
Ok(ok(
|
||||
br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600}"#,
|
||||
))
|
||||
}
|
||||
}
|
||||
for clear in [false, true] {
|
||||
let cache = Arc::new(Cache::default());
|
||||
let http = Arc::new(Blocking {
|
||||
started: tokio::sync::Notify::new(),
|
||||
resume: tokio::sync::Notify::new(),
|
||||
});
|
||||
let task = tokio::spawn({
|
||||
let cache = cache.clone();
|
||||
let http = http.clone();
|
||||
async move {
|
||||
let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
|
||||
cache
|
||||
.refresh(&item(1), &req, http.as_ref(), deadline())
|
||||
.await
|
||||
}
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(1), http.started.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap();
|
||||
let fake = Fake(AtomicUsize::new(0));
|
||||
assert!(
|
||||
cache
|
||||
.refresh(&item(1), &req, &fake, deadline())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let mut other = item(1);
|
||||
other.id = "other".into();
|
||||
cache
|
||||
.refresh(&other, &req, &fake, deadline())
|
||||
.await
|
||||
.unwrap();
|
||||
if clear {
|
||||
cache.clear().unwrap();
|
||||
http.resume.notify_one();
|
||||
assert!(task.await.unwrap().is_err());
|
||||
} else {
|
||||
task.abort();
|
||||
assert!(matches!(task.await, Err(error) if error.is_cancelled()));
|
||||
}
|
||||
cache
|
||||
.refresh(&item(1), &req, &fake, deadline())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fake.0.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn validation() {
|
||||
for data in [
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Crash regression for a single large V8 allocation. The child contains native V8 aborts.
|
||||
use sausage::cred::{
|
||||
script::{Limits, Script},
|
||||
v8::V8,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn removes_bootstrap_capabilities_and_clocks() {
|
||||
let result = V8.evaluate(
|
||||
"export default () => [typeof WebAssembly,typeof SharedArrayBuffer,typeof Atomics,typeof __infra,typeof Intl,typeof queueMicrotask]",
|
||||
"null", None, Limits { max_time_ms: Some(1000), ..Limits::default() },
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
"[\"undefined\",\"undefined\",\"undefined\",\"undefined\",\"undefined\",\"undefined\"]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_string_child() {
|
||||
if std::env::var_os("T28_FLAT_STRING_CHILD").is_none() {
|
||||
return;
|
||||
}
|
||||
let bits = std::env::var("T28_FLAT_STRING_BITS").unwrap_or_else(|_| "28".into());
|
||||
assert!(matches!(bits.as_str(), "27" | "28"));
|
||||
let result = V8.evaluate(
|
||||
&format!("export default () => 'x'.repeat(2**{bits}).indexOf('y')"),
|
||||
"null",
|
||||
None,
|
||||
Limits {
|
||||
max_time_ms: Some(1000),
|
||||
..Limits::default()
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"the flat string bypassed the configured heap budget"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_string_does_not_abort() {
|
||||
for bits in ["27", "28"] {
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "flat_string_child", "--nocapture"])
|
||||
.env("T28_FLAT_STRING_CHILD", "1")
|
||||
.env("T28_FLAT_STRING_BITS", bits)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"the script worker aborted or failed at 2**{bits} bytes: {}",
|
||||
output.status
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user