Skip to content

Commit daaf5e6

Browse files
committed
refactor(sandbox): refactor ticket verification and rename preview session cache
1 parent c148781 commit daaf5e6

4 files changed

Lines changed: 94 additions & 62 deletions

File tree

projects/agent-sandbox-proxy/src/auth.rs

Lines changed: 75 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
2-
use serde::de::DeserializeOwned;
32
use serde::{Deserialize, Serialize};
43
use sha2::{Digest, Sha256};
54
use std::collections::HashMap;
@@ -70,7 +69,6 @@ const DEFAULT_APP_REQUEST_TIMEOUT_SECS: u64 = 10;
7069
const DEFAULT_ADDRESS_CACHE_TTL_SECS: u64 = 30;
7170
const MAX_ADDRESS_CACHE_ENTRIES: usize = 2_000;
7271
const MIN_PROXY_SECRET_BYTES: usize = 32;
73-
const SANDBOX_TICKET_HEADER: &str = "X-Sandbox-Ticket";
7472
const SANDBOX_PREVIEW_SESSION_HEADER: &str = "X-Sandbox-Preview-Session";
7573

7674
#[derive(Clone)]
@@ -108,30 +106,23 @@ pub fn get_proxy_secret() -> &'static str {
108106
})
109107
}
110108

111-
fn decode_jwt_ticket<T: DeserializeOwned>(ticket: &str) -> Result<T, String> {
109+
/// 在代理层边缘进行本地 JWT 验签防刷,直接在第一道闸口过滤非法请求
110+
pub fn verify_jwt_ticket(ticket: &str) -> Result<Claims, String> {
112111
let secret = get_proxy_secret();
113112
let decoding_key = DecodingKey::from_secret(secret.as_bytes());
114113

115114
// JWT 默认使用 HS256 算法,配合 5 秒的时钟容差 (leeway)
116115
let mut validation = Validation::new(Algorithm::HS256);
117116
validation.leeway = 5;
118117

119-
let token_data = decode::<T>(ticket, &decoding_key, &validation)
118+
let token_data = decode::<Claims>(ticket, &decoding_key, &validation)
120119
.map_err(|err| format!("JWT signature validation failed: {}", err))?;
121120

122121
Ok(token_data.claims)
123122
}
124123

125-
/// 在代理层边缘校验既有 WebSocket ticket,保留上游 teamId claims 契约。
126-
pub fn verify_jwt_ticket(ticket: &str) -> Result<Claims, String> {
127-
decode_jwt_ticket(ticket)
128-
}
129-
130-
/// 反向请求 NextJS 主站验证凭证,并置换出真实的沙盒物理端点寻址信息。
131-
async fn request_sandbox_address(
132-
credential: &str,
133-
credential_header: &'static str,
134-
) -> Result<SandboxAddress, String> {
124+
/// 反向请求 NextJS 主站进行 Ticket 验证,并置换出真实的沙盒物理端点寻址信息 (高安全有状态地址置换方案)
125+
pub async fn resolve_sandbox_address(ticket: &str) -> Result<SandboxAddress, String> {
135126
// 1. 读取主站内网 API 基准地址 (默认 http://localhost:3000)
136127
let app_url =
137128
env::var("FASTGPT_APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
@@ -140,17 +131,16 @@ async fn request_sandbox_address(
140131
let request_url = format!("{}/api/core/ai/sandbox/verifyTicket", clean_app_url);
141132

142133
debug!(
143-
"[Auth] Back-channel requesting App to resolve credential. Target: {}",
134+
"[Auth] Back-channel requesting App to resolve ticket. Target: {}",
144135
request_url
145136
);
146137

147-
// 2. 凭证放在内网 header,避免主站 access log 记录 bearer token。
138+
// 2. 发起内网 HTTP GET 请求 (复用全局共享的 TCP 连接池,自动处理 Keep-Alive 与 URL 编码)
148139
let client = get_http_client();
149-
let request = client
150-
.get(&request_url)
151-
.header(credential_header, credential)
152-
// 3. 共享密钥只用于 proxy 到主站的反向通道认证。
153-
.header("X-Proxy-Token", get_proxy_secret());
140+
let mut request = client.get(&request_url).query(&[("ticket", ticket)]);
141+
142+
// 3. 安全二次加固:必须携带正确的 AGENT_SANDBOX_PROXY_SECRET,在 Header 中注入安全防刷握手 Token
143+
request = request.header("X-Proxy-Token", get_proxy_secret());
154144

155145
let response = request
156146
.send()
@@ -188,18 +178,62 @@ async fn request_sandbox_address(
188178

189179
let address = app_res.data;
190180

191-
debug!("[Auth] Credential resolved successfully.");
181+
debug!("[Auth] Ticket resolved successfully.");
192182
Ok(address)
193183
}
194184

195-
/// 验证既有 WebSocket JWT ticket 并解析 sandbox 地址。
196-
pub async fn resolve_sandbox_address(ticket: &str) -> Result<SandboxAddress, String> {
197-
request_sandbox_address(ticket, SANDBOX_TICKET_HEADER).await
198-
}
199-
200185
/// 验证有状态 Preview session 并解析只读 HTTP sandbox 地址。
201186
async fn resolve_preview_sandbox_address(session_id: &str) -> Result<SandboxAddress, String> {
202-
request_sandbox_address(session_id, SANDBOX_PREVIEW_SESSION_HEADER).await
187+
let app_url =
188+
env::var("FASTGPT_APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
189+
let request_url = format!(
190+
"{}/api/core/ai/sandbox/verifyTicket",
191+
app_url.trim_end_matches('/')
192+
);
193+
194+
debug!(
195+
"[Auth] Back-channel requesting App to resolve preview session. Target: {}",
196+
request_url
197+
);
198+
199+
let response = get_http_client()
200+
.get(&request_url)
201+
.header(SANDBOX_PREVIEW_SESSION_HEADER, session_id)
202+
.header("X-Proxy-Token", get_proxy_secret())
203+
.send()
204+
.await
205+
.map_err(|err| format!("HTTP request to App verifyTicket failed: {}", err))?;
206+
207+
let status = response.status();
208+
if !status.is_success() {
209+
let err_text = response
210+
.text()
211+
.await
212+
.unwrap_or_else(|_| "Unknown error".to_string());
213+
error!(
214+
"[Auth] Preview session validation rejected by App (Status {}): {}",
215+
status, err_text
216+
);
217+
return Err(format!("Preview session rejected by App: {}", err_text));
218+
}
219+
220+
let app_res = response
221+
.json::<AppResponse<SandboxAddress>>()
222+
.await
223+
.map_err(|err| format!("Failed to parse response JSON from App: {}", err))?;
224+
225+
if app_res.code != 200 {
226+
return Err(format!(
227+
"App returned error code {}: {:?}",
228+
app_res.code,
229+
app_res
230+
.status_text
231+
.unwrap_or_else(|| "Unknown error".to_string())
232+
));
233+
}
234+
235+
debug!("[Auth] Preview session resolved successfully.");
236+
Ok(app_res.data)
203237
}
204238

205239
fn get_address_cache_ttl() -> Duration {
@@ -211,8 +245,8 @@ fn get_address_cache_ttl() -> Duration {
211245
Duration::from_secs(seconds)
212246
}
213247

214-
fn get_ticket_cache_key(ticket: &str) -> [u8; 32] {
215-
Sha256::digest(ticket.as_bytes()).into()
248+
fn get_preview_session_cache_key(session_id: &str) -> [u8; 32] {
249+
Sha256::digest(session_id.as_bytes()).into()
216250
}
217251

218252
/**
@@ -221,9 +255,11 @@ fn get_ticket_cache_key(ticket: &str) -> [u8; 32] {
221255
* Session validity is checked by FastGPT on cache misses. This cache avoids repeating the
222256
* back-channel lookup and sandbox password read for each resource in one HTML page.
223257
*/
224-
pub async fn resolve_cached_sandbox_address(session_id: &str) -> Result<SandboxAddress, String> {
258+
pub async fn resolve_cached_preview_sandbox_address(
259+
session_id: &str,
260+
) -> Result<SandboxAddress, String> {
225261
let now = Instant::now();
226-
let cache_key = get_ticket_cache_key(session_id);
262+
let cache_key = get_preview_session_cache_key(session_id);
227263
if let Some(cached) = SANDBOX_ADDRESS_CACHE.read().await.get(&cache_key)
228264
&& cached.expires_at > now
229265
{
@@ -253,11 +289,11 @@ pub async fn resolve_cached_sandbox_address(session_id: &str) -> Result<SandboxA
253289
Ok(address)
254290
}
255291

256-
pub async fn invalidate_cached_sandbox_address(session_id: &str) {
292+
pub async fn invalidate_cached_preview_sandbox_address(session_id: &str) {
257293
SANDBOX_ADDRESS_CACHE
258294
.write()
259295
.await
260-
.remove(&get_ticket_cache_key(session_id));
296+
.remove(&get_preview_session_cache_key(session_id));
261297
}
262298

263299
#[cfg(test)]
@@ -379,14 +415,14 @@ mod tests {
379415
}
380416

381417
#[test]
382-
fn test_ticket_cache_key_is_stable_without_retaining_token() {
418+
fn test_preview_session_cache_key_is_stable_without_retaining_credential() {
383419
assert_eq!(
384-
get_ticket_cache_key("ticket-a"),
385-
get_ticket_cache_key("ticket-a")
420+
get_preview_session_cache_key("session-a"),
421+
get_preview_session_cache_key("session-a")
386422
);
387423
assert_ne!(
388-
get_ticket_cache_key("ticket-a"),
389-
get_ticket_cache_key("ticket-b")
424+
get_preview_session_cache_key("session-a"),
425+
get_preview_session_cache_key("session-b")
390426
);
391427
}
392428
}

projects/agent-sandbox-proxy/src/main.rs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ mod preview;
1515
mod relay;
1616

1717
use auth::{
18-
invalidate_cached_sandbox_address, resolve_cached_sandbox_address, resolve_sandbox_address,
18+
invalidate_cached_preview_sandbox_address, resolve_cached_preview_sandbox_address,
19+
resolve_sandbox_address,
1920
};
2021
use preview::{bad_gateway_response, preview_error_response, proxy_preview_file};
2122
use relay::handle_relay;
@@ -218,7 +219,7 @@ async fn preview_handler(
218219
}
219220
let preview_credential = format!("{}:{}", params.sandbox_id, params.session_id);
220221

221-
let address = match resolve_cached_sandbox_address(&preview_credential).await {
222+
let address = match resolve_cached_preview_sandbox_address(&preview_credential).await {
222223
Ok(address) => address,
223224
Err(error) => {
224225
error!("[Preview] Session resolution failed: {}", error);
@@ -233,15 +234,16 @@ async fn preview_handler(
233234
Ok(response) => response,
234235
Err(first_error) => {
235236
error!("[Preview] Cached upstream request failed: {}", first_error);
236-
invalidate_cached_sandbox_address(&preview_credential).await;
237+
invalidate_cached_preview_sandbox_address(&preview_credential).await;
237238

238-
let fresh_address = match resolve_cached_sandbox_address(&preview_credential).await {
239-
Ok(address) => address,
240-
Err(error) => {
241-
error!("[Preview] Upstream re-resolution failed: {}", error);
242-
return bad_gateway_response();
243-
}
244-
};
239+
let fresh_address =
240+
match resolve_cached_preview_sandbox_address(&preview_credential).await {
241+
Ok(address) => address,
242+
Err(error) => {
243+
error!("[Preview] Upstream re-resolution failed: {}", error);
244+
return bad_gateway_response();
245+
}
246+
};
245247
proxy_preview_file(&fresh_address, &params.path, &method, &headers)
246248
.await
247249
.unwrap_or_else(|error| {

projects/app/src/pages/api/core/ai/sandbox/verifyTicket.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,8 @@ const IDE_AGENT_PREVIEW_PORT = 1319;
2323
const IDE_AGENT_PASSWORD_READ_COMMAND = 'sh -c "cat ~/.fastgpt-ide-agent-password"';
2424

2525
const VerifyTicketQuerySchema = z.object({
26-
ticket: z.string().min(1).optional()
26+
ticket: z.string()
2727
});
28-
const SANDBOX_TICKET_HEADER = 'x-sandbox-ticket';
2928
const SANDBOX_PREVIEW_SESSION_HEADER = 'x-sandbox-preview-session';
3029

3130
const SandboxVerifyTicketResponseSchema = z.object({
@@ -83,11 +82,6 @@ async function readIdeAgentPassword(sandbox: SandboxClient) {
8382
async function handler(req: ApiRequestProps): Promise<SandboxVerifyTicketResponse> {
8483
const secret = authAgentSandboxProxy(req);
8584

86-
const { ticket: queryTicket } = parseApiInput({
87-
req,
88-
querySchema: VerifyTicketQuerySchema
89-
}).query;
90-
const headerTicket = req.headers[SANDBOX_TICKET_HEADER];
9185
const headerPreviewSession = req.headers[SANDBOX_PREVIEW_SESSION_HEADER];
9286

9387
const authContext = await (async () => {
@@ -98,10 +92,10 @@ async function handler(req: ApiRequestProps): Promise<SandboxVerifyTicketRespons
9892
};
9993
}
10094

101-
const ticket = z
102-
.string()
103-
.min(1)
104-
.parse((typeof headerTicket === 'string' ? headerTicket : undefined) ?? queryTicket);
95+
const { ticket } = parseApiInput({
96+
req,
97+
querySchema: VerifyTicketQuerySchema
98+
}).query;
10599

106100
try {
107101
const { sourceType, sourceId, userId, chatId } = SandboxTicketClaimsSchema.parse(

projects/app/test/api/core/ai/sandbox/verifyTicket.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ describe('sandbox verifyTicket API', () => {
9191
expect(mocks.getEndpoint).toHaveBeenCalledWith(1318);
9292
});
9393

94-
it('prefers the preview session header over the legacy query transport', async () => {
94+
it('prefers the preview session header over the WebSocket ticket query', async () => {
9595
const req = createPreviewReq();
9696
req.query.ticket = 'invalid-legacy-ticket';
9797

@@ -112,7 +112,7 @@ describe('sandbox verifyTicket API', () => {
112112
expect(mocks.getSandboxClient).not.toHaveBeenCalled();
113113
});
114114

115-
it('rejects requests without a ticket in either transport', async () => {
115+
it('rejects requests without a WebSocket ticket or preview session', async () => {
116116
await expect(handler({ query: {}, headers: {} } as any)).rejects.toThrow();
117117
expect(mocks.getSandboxClient).not.toHaveBeenCalled();
118118
});

0 commit comments

Comments
 (0)