] [--http-shutdown]
- Only `POST /v1/responses` is permitted. No query strings are allowed.
- All request headers are forwarded to the upstream call (aside from overriding `Authorization`). Response status and content-type are mirrored from upstream.
+
+## Hardening Details
+
+Care is taken to restrict access/copying to the value of `OPENAI_API_KEY` retained in memory:
+
+- We leverage [`codex_process_hardening`](https://github.com/openai/codex/blob/main/codex-rs/process-hardening/README.md) so `codex-responses-api-proxy` is run with standard process-hardening techniques.
+- At startup, we allocate a `1024` byte buffer on the stack and write `"Bearer "` as the first `7` bytes.
+- We then read from `stdin`, copying the contents into the buffer after `"Bearer "`.
+- After verifying the key matches `/^[a-zA-Z0-9_-]+$/` (and does not exceed the buffer), we create a `String` from that buffer (so the data is now on the heap).
+- We zero out the stack-allocated buffer using https://crates.io/crates/zeroize so it is not optimized away by the compiler.
+- We invoke `.leak()` on the `String` so we can treat its contents as a `&'static str`, as it will live for the rest of the process.
+- On UNIX, we `mlock(2)` the memory backing the `&'static str`.
+- When using the `&'static str` when building an HTTP request, we use `HeaderValue::from_static()` to avoid copying the `&str`.
+- We also invoke `.set_sensitive(true)` on the `HeaderValue`, which in theory indicates to other parts of the HTTP stack that the header should be treated with "special care" to avoid leakage:
+
+https://github.com/hyperium/http/blob/439d1c50d71e3be3204b6c4a1bf2255ed78e1f93/src/header/value.rs#L346-L376
diff --git a/codex-rs/responses-api-proxy/npm/README.md b/codex-rs/responses-api-proxy/npm/README.md
new file mode 100644
index 00000000..3458e527
--- /dev/null
+++ b/codex-rs/responses-api-proxy/npm/README.md
@@ -0,0 +1,13 @@
+# @openai/codex-responses-api-proxy
+
+npm i -g @openai/codex-responses-api-proxy to install codex-responses-api-proxy
+
+This package distributes the prebuilt [Codex Responses API proxy binary](https://github.com/openai/codex/tree/main/codex-rs/responses-api-proxy) for macOS, Linux, and Windows.
+
+To see available options, run:
+
+```
+node ./bin/codex-responses-api-proxy.js --help
+```
+
+Refer to [`codex-rs/responses-api-proxy/README.md`](https://github.com/openai/codex/blob/main/codex-rs/responses-api-proxy/README.md) for detailed documentation.
diff --git a/codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js b/codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js
new file mode 100755
index 00000000..e2c3ee7d
--- /dev/null
+++ b/codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+// Entry point for the Codex responses API proxy binary.
+
+import { spawn } from "node:child_process";
+import path from "path";
+import { fileURLToPath } from "url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+function determineTargetTriple(platform, arch) {
+ switch (platform) {
+ case "linux":
+ case "android":
+ if (arch === "x64") {
+ return "x86_64-unknown-linux-musl";
+ }
+ if (arch === "arm64") {
+ return "aarch64-unknown-linux-musl";
+ }
+ break;
+ case "darwin":
+ if (arch === "x64") {
+ return "x86_64-apple-darwin";
+ }
+ if (arch === "arm64") {
+ return "aarch64-apple-darwin";
+ }
+ break;
+ case "win32":
+ if (arch === "x64") {
+ return "x86_64-pc-windows-msvc";
+ }
+ if (arch === "arm64") {
+ return "aarch64-pc-windows-msvc";
+ }
+ break;
+ default:
+ break;
+ }
+ return null;
+}
+
+const targetTriple = determineTargetTriple(process.platform, process.arch);
+if (!targetTriple) {
+ throw new Error(
+ `Unsupported platform: ${process.platform} (${process.arch})`,
+ );
+}
+
+const vendorRoot = path.join(__dirname, "..", "vendor");
+const archRoot = path.join(vendorRoot, targetTriple);
+const binaryBaseName = "codex-responses-api-proxy";
+const binaryPath = path.join(
+ archRoot,
+ binaryBaseName,
+ process.platform === "win32" ? `${binaryBaseName}.exe` : binaryBaseName,
+);
+
+const child = spawn(binaryPath, process.argv.slice(2), {
+ stdio: "inherit",
+});
+
+child.on("error", (err) => {
+ console.error(err);
+ process.exit(1);
+});
+
+const forwardSignal = (signal) => {
+ if (!child.killed) {
+ try {
+ child.kill(signal);
+ } catch {
+ /* ignore */
+ }
+ }
+};
+
+["SIGINT", "SIGTERM", "SIGHUP"].forEach((sig) => {
+ process.on(sig, () => forwardSignal(sig));
+});
+
+const childResult = await new Promise((resolve) => {
+ child.on("exit", (code, signal) => {
+ if (signal) {
+ resolve({ type: "signal", signal });
+ } else {
+ resolve({ type: "code", exitCode: code ?? 1 });
+ }
+ });
+});
+
+if (childResult.type === "signal") {
+ process.kill(process.pid, childResult.signal);
+} else {
+ process.exit(childResult.exitCode);
+}
diff --git a/codex-rs/responses-api-proxy/npm/package.json b/codex-rs/responses-api-proxy/npm/package.json
new file mode 100644
index 00000000..f3956a77
--- /dev/null
+++ b/codex-rs/responses-api-proxy/npm/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@openai/codex-responses-api-proxy",
+ "version": "0.0.0-dev",
+ "license": "Apache-2.0",
+ "bin": {
+ "codex-responses-api-proxy": "bin/codex-responses-api-proxy.js"
+ },
+ "type": "module",
+ "engines": {
+ "node": ">=16"
+ },
+ "files": [
+ "bin",
+ "vendor"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/openai/codex.git",
+ "directory": "codex-rs/responses-api-proxy/npm"
+ }
+}
diff --git a/codex-rs/responses-api-proxy/src/read_api_key.rs b/codex-rs/responses-api-proxy/src/read_api_key.rs
index 8ffad2aa..1dda92f3 100644
--- a/codex-rs/responses-api-proxy/src/read_api_key.rs
+++ b/codex-rs/responses-api-proxy/src/read_api_key.rs
@@ -54,9 +54,16 @@ where
));
}
+ if let Err(err) = validate_auth_header_bytes(&buf[AUTH_HEADER_PREFIX.len()..total]) {
+ 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
+ // any invalid UTF-8 sequences, but just in case...
buf.zeroize();
return Err(err).context("reading Authorization header from stdin as UTF-8");
}
@@ -113,6 +120,21 @@ fn mlock_str(value: &str) {
#[cfg(not(unix))]
fn mlock_str(_value: &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<()> {
+ if key_bytes
+ .iter()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
+ {
+ return Ok(());
+ }
+
+ Err(anyhow!(
+ "OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'"
+ ))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -158,7 +180,7 @@ mod tests {
})
.unwrap_err();
let message = format!("{err:#}");
- assert!(message.contains("too large"));
+ assert!(message.contains("OPENAI_API_KEY is too large to fit in the 512-byte buffer"));
}
#[test]
@@ -180,6 +202,23 @@ mod tests {
.unwrap_err();
let message = format!("{err:#}");
- assert!(message.contains("UTF-8"));
+ assert!(
+ message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'")
+ );
+ }
+
+ #[test]
+ fn errors_on_invalid_characters() {
+ let err = read_auth_header_with(|buf| {
+ let data = b"sk-abc!23";
+ buf[..data.len()].copy_from_slice(data);
+ Ok(data.len())
+ })
+ .unwrap_err();
+
+ let message = format!("{err:#}");
+ assert!(
+ message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'")
+ );
}
}