mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Add GitHub Actions OIDC token acquisition to the responses API proxy (#55297)
## What changed Add a `GithubOidc` implementation of `SubjectTokenSource` that requests a fresh token using a bearer credential read from stdin. Validate HTTPS request URLs and replace the audience query parameter while preserving other parameters. CLI integration is not included. Add a token HTTP client that disables redirects and request logging, applies timeouts, and bounds response bodies. Keep token response buffers in zeroizing storage and exclude credentials, request URLs, and response bodies from errors. Generalize the locked stdin credential reader for bounded OIDC bearer tokens while preserving API key validation. ## Testing Add tests for fresh token acquisition, short reads, credential size limits, injection rejection, redirect refusal, and credential-safe errors and logs. Extend API key tests to retain the stricter character restrictions. GitOrigin-RevId: fd7767178f475b8161734f29d29108c795839185
This commit is contained in:
Generated
+7
@@ -4477,14 +4477,20 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"codex-http-client",
|
||||
"codex-process-hardening",
|
||||
"ctor 0.6.3",
|
||||
"http 1.4.0",
|
||||
"libc",
|
||||
"pretty_assertions",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-test",
|
||||
"url",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -17867,6 +17873,7 @@ version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
|
||||
@@ -19,14 +19,20 @@ workspace = true
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-http-client = { workspace = true }
|
||||
codex-process-hardening = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
http = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tiny_http = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt", "time", "net"] }
|
||||
url = { workspace = true }
|
||||
zeroize = { workspace = true, features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-test = { workspace = true, features = ["no-env-filter"] }
|
||||
|
||||
@@ -1,9 +1,76 @@
|
||||
//! Supplies sensitive request headers without copying the locked API key.
|
||||
//! Token clients never follow redirects or emit credential-bearing URL diagnostics.
|
||||
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Result;
|
||||
use reqwest::header::HeaderValue;
|
||||
use anyhow::anyhow;
|
||||
use codex_http_client::ClientRouteClass;
|
||||
use codex_http_client::HttpClient;
|
||||
use codex_http_client::HttpClientBuilder;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_http_client::HttpResponse;
|
||||
use codex_http_client::OutboundProxyPolicy;
|
||||
use codex_http_client::RouteAwareClientPool;
|
||||
use http::HeaderValue;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::time::Duration;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))]
|
||||
pub(crate) const MAX_TOKEN_BYTES: usize = 16 * 1024;
|
||||
#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))]
|
||||
const MAX_RESPONSE_BYTES: usize = 64 * 1024;
|
||||
#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))]
|
||||
pub(crate) const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Supplies a fresh JWT whenever an OpenAI credential needs to be issued.
|
||||
/// Acquisition stays asynchronous so callers can compose it with the exchange.
|
||||
/// Implementations must not include tokens, credential-bearing URLs, or upstream
|
||||
/// response bodies in errors. The exchange deliberately has no issuer-specific config.
|
||||
#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))]
|
||||
pub(crate) trait SubjectTokenSource: Send {
|
||||
fn subject_token<'a>(&'a mut self, client: &'a TokenClient) -> SubjectTokenFuture<'a>;
|
||||
}
|
||||
|
||||
// Keep the source usable as a trait object while acquisition runs asynchronously.
|
||||
pub(crate) type SubjectTokenFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = Result<Zeroizing<String>>> + Send + 'a>>;
|
||||
|
||||
/// Credential requests must not follow redirects or log URLs or response headers.
|
||||
/// The private field prevents other modules from wrapping arbitrary HTTP clients.
|
||||
#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))]
|
||||
pub(crate) struct TokenClient(HttpClient);
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))]
|
||||
impl TokenClient {
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "federation creates the routed client in the next stack layer"
|
||||
)]
|
||||
pub(crate) fn new() -> Self {
|
||||
// The standalone proxy does not load Codex's session config. For its
|
||||
// opt-in auth flow, resolve system and environment proxy settings per URL.
|
||||
let factory = HttpClientFactory::new(OutboundProxyPolicy::RespectSystemProxy);
|
||||
Self(
|
||||
RouteAwareClientPool::with_builder(factory, ClientRouteClass::Auth, Self::builder())
|
||||
.into_client(),
|
||||
)
|
||||
}
|
||||
|
||||
fn builder() -> HttpClientBuilder {
|
||||
HttpClientBuilder::new()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.without_redirects()
|
||||
.without_request_logging()
|
||||
}
|
||||
|
||||
pub(crate) fn http(&self) -> &HttpClient {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum Auth {
|
||||
ApiKey(&'static str),
|
||||
@@ -44,6 +111,35 @@ impl Auth {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))]
|
||||
pub(crate) async fn read_response<T: DeserializeOwned>(
|
||||
mut response: HttpResponse,
|
||||
source: &str,
|
||||
) -> Result<T> {
|
||||
if !response.status().is_success() {
|
||||
// Never read or log the error body; it may echo a credential. Redirects are
|
||||
// failures too. HTTP errors can contain the URL, so don't attach them.
|
||||
return Err(anyhow!(
|
||||
"{source} token request rejected (HTTP {})",
|
||||
response.status().as_u16()
|
||||
));
|
||||
}
|
||||
// Keep secret-bearing response bytes in one allocation, including on errors.
|
||||
let mut body = Zeroizing::new(Vec::with_capacity(MAX_RESPONSE_BYTES));
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|_| anyhow!("{source} returned an unreadable token response"))?
|
||||
{
|
||||
if chunk.len() > MAX_RESPONSE_BYTES - body.len() {
|
||||
return Err(anyhow!("{source} returned an oversized token response"));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
serde_json::from_slice(&body)
|
||||
.map_err(|_| anyhow!("{source} returned an invalid token response"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "auth_tests.rs"]
|
||||
mod tests;
|
||||
pub(crate) mod tests;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
//! Ensures authentication diagnostics do not expose credentials.
|
||||
//! Credential-safe diagnostics and transport fixtures for the production token-client policy.
|
||||
|
||||
use super::Auth;
|
||||
use super::TokenClient;
|
||||
|
||||
pub(crate) fn direct_token_client() -> TokenClient {
|
||||
TokenClient(TokenClient::builder().build_direct().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_api_keys_in_both_formats() {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Obtains fresh GitHub Actions OIDC subject tokens for the federated exchange.
|
||||
//! The stdin request credential and minted tokens must never appear in diagnostics.
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use http::header::AUTHORIZATION;
|
||||
use http::header::HeaderValue;
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::auth::MAX_TOKEN_BYTES;
|
||||
use crate::auth::SubjectTokenFuture;
|
||||
use crate::auth::SubjectTokenSource;
|
||||
use crate::auth::TOKEN_REQUEST_TIMEOUT;
|
||||
use crate::auth::TokenClient;
|
||||
use crate::auth::read_response;
|
||||
use crate::read_api_key::AUTH_HEADER_PREFIX;
|
||||
use crate::read_api_key::read_auth_header_with;
|
||||
use crate::read_api_key::read_from_stdin;
|
||||
|
||||
// Allow space for "Bearer " and a trailing CRLF.
|
||||
const REQUEST_AUTH_BUFFER_SIZE: usize = AUTH_HEADER_PREFIX.len() + MAX_TOKEN_BYTES + 2;
|
||||
|
||||
// Intentionally no Debug: request_auth contains a credential.
|
||||
pub(crate) struct GithubOidc {
|
||||
request_url: Url,
|
||||
request_auth: HeaderValue,
|
||||
}
|
||||
|
||||
impl GithubOidc {
|
||||
#[expect(dead_code, reason = "CLI is wired in a later stack layer")]
|
||||
pub(crate) fn from_stdin(request_url: &str, audience: &str) -> Result<Self> {
|
||||
// Avoid echoing caller-supplied URL parameters in parsing errors.
|
||||
let mut request_url =
|
||||
Url::parse(request_url).map_err(|_| anyhow!("invalid GitHub OIDC request URL"))?;
|
||||
if request_url.scheme() != "https"
|
||||
|| request_url.host_str().is_none()
|
||||
|| !request_url.username().is_empty()
|
||||
|| request_url.password().is_some()
|
||||
|| request_url.fragment().is_some()
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"GitHub OIDC request URL must be HTTPS without user info or fragment"
|
||||
));
|
||||
}
|
||||
// Replace any existing audience; preserve GitHub's other query parameters.
|
||||
let pairs: Vec<(String, String)> = request_url
|
||||
.query_pairs()
|
||||
.filter(|(key, _)| key != "audience")
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
request_url
|
||||
.query_pairs_mut()
|
||||
.clear()
|
||||
.extend_pairs(pairs)
|
||||
.append_pair("audience", audience);
|
||||
Ok(Self {
|
||||
request_url,
|
||||
request_auth: read_request_auth(read_from_stdin)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SubjectTokenSource for GithubOidc {
|
||||
fn subject_token<'a>(&'a mut self, client: &'a TokenClient) -> SubjectTokenFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let response = client
|
||||
.http()
|
||||
.get(self.request_url.clone())
|
||||
.timeout(TOKEN_REQUEST_TIMEOUT)
|
||||
.header(AUTHORIZATION, self.request_auth.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| anyhow!("GitHub OIDC token request failed"))?;
|
||||
let response: GithubResponse = read_response(response, "GitHub OIDC").await?;
|
||||
Ok(response.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GithubResponse {
|
||||
value: Zeroizing<String>,
|
||||
}
|
||||
|
||||
// The launcher must remove ACTIONS_ID_TOKEN_REQUEST_TOKEN from its environment
|
||||
// before exec. This header is retained for the lifetime of the proxy.
|
||||
fn read_request_auth<F>(mut read: F) -> Result<HeaderValue>
|
||||
where
|
||||
F: FnMut(&mut [u8]) -> std::io::Result<usize>,
|
||||
{
|
||||
let read_credential = |buf: &mut [u8]| {
|
||||
read(buf).map_err(|_| {
|
||||
std::io::Error::other("could not read GitHub OIDC request credential from stdin")
|
||||
})
|
||||
};
|
||||
let value = read_auth_header_with::<REQUEST_AUTH_BUFFER_SIZE>(
|
||||
read_credential,
|
||||
validate_github_request_token,
|
||||
|| anyhow!("GitHub OIDC request credential too large"),
|
||||
)?;
|
||||
let mut header = HeaderValue::from_static(value);
|
||||
header.set_sensitive(true);
|
||||
Ok(header)
|
||||
}
|
||||
|
||||
fn validate_github_request_token(token: &[u8]) -> Result<usize> {
|
||||
let token = token.strip_suffix(b"\n").unwrap_or(token);
|
||||
let token = token.strip_suffix(b"\r").unwrap_or(token);
|
||||
// RFC 6750 permits JWT periods; reject whitespace and control bytes.
|
||||
if token.is_empty()
|
||||
|| token.len() > MAX_TOKEN_BYTES
|
||||
|| !token.iter().all(|byte| {
|
||||
byte.is_ascii_alphanumeric()
|
||||
|| matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/' | b'=')
|
||||
})
|
||||
{
|
||||
return Err(anyhow!("invalid workload identity bearer credential"));
|
||||
}
|
||||
Ok(token.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "github_oidc_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,184 @@
|
||||
use super::*;
|
||||
use crate::auth::tests::direct_token_client;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::io::Cursor;
|
||||
use std::io::Read;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tiny_http::Header;
|
||||
use tiny_http::Server;
|
||||
use tiny_http::StatusCode;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
#[test]
|
||||
fn stdin_accepts_bounded_bearer_tokens_in_short_reads_and_rejects_injection() {
|
||||
let token = format!("{}.payload.sig", "a".repeat(2000));
|
||||
let input = format!("{token}\r\n");
|
||||
let mut cursor = Cursor::new(input.as_bytes());
|
||||
let header = read_request_auth(|buf| {
|
||||
let len = buf.len().min(3);
|
||||
cursor.read(&mut buf[..len])
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(header.to_str().unwrap(), format!("Bearer {token}"));
|
||||
assert!(header.is_sensitive());
|
||||
for token in ["", "a\rb", "a b", "a\0b", "x:y", "💥", "a\r\r", "a\r\r\n"] {
|
||||
let mut cursor = Cursor::new(token.as_bytes());
|
||||
assert!(read_request_auth(|buf| cursor.read(buf)).is_err());
|
||||
}
|
||||
for suffix in ["", "\n", "\r\n"] {
|
||||
let max_token = "a".repeat(MAX_TOKEN_BYTES);
|
||||
let input = format!("{max_token}{suffix}");
|
||||
let mut cursor = Cursor::new(input.as_bytes());
|
||||
let header = read_request_auth(|buf| cursor.read(buf)).unwrap();
|
||||
assert_eq!(header.to_str().unwrap(), format!("Bearer {max_token}"));
|
||||
|
||||
let input = format!("a{max_token}{suffix}");
|
||||
let mut cursor = Cursor::new(input.as_bytes());
|
||||
assert!(read_request_auth(|buf| cursor.read(buf)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_read_error_cannot_echo_a_github_credential() {
|
||||
let mut sent = false;
|
||||
let error = read_request_auth(|buf| {
|
||||
if sent {
|
||||
return Err(std::io::Error::other("private.github.request"));
|
||||
}
|
||||
sent = true;
|
||||
buf[..3].copy_from_slice(b"abc");
|
||||
Ok(3)
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
format!("{error:#}"),
|
||||
"could not read GitHub OIDC request credential from stdin"
|
||||
);
|
||||
}
|
||||
|
||||
fn server() -> (Server, Url) {
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
let url = Url::parse(&format!("http://{}", server.server_addr())).unwrap();
|
||||
(server, url)
|
||||
}
|
||||
|
||||
fn source_at(url: &Url) -> GithubOidc {
|
||||
let mut cursor = Cursor::new(b"private.github.request");
|
||||
GithubOidc {
|
||||
request_url: url.join("/github").unwrap(),
|
||||
request_auth: read_request_auth(|buf| cursor.read(buf)).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn client() -> (TokenClient, Runtime) {
|
||||
let client = direct_token_client();
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
(client, runtime)
|
||||
}
|
||||
|
||||
fn recv(server: &Server) -> tiny_http::Request {
|
||||
server
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.unwrap()
|
||||
.expect("expected HTTP request")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obtains_fresh_subject_tokens_using_github_request_credential() {
|
||||
let (server, url) = server();
|
||||
let worker = thread::spawn(move || {
|
||||
for token in ["github.first.jwt", "github.second.jwt"] {
|
||||
let req = recv(&server);
|
||||
assert_eq!(req.url(), "/github");
|
||||
assert_eq!(req.method(), &tiny_http::Method::Get);
|
||||
assert!(req.headers().iter().any(|h| h.field.equiv("Authorization")
|
||||
&& h.value.as_str() == "Bearer private.github.request"));
|
||||
req.respond(
|
||||
tiny_http::Response::from_string(json!({"value": token}).to_string())
|
||||
.with_header(Header::from_bytes("Content-Type", "application/json").unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let mut source: Box<dyn SubjectTokenSource> = Box::new(source_at(&url));
|
||||
let (client, runtime) = client();
|
||||
runtime.block_on(async {
|
||||
assert_eq!(
|
||||
source.subject_token(&client).await.unwrap().as_str(),
|
||||
"github.first.jwt"
|
||||
);
|
||||
assert_eq!(
|
||||
source.subject_token(&client).await.unwrap().as_str(),
|
||||
"github.second.jwt"
|
||||
);
|
||||
});
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[tracing_test::traced_test]
|
||||
fn rejected_subject_does_not_log_response_or_follow_redirect() {
|
||||
tracing::debug!(target: "codex_http_client::client", "credential log capture sentinel");
|
||||
let (server, url) = server();
|
||||
let target = url.join("/credential-leak").unwrap();
|
||||
let worker = thread::spawn(move || {
|
||||
let req = recv(&server);
|
||||
assert_eq!(req.url(), "/github?opaque=private.github.url");
|
||||
req.respond(
|
||||
tiny_http::Response::from_string("private.github.request")
|
||||
.with_status_code(StatusCode(302))
|
||||
.with_header(Header::from_bytes("Location", target.as_str()).unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
server
|
||||
.recv_timeout(Duration::from_millis(200))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
});
|
||||
let (client, runtime) = client();
|
||||
let mut source = source_at(&url);
|
||||
source
|
||||
.request_url
|
||||
.set_query(Some("opaque=private.github.url"));
|
||||
let error = format!(
|
||||
"{:#}",
|
||||
runtime.block_on(source.subject_token(&client)).unwrap_err()
|
||||
);
|
||||
assert!(error.contains("302"), "{error}");
|
||||
assert!(!error.contains("private.github.request"), "{error}");
|
||||
assert!(!error.contains("private.github.url"), "{error}");
|
||||
assert!(!error.contains(url.as_str()), "{error}");
|
||||
worker.join().unwrap();
|
||||
assert!(logs_contain("credential log capture sentinel"));
|
||||
assert!(!logs_contain("private.github.url"));
|
||||
assert!(!logs_contain("private.github.request"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_cannot_echo_a_secret_in_diagnostics() {
|
||||
let (server, url) = server();
|
||||
let worker = thread::spawn(move || {
|
||||
recv(&server)
|
||||
.respond(tiny_http::Response::from_string(
|
||||
r#"{"value": private.github.request}"#,
|
||||
))
|
||||
.unwrap();
|
||||
});
|
||||
let (client, runtime) = client();
|
||||
let error = format!(
|
||||
"{:#}",
|
||||
runtime
|
||||
.block_on(source_at(&url).subject_token(&client))
|
||||
.unwrap_err()
|
||||
);
|
||||
assert!(error.contains("invalid token response"), "{error}");
|
||||
assert!(!error.contains("private.github.request"), "{error}");
|
||||
worker.join().unwrap();
|
||||
}
|
||||
@@ -30,6 +30,8 @@ use tiny_http::StatusCode;
|
||||
|
||||
mod auth;
|
||||
mod dump;
|
||||
#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))]
|
||||
mod github_oidc;
|
||||
mod read_api_key;
|
||||
use auth::Auth;
|
||||
use dump::ExchangeDumper;
|
||||
|
||||
@@ -6,14 +6,22 @@ use zeroize::Zeroize;
|
||||
/// Use a generous buffer size to avoid truncation and to allow for longer API
|
||||
/// keys in the future.
|
||||
const BUFFER_SIZE: usize = 1024;
|
||||
const AUTH_HEADER_PREFIX: &[u8] = b"Bearer ";
|
||||
pub(crate) const AUTH_HEADER_PREFIX: &[u8] = b"Bearer ";
|
||||
|
||||
/// Reads the auth token from stdin and returns a static `Authorization` header
|
||||
/// value with the auth token used with `Bearer`. The header value is returned
|
||||
/// as a `&'static str` whose bytes are locked in memory to avoid accidental
|
||||
/// exposure.
|
||||
pub(crate) fn read_auth_header_from_stdin() -> Result<&'static str> {
|
||||
read_auth_header_with(read_from_stdin)
|
||||
read_api_key_with(read_from_stdin)
|
||||
}
|
||||
|
||||
fn read_api_key_with(
|
||||
read_fn: impl FnMut(&mut [u8]) -> std::io::Result<usize>,
|
||||
) -> Result<&'static str> {
|
||||
read_auth_header_with::<BUFFER_SIZE>(read_fn, validate_auth_header_bytes, || {
|
||||
anyhow!("API key is too large to fit in the {BUFFER_SIZE}-byte buffer")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -68,18 +76,23 @@ pub(crate) fn read_from_stdin(buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_auth_header_with<F>(mut read_fn: F) -> Result<&'static str>
|
||||
where
|
||||
F: FnMut(&mut [u8]) -> std::io::Result<usize>,
|
||||
{
|
||||
/// Reads and locks one permanent credential. The validator receives the raw
|
||||
/// token line and returns the length of its validated prefix after trimming.
|
||||
/// The buffer size includes the Bearer prefix and any line terminators.
|
||||
/// Do not use for renewable tokens: the allocation lives until process exit.
|
||||
pub(crate) fn read_auth_header_with<const N: usize>(
|
||||
mut read_fn: impl FnMut(&mut [u8]) -> std::io::Result<usize>,
|
||||
validate: impl FnOnce(&[u8]) -> Result<usize>,
|
||||
buffer_full_error: impl FnOnce() -> anyhow::Error,
|
||||
) -> Result<&'static str> {
|
||||
// TAKE CARE WHEN MODIFYING THIS CODE!!!
|
||||
//
|
||||
// This function goes to great lengths to avoid leaving the API key in
|
||||
// This function goes to great lengths to avoid leaving the credential in
|
||||
// memory longer than necessary and to avoid copying it around. We read
|
||||
// directly into a stack buffer so the only heap allocation should be the
|
||||
// one to create the String (with the exact size) for the header value,
|
||||
// which we then immediately protect with mlock(2).
|
||||
let mut buf = [0u8; BUFFER_SIZE];
|
||||
let mut buf = [0u8; N];
|
||||
buf[..AUTH_HEADER_PREFIX.len()].copy_from_slice(AUTH_HEADER_PREFIX);
|
||||
|
||||
let prefix_len = AUTH_HEADER_PREFIX.len();
|
||||
@@ -119,32 +132,21 @@ where
|
||||
// If buffer filled and we did not see newline or EOF, error out.
|
||||
if total_read == capacity && !saw_newline && !saw_eof {
|
||||
buf.zeroize();
|
||||
return Err(anyhow!(
|
||||
"API key is too large to fit in the {BUFFER_SIZE}-byte buffer"
|
||||
));
|
||||
return Err(buffer_full_error());
|
||||
}
|
||||
|
||||
let mut total = prefix_len + total_read;
|
||||
while total > prefix_len && (buf[total - 1] == b'\n' || buf[total - 1] == b'\r') {
|
||||
total -= 1;
|
||||
}
|
||||
|
||||
if total == AUTH_HEADER_PREFIX.len() {
|
||||
buf.zeroize();
|
||||
return Err(anyhow!(
|
||||
"API key must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)"
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(err) = validate_auth_header_bytes(&buf[AUTH_HEADER_PREFIX.len()..total]) {
|
||||
buf.zeroize();
|
||||
return Err(err);
|
||||
}
|
||||
let total = match validate(&buf[prefix_len..prefix_len + total_read]) {
|
||||
Ok(len) => prefix_len + len,
|
||||
Err(err) => {
|
||||
buf.zeroize();
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
let header_str = match std::str::from_utf8(&buf[..total]) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
// In theory, validate_auth_header_bytes() should have caught
|
||||
// In theory, the validator should have caught
|
||||
// any invalid UTF-8 sequences, but just in case...
|
||||
buf.zeroize();
|
||||
return Err(err).context("reading Authorization header from stdin as UTF-8");
|
||||
@@ -161,7 +163,7 @@ where
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn mlock_str(value: &str) {
|
||||
fn mlock_str(value: &'static str) {
|
||||
use libc::_SC_PAGESIZE;
|
||||
use libc::c_void;
|
||||
use libc::mlock;
|
||||
@@ -200,16 +202,27 @@ fn mlock_str(value: &str) {
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn mlock_str(_value: &str) {}
|
||||
fn mlock_str(_value: &'static str) {}
|
||||
|
||||
/// The key should match /^[A-Za-z0-9\-_]+$/. Ensure there is no funny business
|
||||
/// with NUL characters and whatnot.
|
||||
fn validate_auth_header_bytes(key_bytes: &[u8]) -> Result<()> {
|
||||
fn validate_auth_header_bytes(mut key_bytes: &[u8]) -> Result<usize> {
|
||||
while key_bytes
|
||||
.last()
|
||||
.is_some_and(|byte| matches!(byte, b'\n' | b'\r'))
|
||||
{
|
||||
key_bytes = &key_bytes[..key_bytes.len() - 1];
|
||||
}
|
||||
if key_bytes.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"API key must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)"
|
||||
));
|
||||
}
|
||||
if key_bytes
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
{
|
||||
return Ok(());
|
||||
return Ok(key_bytes.len());
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
@@ -226,7 +239,7 @@ mod tests {
|
||||
#[test]
|
||||
fn reads_key_with_no_newlines() {
|
||||
let mut sent = false;
|
||||
let result = read_auth_header_with(|buf| {
|
||||
let result = read_api_key_with(|buf| {
|
||||
if sent {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -244,7 +257,7 @@ mod tests {
|
||||
fn reads_key_with_short_reads() {
|
||||
let mut chunks: VecDeque<&[u8]> =
|
||||
VecDeque::from(vec![b"sk-".as_ref(), b"abc".as_ref(), b"123\n".as_ref()]);
|
||||
let result = read_auth_header_with(|buf| match chunks.pop_front() {
|
||||
let result = read_api_key_with(|buf| match chunks.pop_front() {
|
||||
Some(chunk) if !chunk.is_empty() => {
|
||||
buf[..chunk.len()].copy_from_slice(chunk);
|
||||
Ok(chunk.len())
|
||||
@@ -259,7 +272,7 @@ mod tests {
|
||||
#[test]
|
||||
fn reads_key_and_trims_newlines() {
|
||||
let mut sent = false;
|
||||
let result = read_auth_header_with(|buf| {
|
||||
let result = read_api_key_with(|buf| {
|
||||
if sent {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -275,14 +288,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn errors_when_no_input_provided() {
|
||||
let err = read_auth_header_with(|_| Ok(0)).unwrap_err();
|
||||
let err = read_api_key_with(|_| Ok(0)).unwrap_err();
|
||||
let message = format!("{err:#}");
|
||||
assert!(message.contains("must be provided"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_when_buffer_filled() {
|
||||
let err = read_auth_header_with(|buf| {
|
||||
let err = read_api_key_with(|buf| {
|
||||
let data = vec![b'a'; BUFFER_SIZE - AUTH_HEADER_PREFIX.len()];
|
||||
buf[..data.len()].copy_from_slice(&data);
|
||||
Ok(data.len())
|
||||
@@ -296,7 +309,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn propagates_io_error() {
|
||||
let err = read_auth_header_with(|_| Err(io::Error::other("boom"))).unwrap_err();
|
||||
let err = read_api_key_with(|_| Err(io::Error::other("boom"))).unwrap_err();
|
||||
|
||||
let io_error = err.downcast_ref::<io::Error>().unwrap();
|
||||
assert_eq!(io_error.kind(), io::ErrorKind::Other);
|
||||
@@ -306,7 +319,7 @@ mod tests {
|
||||
#[test]
|
||||
fn errors_on_invalid_utf8() {
|
||||
let mut sent = false;
|
||||
let err = read_auth_header_with(|buf| {
|
||||
let err = read_api_key_with(|buf| {
|
||||
if sent {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -323,19 +336,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn errors_on_invalid_characters() {
|
||||
let mut sent = false;
|
||||
let err = read_auth_header_with(|buf| {
|
||||
if sent {
|
||||
return Ok(0);
|
||||
}
|
||||
let data = b"sk-abc!23";
|
||||
buf[..data.len()].copy_from_slice(data);
|
||||
sent = true;
|
||||
Ok(data.len())
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
let message = format!("{err:#}");
|
||||
assert!(message.contains("API key may only contain ASCII letters, numbers, '-' or '_'"));
|
||||
for data in [
|
||||
b"sk-abc!23".as_slice(),
|
||||
b"sk.abc123",
|
||||
b"sk-abc+123",
|
||||
b"sk-abc=123",
|
||||
] {
|
||||
let mut sent = false;
|
||||
let err = read_api_key_with(|buf| {
|
||||
if sent {
|
||||
return Ok(0);
|
||||
}
|
||||
buf[..data.len()].copy_from_slice(data);
|
||||
sent = true;
|
||||
Ok(data.len())
|
||||
})
|
||||
.unwrap_err();
|
||||
let message = format!("{err:#}");
|
||||
assert!(
|
||||
message.contains("API key may only contain ASCII letters, numbers, '-' or '_'")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user