Commit 9d1cafce by Jon Committed by GitHub

fix(sandbox): fix unauthenticated RCE via code-server (#6781)

parent 6867c6e6
...@@ -114,7 +114,7 @@ export function getSandboxDefaults(): SandboxDefaults { ...@@ -114,7 +114,7 @@ export function getSandboxDefaults(): SandboxDefaults {
}, },
workDirectory: '/home/sandbox/workspace', workDirectory: '/home/sandbox/workspace',
// workDirectory: env.AGENT_SANDBOX_OPENSANDBOX_WORK_DIRECTORY ?? '/home/sandbox/workspace', // workDirectory: env.AGENT_SANDBOX_OPENSANDBOX_WORK_DIRECTORY ?? '/home/sandbox/workspace',
targetPort: 8080, targetPort: 44772,
entrypoint: '/home/sandbox/entrypoint.sh' entrypoint: '/home/sandbox/entrypoint.sh'
// entrypoint: env.AGENT_SANDBOX_OPENSANDBOX_ENTRYPOINT ?? '/home/sandbox/entrypoint.sh' // entrypoint: env.AGENT_SANDBOX_OPENSANDBOX_ENTRYPOINT ?? '/home/sandbox/entrypoint.sh'
}; };
......
...@@ -10,11 +10,7 @@ unset FASTGPT_SESSION_ID FASTGPT_WORKDIR FASTGPT_ENABLE_CODE_SERVER ...@@ -10,11 +10,7 @@ unset FASTGPT_SESSION_ID FASTGPT_WORKDIR FASTGPT_ENABLE_CODE_SERVER
# Start code-server or sleep forever # Start code-server or sleep forever
if [ "${_ENABLE_CODE_SERVER}" = "true" ]; then if [ "${_ENABLE_CODE_SERVER}" = "true" ]; then
# --bind-addr 0.0.0.0:8080 allows access from outside the container
# --auth none removes password protection
exec code-server \ exec code-server \
--bind-addr 0.0.0.0:8080 \
--auth none \
--disable-telemetry \ --disable-telemetry \
--disable-update-check \ --disable-update-check \
--disable-workspace-trust \ --disable-workspace-trust \
......
...@@ -34,10 +34,65 @@ async function main() { ...@@ -34,10 +34,65 @@ async function main() {
// Import pure utilities from sandboxProxyUtils — no service-layer deps, safe in tsx CJS mode. // Import pure utilities from sandboxProxyUtils — no service-layer deps, safe in tsx CJS mode.
// getSandboxProxyTarget is NOT imported here; auth is delegated to the proxyAuth API route. // getSandboxProxyTarget is NOT imported here; auth is delegated to the proxyAuth API route.
const { parseSubdomainProxy, rewriteHtml, redeemRelayToken } = (await import( const {
parseSubdomainProxy,
rewriteHtml,
redeemRelayToken,
ensureCodeServerSession,
deleteCsSession
} = (await import(
'./src/service/core/sandbox/proxyUtils' './src/service/core/sandbox/proxyUtils'
)) as typeof import('./src/service/core/sandbox/proxyUtils'); )) as typeof import('./src/service/core/sandbox/proxyUtils');
// Fetch the code-server password from the container config.yaml via the internal API.
async function fetchCodeServerPassword(sandboxId: string): Promise<string | null> {
try {
const resp = await fetch(`http://127.0.0.1:${port}/api/core/sandbox/proxyCSPassword`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sandboxId })
});
if (!resp.ok) return null;
const { password } = await resp.json();
return password || null;
} catch {
return null;
}
}
// Inject code-server session cookie into an outgoing request header object.
function injectCsKey(reqHeaders: IncomingMessage['headers'], key: string): void {
const existing = (reqHeaders.cookie as string | undefined) ?? '';
const stripped = existing
.split(';')
.map((s) => s.trim())
.filter((s) => !s.toLowerCase().startsWith('code-server-session='))
.join('; ');
reqHeaders.cookie = stripped
? `${stripped}; code-server-session=${key}`
: `code-server-session=${key}`;
}
// Build the correct code-server login base URL.
// After prefix stripping, req.url starts with /proxy/8080/... when going through execd.
// In that case the login endpoint is at target/proxy/8080, not target/login directly.
function deriveCsLoginTarget(target: string, url: string): string {
const m = url.match(/^\/proxy\/(\d+)/);
return m ? `${target}/proxy/${m[1]}` : target;
}
// Ensure code-server is authenticated and inject the session cookie into reqHeaders.
async function injectCodeServerAuth(
reqHeaders: IncomingMessage['headers'],
sandboxId: string,
target: string
): Promise<void> {
const key = await ensureCodeServerSession(sandboxId, target, () =>
fetchCodeServerPassword(sandboxId)
);
if (key) injectCsKey(reqHeaders, key);
}
const proxy = httpProxy.createProxyServer({ xfwd: true, changeOrigin: true }); const proxy = httpProxy.createProxyServer({ xfwd: true, changeOrigin: true });
proxy.on( proxy.on(
'error', 'error',
...@@ -49,6 +104,22 @@ async function main() { ...@@ -49,6 +104,22 @@ async function main() {
} }
); );
// Detect code-server session expiry: if the upstream returns a 302 to /login,
// evict the cached CS session so the next request triggers a fresh login.
proxy.on('proxyRes', (proxyRes, req) => {
if (
proxyRes.statusCode === 302 &&
typeof proxyRes.headers.location === 'string' &&
proxyRes.headers.location.includes('/login')
) {
const sid = (req as IncomingMessage).headers['x-fastgpt-sandbox-id'] as string | undefined;
if (sid) {
dev && console.log(`[proxy:cs] session expired, evicting csSession sandboxId=${sid}`);
deleteCsSession(sid);
}
}
});
// absproxy: fetch upstream then rewrite HTML paths with base prefix // absproxy: fetch upstream then rewrite HTML paths with base prefix
async function handleAbsProxy( async function handleAbsProxy(
req: IncomingMessage, req: IncomingMessage,
...@@ -103,11 +174,16 @@ async function main() { ...@@ -103,11 +174,16 @@ async function main() {
) { ) {
try { try {
const target = await authProxyTarget(req.headers, sandboxId, portNum); const target = await authProxyTarget(req.headers, sandboxId, portNum);
const csTarget = deriveCsLoginTarget(target, req.url || '');
if (proxyType === 'absproxy') { if (proxyType === 'absproxy') {
await injectCodeServerAuth(req.headers, sandboxId, csTarget);
await handleAbsProxy(req, res, target, sandboxId, String(portNum)); await handleAbsProxy(req, res, target, sandboxId, String(portNum));
} else { } else {
// Rewrite Origin so code-server's CSRF check passes (changeOrigin only rewrites Host). // Rewrite Origin so code-server's CSRF check passes (changeOrigin only rewrites Host).
const targetUrl = new URL(target); const targetUrl = new URL(target);
await injectCodeServerAuth(req.headers, sandboxId, csTarget);
// Mark the request so the proxyRes handler can identify the sandbox on session expiry.
req.headers['x-fastgpt-sandbox-id'] = sandboxId;
proxy.web(req, res, { proxy.web(req, res, {
target, target,
headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` } headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` }
...@@ -157,6 +233,9 @@ async function main() { ...@@ -157,6 +233,9 @@ async function main() {
try { try {
const target = await authProxyTarget(req.headers, sandboxId, portNum); const target = await authProxyTarget(req.headers, sandboxId, portNum);
const targetUrl = new URL(target); const targetUrl = new URL(target);
const csTarget = deriveCsLoginTarget(target, req.url || '');
await injectCodeServerAuth(req.headers, sandboxId, csTarget);
req.headers['x-fastgpt-sandbox-id'] = sandboxId;
proxy.web(req, res, { proxy.web(req, res, {
target, target,
headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` } headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` }
...@@ -352,6 +431,9 @@ async function main() { ...@@ -352,6 +431,9 @@ async function main() {
// Rewrite Origin to match the target host so code-server's CSRF check passes. // Rewrite Origin to match the target host so code-server's CSRF check passes.
// changeOrigin:true only rewrites Host, not Origin. // changeOrigin:true only rewrites Host, not Origin.
const targetUrl = new URL(target); const targetUrl = new URL(target);
const csTarget = deriveCsLoginTarget(target, req.url || '');
await injectCodeServerAuth(req.headers, sandboxId, csTarget);
req.headers['x-fastgpt-sandbox-id'] = sandboxId;
proxy.ws(req, socket, head, { proxy.ws(req, socket, head, {
target, target,
headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` } headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` }
......
...@@ -11,7 +11,7 @@ const SandboxIframe = () => { ...@@ -11,7 +11,7 @@ const SandboxIframe = () => {
return ( return (
<Box w={'100%'} h={'100%'}> <Box w={'100%'} h={'100%'}>
<iframe <iframe
src={sandboxEndpointUrl} src={`${sandboxEndpointUrl}proxy/8080/`}
sandbox="allow-scripts allow-forms allow-popups allow-downloads allow-presentation allow-same-origin" sandbox="allow-scripts allow-forms allow-popups allow-downloads allow-presentation allow-same-origin"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
style={{ style={{
......
import type { NextApiRequest, NextApiResponse } from 'next';
import { NextAPI } from '@/service/middleware/entry';
import { getCodeServerPasswordFromSandbox } from '@/service/core/sandbox/proxy';
// Internal-only endpoint: read the code-server password from the container config.yaml.
// Called by server.ts (running in the same process) to avoid importing service packages directly.
// Only requests from 127.0.0.1 are accepted.
async function handler(req: NextApiRequest, res: NextApiResponse) {
const clientIp = req.socket.remoteAddress;
if (clientIp !== '127.0.0.1' && clientIp !== '::1' && clientIp !== '::ffff:127.0.0.1') {
return res.status(403).json({ error: 'Internal only' });
}
const { sandboxId } = req.body as { sandboxId?: string };
if (!sandboxId) return res.status(400).json({ error: 'Missing sandboxId' });
const password = await getCodeServerPasswordFromSandbox(sandboxId);
return res.json({ password });
}
export default NextAPI(handler);
...@@ -3,6 +3,11 @@ import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants'; ...@@ -3,6 +3,11 @@ import { SandboxStatusEnum } from '@fastgpt/global/core/ai/sandbox/constants';
import { parseHeaderCert } from '@fastgpt/service/support/permission/auth/common'; import { parseHeaderCert } from '@fastgpt/service/support/permission/auth/common';
import type { IncomingHttpHeaders } from 'http'; import type { IncomingHttpHeaders } from 'http';
import { upsertProxySession, getProxySession } from './proxyUtils'; import { upsertProxySession, getProxySession } from './proxyUtils';
import {
getSandboxProviderConfig,
connectToProviderSandbox,
disconnectFromProviderSandbox
} from '@fastgpt/service/core/agentSkills/sandboxConfig';
const dev = process.env.NODE_ENV !== 'production'; const dev = process.env.NODE_ENV !== 'production';
...@@ -59,3 +64,26 @@ export async function getSandboxProxyTarget( ...@@ -59,3 +64,26 @@ export async function getSandboxProxyTarget(
upsertProxySession(sandboxId, authTeamId, host, protocol); upsertProxySession(sandboxId, authTeamId, host, protocol);
return `${protocol}://${host}:${targetPort}`; return `${protocol}://${host}:${targetPort}`;
} }
/**
* Read the code-server password from the container's config.yaml via exec.
* Returns null if the sandbox is not found, has no providerSandboxId, or exec fails.
*/
export async function getCodeServerPasswordFromSandbox(sandboxId: string): Promise<string | null> {
const sandbox = await MongoSandboxInstance.findOne({ sandboxId }).lean();
if (!sandbox?.metadata?.providerSandboxId) return null;
const providerConfig = getSandboxProviderConfig();
const adapter = await connectToProviderSandbox(
providerConfig,
sandbox.metadata.providerSandboxId
);
try {
const result = await adapter.execute(
"grep '^password:' ~/.config/code-server/config.yaml 2>/dev/null | awk '{print $2}' | tr -d '[:space:]'"
);
return result.stdout.trim() || null;
} finally {
await disconnectFromProviderSandbox(adapter);
}
}
...@@ -116,6 +116,7 @@ export function getProxySession(sandboxId: string): ProxySession | null { ...@@ -116,6 +116,7 @@ export function getProxySession(sandboxId: string): ProxySession | null {
export function deleteProxySession(sandboxId: string): void { export function deleteProxySession(sandboxId: string): void {
_sessionStore().delete(sandboxId); _sessionStore().delete(sandboxId);
deleteCsSession(sandboxId);
} }
// Remove all proxy sessions belonging to a given team. // Remove all proxy sessions belonging to a given team.
...@@ -123,8 +124,113 @@ export function deleteProxySession(sandboxId: string): void { ...@@ -123,8 +124,113 @@ export function deleteProxySession(sandboxId: string): void {
export function deleteProxySessionsByTeam(teamId: string): void { export function deleteProxySessionsByTeam(teamId: string): void {
const store = _sessionStore(); const store = _sessionStore();
for (const [k, v] of store) { for (const [k, v] of store) {
if (v.teamId === teamId) store.delete(k); if (v.teamId === teamId) {
store.delete(k);
deleteCsSession(k);
} }
}
}
// ---- code-server session store ----
// Keyed by sandboxId; stores the `key` cookie value returned by code-server /login.
// TTL matches ProxySession so both expire around the same time.
const CS_SESSION_TTL = 2 * 60 * 60 * 1000; // 2 h
const MAX_CS_SESSION_STORE_SIZE = 1000;
type CsSession = { keyCookie: string; exp: number };
const _csStore = (): Map<string, CsSession> => {
const g = globalThis as any;
if (!g.__csSessionStore) g.__csSessionStore = new Map();
return g.__csSessionStore;
};
export function getCsSession(sandboxId: string): string | null {
const entry = _csStore().get(sandboxId);
if (!entry || entry.exp < Date.now()) {
_csStore().delete(sandboxId);
return null;
}
entry.exp = Date.now() + CS_SESSION_TTL;
return entry.keyCookie;
}
export function deleteCsSession(sandboxId: string): void {
_csStore().delete(sandboxId);
}
/**
* Ensure a valid code-server session exists for the given sandbox.
* Checks the in-process cache first; on miss invokes getPassword() and POSTs /login.
* Returns the `key` cookie value on success, or null on failure.
*/
export async function ensureCodeServerSession(
sandboxId: string,
target: string, // e.g. "http://10.0.0.5:8080"
getPassword: () => Promise<string | null>
): Promise<string | null> {
const cached = getCsSession(sandboxId);
if (cached) return cached;
const password = await getPassword();
if (!password) return null;
let resp: Response;
try {
resp = await fetch(`${target}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
origin: new URL(target).origin // scheme://host:port only, no path component
},
body: `password=${encodeURIComponent(password)}`,
redirect: 'manual' // success → 302; wrong password → 200
});
} catch (e) {
console.error(`[csLogin] fetch error sandboxId=${sandboxId}: ${(e as Error).message}`);
return null;
}
if (resp.status !== 302 && resp.status !== 301) {
console.warn(`[csLogin] unexpected status=${resp.status} sandboxId=${sandboxId}`);
return null;
}
// Extract key=<value> from Set-Cookie header(s)
const setCookies: string[] = (resp.headers as any).getSetCookie?.() ?? [
resp.headers.get('set-cookie') ?? ''
];
let keyCookie: string | null = null;
for (const h of setCookies) {
const m = (h as string).match(/(?:^|;\s*)code-server-session=([^;]+)/i);
if (m) {
keyCookie = m[1].trim();
break;
}
}
if (!keyCookie) {
console.warn(`[csLogin] no key cookie in response sandboxId=${sandboxId}`);
return null;
}
const csStore = _csStore();
// Enforce capacity cap: evict the soonest-to-expire entry before adding a new one
if (!csStore.has(sandboxId) && csStore.size >= MAX_CS_SESSION_STORE_SIZE) {
let evictKey: string | null = null;
let minExp = Infinity;
for (const [k, v] of csStore) {
if (v.exp < minExp) {
minExp = v.exp;
evictKey = k;
}
}
if (evictKey) csStore.delete(evictKey);
}
csStore.set(sandboxId, { keyCookie, exp: Date.now() + CS_SESSION_TTL });
// Prune expired entries on each write
for (const [k, v] of csStore) if (v.exp < Date.now()) csStore.delete(k);
return keyCookie;
} }
// Rewrite absolute paths in HTML for the absproxy mode // Rewrite absolute paths in HTML for the absproxy mode
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment