Now I have all the data verified. Here is the complete security audit report:
Date: 2026-03-03
Scope: oauth3/ (Rust/Axum TEE authorization server) + Redacted/ (Next.js + CosmWasm frontend)
Auditor: Claude Code (Automated Static Analysis)
The Redacted platform combines a TEE-hosted OAuth 2.0 authorization server (oauth3) with a Next.js frontend (Redacted) and CosmWasm smart contracts on the XION blockchain to issue clearance badges based on Gmail content verification. The architecture is ambitious and generally well-designed, with strong cryptographic foundations (PKCE, HMAC sessions, encrypted cookies, TDX attestation, secp256r1 signature verification).
However, 2 critical, 4 high, 6 medium, 3 low, and 4 informational findings were identified. The most severe issues are an open-redirect default in is_trusted_redirect() and a placeholder OAuth mode that creates authenticated sessions without any identity verification. Both are in the oauth3 backend and could undermine the entire trust model.
Overall Risk Assessment: High — The critical findings in the oauth3 server could allow attackers to bypass authentication and redirect users to malicious origins. The smart contract and frontend are more robust, but the TEE attestation chain is only as strong as its weakest link.
| Field | Value |
|---|---|
| Severity | Critical |
| Location | oauth3/src/auth/oidc.rs:111-117 |
Description:
The is_trusted_redirect() function defaults to returning true (allow) when the ALLOWED_RETURN_TO_ORIGINS environment variable is not configured. This means any return_to URL will be accepted, enabling open redirect attacks.
// No allowlist configured — warn and allow for backwards compatibility
tracing::warn!(
return_to = %url,
"No ALLOWED_RETURN_TO_ORIGINS configured; allowing external redirect. \
Set ALLOWED_RETURN_TO_ORIGINS to restrict."
);
true // <-- ALLOWS ANY ORIGINImpact:
An attacker can craft a login URL with a malicious return_to parameter, causing the server to redirect authenticated users (with their HMAC session tokens) to an attacker-controlled domain. This enables session token theft and full account takeover. Since the HMAC token is appended to the redirect URL for trusted origins, the token would be delivered directly to the attacker.
Recommendation:
Default to false (deny) when no allowlist is configured. Fail closed, not open:
// No allowlist configured — deny by default
tracing::warn!("No ALLOWED_RETURN_TO_ORIGINS configured; rejecting external redirect.");
false| Field | Value |
|---|---|
| Severity | Critical |
| Location | oauth3/src/auth/oidc.rs:244-273 |
Description:
When the OIDC callback receives no authorization code, state, or session cookie (indicating a failed or absent OAuth flow), the handler falls through to a "placeholder" code path that creates a new user with a fabricated identity and issues a fully authenticated 60-minute session — with zero authentication performed.
// Placeholder behavior — no OAuth actually happened
let user_id = uuid::Uuid::new_v4().to_string();
session::set_session(&cookies, &state.cookie_key, &user_id, 60);
redirect_after_login(&cookies, &state.config, &user_id, "/").into_response()Impact:
Any unauthenticated request to the OIDC callback endpoint (e.g., GET /oauth3/auth/google/callback with no parameters) will receive a valid session cookie. An attacker can trivially obtain an authenticated session, potentially bypassing the entire OAuth flow and gaining access to the Gmail verification pipeline.
Recommendation:
Remove the placeholder fallback entirely or gate it behind a compile-time feature flag that is disabled in release builds:
#[cfg(feature = "dev-placeholder")]
{
// ... placeholder logic, only in dev
}
#[cfg(not(feature = "dev-placeholder"))]
{
return (StatusCode::BAD_REQUEST, "Missing authorization code").into_response();
}| Field | Value |
|---|---|
| Severity | High |
| Location | oauth3/src/web/handlers/account.rs:224-238 |
Description:
The generate_api_key() function uses rand::thread_rng(), a non-cryptographic PRNG, instead of OsRng (the OS-provided CSPRNG). All other security-sensitive random generation in the codebase correctly uses OsRng.
fn generate_api_key() -> String {
use rand::Rng;
let mut rng = rand::thread_rng(); // NOT cryptographically secure
let key: String = (0..KEY_LEN)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect();
format!("oak_{}", key)
}Impact:
thread_rng() may be predictable if an attacker can observe or influence system state. Generated API keys could be guessable, enabling unauthorized access to the proxy and any scoped operations.
Recommendation:
Replace with OsRng, consistent with the rest of the codebase:
use rand::rngs::OsRng;
let mut rng = OsRng;| Field | Value |
|---|---|
| Severity | High |
| Location | oauth3/src/web/session.rs:250-258 |
Description:
The is_expired() function returns false (not expired) when it cannot parse the timestamp string. This means malformed or corrupted expiry timestamps will be treated as valid, never-expiring tokens.
fn is_expired(timestamp: &str) -> bool {
if let Ok(expires) = time::OffsetDateTime::parse(
timestamp,
&time::format_description::well_known::Rfc3339,
) {
return time::OffsetDateTime::now_utc() > expires;
}
false // <-- Unparseable = "not expired" (FAIL OPEN)
}Notably, a separate is_expired function in oauth3/src/web/proxy.rs:150-151 correctly fails closed (return true on parse error), making this inconsistency even more concerning.
Impact:
If a database corruption or migration issue causes malformed timestamps in access/refresh tokens, those tokens will never expire. An attacker who obtains such a token would have indefinite access.
Recommendation:
Fail closed — treat unparseable timestamps as expired:
false // Change to:
true // Unparseable → treat as expired (fail closed)| Field | Value |
|---|---|
| Severity | High |
| Location | oauth3/src/app.rs (entire file — no CorsLayer present) |
Description:
The oauth3 Axum server does not configure any CORS middleware. No tower_http::cors::CorsLayer is applied to the router. The browser's default same-origin policy will block cross-origin fetch() requests, but this is an implicit rather than explicit security control.
Impact:
Without explicit CORS headers, the server relies entirely on browser defaults. If the deployment places oauth3 behind a reverse proxy that adds permissive CORS headers, or if the server is accessed via non-browser clients, there is no server-side enforcement. Additionally, the lack of explicit configuration means simple requests (form POSTs, navigation) are not restricted.
Recommendation:
Add an explicit CorsLayer with a strict allowlist:
use tower_http::cors::{CorsLayer, AllowOrigin};
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list(["https://theredactedfile.com".parse().unwrap()]))
.allow_methods([Method::GET, Method::POST])
.allow_credentials(true);
app.layer(cors);| Field | Value |
|---|---|
| Severity | High |
| Location | oauth3/src/app.rs (entire file — no rate-limit middleware) |
Description:
The oauth3 server has no rate limiting on any endpoint, including authentication-critical paths: /oauth3/auth/* (login/callback), /oauth3/token (token exchange), /verify/gmail (verification), and /api/keys (API key management). While the Next.js frontend has best-effort memory-based rate limiting, direct requests to oauth3 bypass it entirely.
Impact:
Attackers can perform credential stuffing, brute-force API keys, or flood the Gmail verification endpoint (which consumes Google API quota and TEE resources). The /oauth3/token endpoint is especially vulnerable to authorization code brute-forcing.
Recommendation:
Add rate limiting middleware, especially on token and verification endpoints:
use tower_governor::{GovernorLayer, GovernorConfigBuilder};
let config = GovernorConfigBuilder::default()
.per_second(2)
.burst_size(10)
.finish()
.unwrap();
app.layer(GovernorLayer { config });| Field | Value |
|---|---|
| Severity | Medium |
| Location | oauth3/src/web/proxy.rs:156-159 |
Description:
The scope_allows_provider() function treats scopes: None as a wildcard, granting access to all providers. Session-based authentication sets scopes to None, meaning any logged-in user can proxy requests to any configured OAuth provider without restriction.
fn scope_allows_provider(scopes: Option<&str>, provider: &str) -> bool {
let Some(scopes) = scopes else {
return true; // No scopes = allow everything
};
// ...
}Impact:
A user with a valid session cookie can access any provider's API through the proxy, even providers they haven't explicitly authorized. This could enable unauthorized access to Google, GitHub, or other provider APIs through the proxy.
Recommendation:
Require explicit proxy scope for session-based access, or validate that the user has linked the requested provider:
let Some(scopes) = scopes else {
// Session auth: verify user has linked this provider
return false; // Or check identity table
};| Field | Value |
|---|---|
| Severity | Medium |
| Location | oauth3/src/web/handlers/oauth.rs:515-516 |
Description:
The client secret verification compares SHA-256 hash strings using standard string equality (!=), which is susceptible to timing side-channel attacks. While the comparison is on hashes (not raw secrets), timing differences in string comparison could leak information about the hash value.
let provided_hash = hash_token(&secret);
if provided_hash != expected { // NOT constant-time
return Err(token_error_response(StatusCode::UNAUTHORIZED, "invalid_client"));
}Impact:
An attacker making many requests could theoretically determine the hash value byte-by-byte through response timing analysis. This is mitigated by the hashing step (the leaked information is about the hash, not the original secret) and by network jitter, but the risk exists in principle.
Recommendation:
Use constant-time comparison:
use subtle::ConstantTimeEq;
if provided_hash.as_bytes().ct_eq(expected.as_bytes()).unwrap_u8() != 1 {
return Err(...);
}| Field | Value |
|---|---|
| Severity | Medium |
| Location | oauth3/src/auth/session.rs:52-59 |
Description:
The set_login_return_to() function stores the return_to URL in a plain cookie using cookies.add(), while all other sensitive cookies in the application use cookies.private() (encrypted + signed). This means the redirect URL is visible and modifiable by the client.
pub fn set_login_return_to(cookies: &Cookies, path: &str) {
// ...
cookies.add(cookie); // NOT encrypted — uses cookies.add() instead of cookies.private().add()
}Impact:
A man-in-the-middle (when APP_FORCE_SECURE is not set) or a client-side attacker could modify the return_to cookie to redirect the user to a malicious URL after login. Combined with SEC-01 (if allowlist is not configured), this amplifies the open redirect risk.
Recommendation:
Use the private (encrypted) cookie jar:
cookies.private(key).add(cookie);| Field | Value |
|---|---|
| Severity | Medium |
| Location | Redacted/contracts/clearance/src/contract.rs (REPORTDATA validation logic) |
Description:
The TDX attestation quote contains a 64-byte REPORTDATA field. The smart contract only validates the first 32 bytes (comparing them against the SHA-256 hash of the canonical result). The remaining 32 bytes are not checked, which means they could contain arbitrary data without invalidating the quote.
Impact:
While the first 32 bytes bind the attestation to the verification result, the unchecked 32 bytes could theoretically be used to smuggle additional data through the attestation pipeline, or a future vulnerability in quote generation could exploit this unused space. The practical risk is low given the current single-purpose design.
Recommendation:
Verify that the second 32 bytes are zero-padded, matching the TEE's expected behavior:
// After checking first 32 bytes match the hash:
if report_data[32..64] != [0u8; 32] {
return Err(ContractError::InvalidQuote("unexpected REPORTDATA padding".into()));
}| Field | Value |
|---|---|
| Severity | Medium |
| Location | Redacted/src/app/api/oauth3/callback/route.ts:39 |
Description:
The OAuth callback response serves an HTML page with Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline'. While default-src 'none' is restrictive, script-src 'unsafe-inline' allows execution of any inline script, defeating script-based CSP protections on this page.
Impact:
If an attacker can inject content into this HTML response (e.g., through a reflected parameter — currently mitigated by the token regex validation), they could execute arbitrary JavaScript. The callback page handles the OAuth token, so XSS here could steal the token before it's stored in the httpOnly cookie.
Recommendation:
Use a nonce-based CSP:
const nonce = crypto.randomUUID();
// CSP: script-src 'nonce-${nonce}'
// <script nonce="${nonce}">...</script>| Field | Value |
|---|---|
| Severity | Medium |
| Location | oauth3/src/web/handlers/verify.rs:147-150 |
Description:
The verification handler calls SystemTime::now().duration_since(UNIX_EPOCH).unwrap(). While SystemTime::now() succeeding is almost universal, duration_since(UNIX_EPOCH) will panic if the system clock is before January 1, 1970. In a TEE environment where the clock is managed by the hypervisor, clock anomalies are more plausible than on standard hardware.
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap() // PANICS if clock < UNIX_EPOCH
.as_secs();Impact:
A TEE clock anomaly would crash the verification handler, causing a denial of service for all verification requests. This is the only path to generate attestation quotes, so the entire verification pipeline would be unavailable.
Recommendation:
Use unwrap_or with a fallback or return an error:
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "clock error"))?
.as_secs();| Field | Value |
|---|---|
| Severity | Low |
| Location | oauth3/src/attestation/mod.rs:12 |
Description:
The get_dstack_client() function uses .expect("Failed to build Unix socket client") which will panic (crash the entire server) if the Unix socket client cannot be constructed. This only applies to the production path when DSTACK_SOCKET is set.
Impact:
If the Unix socket path is invalid or inaccessible at startup, the server will crash. This is a low-severity availability issue since it would be caught immediately during deployment.
Recommendation:
Return Result and handle the error gracefully:
fn get_dstack_client() -> Result<reqwest::Client> {
// ...
.build().context("Failed to build Unix socket client")?
}| Field | Value |
|---|---|
| Severity | Low |
| Location | Redacted/next.config.js:33 |
Description:
The global CSP only defines frame-ancestors 'self'. It does not restrict script-src, style-src, connect-src, img-src, or other directives. This provides clickjacking protection but no XSS mitigation at the CSP layer.
Impact:
If an XSS vulnerability exists elsewhere in the application, there is no CSP defense-in-depth to prevent script execution. The application relies solely on React's built-in escaping and input validation.
Recommendation:
Add a more comprehensive CSP. This requires careful testing with the Abstraxion SDK and Reclaim iframe:
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https://s3.amazonaws.com; connect-src 'self' https://*.burnt.com https://*.phala.network; frame-ancestors 'self'"| Field | Value |
|---|---|
| Severity | Low |
| Location | Redacted/src/middleware.ts |
Description:
The rate limiting in the Next.js middleware uses an in-memory Map that resets on every cold start (acknowledged in code comments). On serverless platforms like Cloudflare Workers (the deployment target per package.json), each worker instance has its own memory space, and cold starts are frequent.
Impact:
Rate limiting provides minimal protection in production. An attacker can reset rate limits by waiting for a cold start or by hitting different worker instances. The oauth3 server (SEC-06) has no rate limiting at all, so even effective frontend rate limiting could be bypassed by calling oauth3 directly.
Recommendation:
Use a distributed rate limiter (e.g., Cloudflare KV, Upstash Redis) for persistent enforcement, or implement rate limiting at the oauth3 layer where it cannot be bypassed.
| Field | Value |
|---|---|
| Severity | Informational |
| Location | oauth3/src/web/handlers/verify.rs:13, Redacted/src/app/api/oauth3/verify/route.ts:57, Redacted/src/app/api/reclaim/init/route.ts:26 |
Description:
The email address jeevacation@gmail.com is hardcoded in three locations across both repositories. Changing the target email requires redeploying both applications.
Recommendation:
Move to a shared environment variable (e.g., SUSPECT_EMAIL) to enable configuration changes without code deployment.
| Field | Value |
|---|---|
| Severity | Informational |
| Location | Redacted/.env.production:12-13 |
Description:
The production CVM URL (https://b7de194832e39104e31cbb56b0efadd9fd3466b4-8080.dstack-pha-prod5.phala.network) is committed to the repository in both OAUTH3_BASE_URL and NEXT_PUBLIC_OAUTH3_BASE_URL. While the CVM is designed to be publicly accessible, exposing the infrastructure URL enables direct targeting.
Recommendation:
Consider using a custom domain or CDN in front of the CVM to decouple the public URL from the infrastructure hostname.
| Field | Value |
|---|---|
| Severity | Informational |
| Location | oauth3/src/config.rs |
Description:
When COOKIE_KEY_BASE64 is not set, the server generates a random key at startup with a warning. This means sessions are lost on restart and cannot be shared across multiple instances. This is acceptable for development but would be a problem if accidentally deployed without configuration.
Recommendation:
Consider making COOKIE_KEY_BASE64 a required configuration in production (fail to start if not set and not in dev mode).
| Field | Value |
|---|---|
| Severity | Informational |
| Location | Redacted/package.json:16, Redacted/abstraxion-patch-loader.js |
Description:
The application depends on @burnt-labs/abstraxion@^1.0.0-alpha.73 and applies compile-time regex-based patches via a custom webpack loader to fix grant comparison bugs. This creates a fragile dependency: any update to abstraxion could silently break the patches without warning.
Recommendation:
Pin the exact version (1.0.0-alpha.73 without ^), and add build-time verification that patches were applied successfully. Track upstream for a stable release.
The verification pipeline spans both repositories:
-
Login (
Redacted/api/oauth3/login→oauth3/auth/google): The frontend redirects users to the CVM's Google OAuth flow. Thereturn_toURL is set via a cookie. Risk: SEC-01 (open redirect) and SEC-09 (unencrypted return_to cookie) could allow an attacker to hijack this redirect. -
Callback (
oauth3/auth/google/callback→Redacted/api/oauth3/callback): The CVM completes OAuth, sets a session, and redirects back to the frontend with an HMAC session token. The frontend stores this as an httpOnly cookie. Risk: SEC-02 (placeholder mode) could generate a valid session without any Google authentication actually occurring. -
Verification (
Redacted/api/oauth3/verify→oauth3/verify/gmail): The frontend proxies the verification request with the session token. The CVM searches Gmail, generates a TDX attestation quote, and returns the signed result. Risk: The proxy atRedacted/api/oauth3/verifycorrectly validates the address format and cleans up the token after use. -
Minting (
Redacted/VerifyFlow.tsx→ XION blockchain): The frontend submits the CVM's result and quote to the clearance smart contract, which verifies the TDX attestation and mints an NFT badge. Risk: SEC-10 (unchecked REPORTDATA bytes) is a minor gap in the contract's validation.
The critical trust boundary is between the CVM (oauth3) and the smart contract. The CVM's TDX attestation quote must convince the contract that verification happened inside a genuine TEE. The chain is:
- CVM side: Generates
CanonicalResult→ SHA-256 hash → TDX REPORTDATA → signed quote - Contract side: Parses quote → extracts REPORTDATA → verifies hash matches result → verifies secp256r1 signature against stored TEE pubkey
This chain is sound, provided:
- The TEE pubkey in the contract is authentic (set by admin)
- The CVM code running in the TEE is the audited version (verifiable via TDX quote's MRTD/RTMR fields — not currently checked by the contract)
- The placeholder mode (SEC-02) is not reachable in production
-
PKCE S256 enforcement — The OAuth2 implementation correctly requires S256 PKCE for public clients and rejects the
plainmethod (oauth3/src/web/handlers/oauth.rs). -
Authorization code stored as SHA-256 hash — Auth codes are hashed before storage and consumed atomically, preventing replay attacks.
-
OIDC token validation — Full signature, issuer, audience, nonce, and expiry validation via the
openidconnectcrate. -
Google token revocation after verification — The CVM revokes the Google access token immediately after Gmail search and unlinks the Google identity from the user account (
verify.rs:127-145). -
Encrypted session cookies — All session cookies use
tower-cookiesprivate jar (encrypted + signed), except thereturn_tocookie (SEC-09). -
Smart contract attestation verification — The CosmWasm contract performs thorough TDX v4 quote parsing, secp256r1 signature verification, REPORTDATA hash binding, address binding, 24-hour freshness enforcement, and duplicate badge prevention.
-
Parameterized database queries — Diesel ORM is used throughout oauth3, providing inherent SQL injection protection.
-
SSRF protection in proxy — The oauth3 proxy validates paths against traversal attempts (
..,//,@) and verifies the resolved URL host matches the configured provider host. -
HTML escaping in consent page — The OAuth consent page uses a dedicated
escape_html()function that handles all five critical characters (&,<,>,",'). -
Arithmetic overflow protection — The smart contract's
Cargo.tomlenablesoverflow-checks = truein the release profile. -
httpOnly cookie for OAuth token — The frontend stores the CVM session token in an httpOnly cookie with SameSite=Lax, preventing XSS-based token theft.
| ID | Severity | Title | Location |
|---|---|---|---|
| SEC-01 | Critical | Open redirect default when allowlist unset | oauth3/src/auth/oidc.rs:117 |
| SEC-02 | Critical | Placeholder mode creates sessions without auth | oauth3/src/auth/oidc.rs:244-273 |
| SEC-03 | High | API key generation uses weak RNG | oauth3/src/web/handlers/account.rs:229 |
| SEC-04 | High | Token expiry check fails open on parse error | oauth3/src/web/session.rs:250-257 |
| SEC-05 | High | No CORS configuration on oauth3 server | oauth3/src/app.rs |
| SEC-06 | High | No rate limiting on auth endpoints | oauth3/src/app.rs |
| SEC-07 | Medium | Session cookies bypass proxy scope checks | oauth3/src/web/proxy.rs:156-159 |
| SEC-08 | Medium | Client secret comparison not constant-time | oauth3/src/web/handlers/oauth.rs:516 |
| SEC-09 | Medium | return_to cookie not encrypted |
oauth3/src/auth/session.rs:52-59 |
| SEC-10 | Medium | Only first 32 bytes of REPORTDATA validated | Redacted/contracts/clearance/src/contract.rs |
| SEC-11 | Medium | CSP uses unsafe-inline in callback |
Redacted/src/app/api/oauth3/callback/route.ts:39 |
| SEC-12 | Medium | SystemTime::now().unwrap() can panic in TEE |
oauth3/src/web/handlers/verify.rs:147-150 |
| SEC-13 | Low | .expect() panic in attestation client |
oauth3/src/attestation/mod.rs:12 |
| SEC-14 | Low | Minimal global CSP (frame-ancestors only) | Redacted/next.config.js:33 |
| SEC-15 | Low | Memory-based rate limiting resets on cold start | Redacted/src/middleware.ts |
| SEC-16 | Info | Hardcoded suspect email in 3 locations | Multiple files |
| SEC-17 | Info | Production CVM URL committed to repo | Redacted/.env.production |
| SEC-18 | Info | Dev cookie key auto-generated if unconfigured | oauth3/src/config.rs |
| SEC-19 | Info | Alpha dependency with compile-time patches | Redacted/package.json |
Totals: 2 Critical, 4 High, 6 Medium, 3 Low, 4 Informational
End of report.