Skip to content

Commit 7986f5a

Browse files
authored
fix(security): add CSRF protection, CORS whitelist, and security headers (#1853)
1 parent de90499 commit 7986f5a

12 files changed

Lines changed: 463 additions & 34 deletions

File tree

multimodal/tarko/agent-server-next/examples/bootstrap.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
import { getContext } from 'hono/context-storage';
6-
import { AuthHook, CorsHook, AgentServer, ContextStorageHook } from '../src/index';
6+
import {
7+
AuthHook,
8+
AgentServer,
9+
ContextStorageHook,
10+
createCorsHook,
11+
createCsrfProtectionHook,
12+
SecurityHeadersHook,
13+
} from '../src/index';
714
import { resolve } from 'path';
815
import { ContextVariables } from '../src/types';
916

@@ -140,8 +147,10 @@ const logger = {
140147
};
141148

142149
server.setLogger(logger);
150+
server.registerHook(SecurityHeadersHook);
143151
server.registerHook(AuthHook);
144-
server.registerHook(CorsHook);
152+
server.registerHook(createCorsHook(server.port));
153+
server.registerHook(createCsrfProtectionHook());
145154
server.registerHook(ContextStorageHook);
146155

147156
console.log('🚀 Starting TARS Agent Server...');

multimodal/tarko/agent-server-next/src/hooks/builtInHooks.ts

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import crypto from 'crypto';
67
import { cors } from "hono/cors";
78
import { BuiltInPriorities, HookRegistrationOptions } from "./types";
89
import { accessLogMiddleware, errorHandlingMiddleware, requestIdMiddleware } from "../middlewares";
@@ -36,20 +37,89 @@ export const ContextStorageHook: HookRegistrationOptions = {
3637
handler: contextStorage(),
3738
}
3839

40+
/**
41+
* Check if an origin is allowed for CORS.
42+
* Allows localhost/127.0.0.1 on the server port, file:// protocol,
43+
* and any additional origins from TARKO_ALLOWED_ORIGINS env var.
44+
*/
45+
function isAllowedOrigin(origin: string, port: number): boolean {
46+
const allowedOrigins = new Set([
47+
`http://localhost:${port}`,
48+
`http://127.0.0.1:${port}`,
49+
'file://',
50+
]);
51+
52+
// Support additional origins via environment variable
53+
const extraOrigins = process.env.TARKO_ALLOWED_ORIGINS;
54+
if (extraOrigins) {
55+
for (const o of extraOrigins.split(',')) {
56+
const trimmed = o.trim();
57+
if (trimmed) {
58+
allowedOrigins.add(trimmed);
59+
}
60+
}
61+
}
3962

63+
if (allowedOrigins.has(origin)) {
64+
return true;
65+
}
4066

67+
// Allow file:// origins (which may have a path suffix)
68+
if (origin.startsWith('file://')) {
69+
return true;
70+
}
71+
72+
return false;
73+
}
74+
75+
/**
76+
* Create a CORS hook with origin whitelist based on server port.
77+
* @param port The server port to allow in CORS origins
78+
*/
79+
export function createCorsHook(port: number): HookRegistrationOptions {
80+
return {
81+
id: 'cors',
82+
name: 'CORS',
83+
priority: BuiltInPriorities.CORS,
84+
description: 'Cross-Origin Resource Sharing middleware with origin whitelist',
85+
handler: cors({
86+
origin: (origin) => {
87+
if (!origin || isAllowedOrigin(origin, port)) {
88+
return origin || '*';
89+
}
90+
return null;
91+
},
92+
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
93+
allowHeaders: [
94+
'Content-Type',
95+
'Authorization',
96+
'X-Requested-With',
97+
'X-CSRF-Token',
98+
'x-user-info',
99+
'x-jwt-token',
100+
],
101+
credentials: true,
102+
}),
103+
};
104+
}
105+
106+
/**
107+
* @deprecated Use createCorsHook(port) instead for proper origin validation.
108+
* This export uses permissive CORS with ACCESS_ALLOW_ORIGIN env var fallback to '*'.
109+
*/
41110
export const CorsHook: HookRegistrationOptions = {
42111
id: 'cors',
43112
name: 'CORS',
44113
priority: BuiltInPriorities.CORS,
45-
description: 'Cross-Origin Resource Sharing middleware',
114+
description: 'Cross-Origin Resource Sharing middleware (deprecated: use createCorsHook)',
46115
handler: cors({
47116
origin: process.env.ACCESS_ALLOW_ORIGIN || '*',
48117
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
49118
allowHeaders: [
50119
'Content-Type',
51120
'Authorization',
52121
'X-Requested-With',
122+
'X-CSRF-Token',
53123
'x-user-info',
54124
'x-jwt-token',
55125
],
@@ -75,3 +145,86 @@ export const AuthHook: HookRegistrationOptions = {
75145
description: 'Authentication and authorization middleware',
76146
handler: authMiddleware,
77147
}
148+
149+
// ---- CSRF Token Management ----
150+
151+
const CSRF_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
152+
const CSRF_MAX_TOKENS = 1000;
153+
const csrfTokenStore = new Map<string, number>();
154+
155+
function cleanExpiredCsrfTokens(): void {
156+
const now = Date.now();
157+
for (const [token, expiry] of csrfTokenStore) {
158+
if (expiry <= now) {
159+
csrfTokenStore.delete(token);
160+
}
161+
}
162+
}
163+
164+
export function generateCsrfToken(): string {
165+
if (csrfTokenStore.size > CSRF_MAX_TOKENS) {
166+
cleanExpiredCsrfTokens();
167+
}
168+
const token = crypto.randomBytes(32).toString('hex');
169+
csrfTokenStore.set(token, Date.now() + CSRF_TOKEN_EXPIRY_MS);
170+
return token;
171+
}
172+
173+
function isValidCsrfToken(token: string): boolean {
174+
const expiry = csrfTokenStore.get(token);
175+
if (!expiry) return false;
176+
if (Date.now() > expiry) {
177+
csrfTokenStore.delete(token);
178+
return false;
179+
}
180+
return true;
181+
}
182+
183+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
184+
185+
/**
186+
* Create a CSRF protection hook.
187+
* Validates X-CSRF-Token header on mutation requests (POST/PUT/DELETE).
188+
*/
189+
export function createCsrfProtectionHook(): HookRegistrationOptions {
190+
return {
191+
id: 'csrf-protection',
192+
name: 'CSRF Protection',
193+
priority: BuiltInPriorities.AUTH - 10, // Just before auth
194+
description: 'CSRF token validation for mutation requests',
195+
handler: async (c, next) => {
196+
if (SAFE_METHODS.has(c.req.method)) {
197+
await next();
198+
return;
199+
}
200+
201+
const token = c.req.header('X-CSRF-Token');
202+
if (!token || !isValidCsrfToken(token)) {
203+
return c.json({
204+
error: 'CSRF token missing or invalid',
205+
message: 'A valid CSRF token is required for mutation requests. Obtain one via GET /api/v1/csrf-token.',
206+
}, 403);
207+
}
208+
209+
await next();
210+
},
211+
};
212+
}
213+
214+
/**
215+
* Security headers hook.
216+
* Adds standard security response headers.
217+
*/
218+
export const SecurityHeadersHook: HookRegistrationOptions = {
219+
id: 'security-headers',
220+
name: 'Security Headers',
221+
priority: BuiltInPriorities.CORS + 10, // Before CORS
222+
description: 'Adds security response headers',
223+
handler: async (c, next) => {
224+
await next();
225+
c.header('X-Content-Type-Options', 'nosniff');
226+
c.header('X-Frame-Options', 'DENY');
227+
c.header('X-XSS-Protection', '1; mode=block');
228+
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
229+
},
230+
};

multimodal/tarko/agent-server-next/src/hooks/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,14 @@
44
*/
55

66
export { HookManager } from './HookManager';
7-
export { CorsHook, AccessLogHook, AuthHook, ContextStorageHook } from './builtInHooks'
7+
export {
8+
CorsHook,
9+
AccessLogHook,
10+
AuthHook,
11+
ContextStorageHook,
12+
SecurityHeadersHook,
13+
createCorsHook,
14+
createCsrfProtectionHook,
15+
generateCsrfToken,
16+
} from './builtInHooks'
817
export * from './types';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright (c) 2025 Bytedance, Inc. and its affiliates.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { Hono } from 'hono';
7+
import { generateCsrfToken } from '../hooks/builtInHooks';
8+
import type { ContextVariables } from '../types';
9+
10+
/**
11+
* Create CSRF token routes
12+
*/
13+
export function createCsrfRoutes(): Hono<{ Variables: ContextVariables }> {
14+
const router = new Hono<{ Variables: ContextVariables }>();
15+
16+
router.get('/api/v1/csrf-token', (c) => {
17+
const token = generateCsrfToken();
18+
return c.json({ token });
19+
});
20+
21+
return router;
22+
}

multimodal/tarko/agent-server-next/src/routes/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
export { createQueryRoutes } from './queries';
77
export { createSessionRoutes } from './sessions';
88
export { createShareRoutes } from './share';
9-
export { createSystemRoutes } from './system';
9+
export { createSystemRoutes } from './system';
10+
export { createCsrfRoutes } from './csrf';

multimodal/tarko/agent-server-next/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
createSessionRoutes,
2828
createShareRoutes,
2929
createSystemRoutes,
30+
createCsrfRoutes,
3031
} from './routes';
3132
import { createUserConfigRoutes } from './routes/user';
3233
import { HookManager, BuiltInPriorities, type HookRegistrationOptions } from './hooks';
@@ -166,6 +167,7 @@ export class AgentServer<T extends AgentAppConfig = AgentAppConfig> {
166167
*/
167168
private setupRoutes(): void {
168169
// Register all API routes
170+
this.app.route('/', createCsrfRoutes());
169171
this.app.route('/', createQueryRoutes());
170172
this.app.route('/', createSessionRoutes());
171173
this.app.route('/', createShareRoutes());

multimodal/tarko/agent-server/src/api/index.ts

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,111 @@ import express from 'express';
22
import cors from 'cors';
33
import { registerAllRoutes } from './routes';
44
import { setupWorkspaceStaticServer } from '../utils/workspace-static-server';
5+
import { csrfProtectionMiddleware } from './middleware/csrf-protection';
6+
import { registerCsrfRoutes } from './routes/csrf';
57

68
/**
7-
* Get default CORS options if none are provided
8-
*
9-
* TODO: support cors config.
9+
* Check if an origin is allowed for CORS.
10+
* Allows localhost/127.0.0.1 on the server port, file:// protocol,
11+
* and any additional origins from TARKO_ALLOWED_ORIGINS env var.
1012
*/
11-
export function getDefaultCorsOptions(): cors.CorsOptions {
13+
function isAllowedOrigin(origin: string | undefined, port: number): boolean {
14+
if (!origin) {
15+
// Allow requests with no Origin header (e.g., curl, same-origin)
16+
return true;
17+
}
18+
19+
const allowedOrigins = new Set([
20+
`http://localhost:${port}`,
21+
`http://127.0.0.1:${port}`,
22+
'file://',
23+
]);
24+
25+
// Support additional origins via environment variable
26+
const extraOrigins = process.env.TARKO_ALLOWED_ORIGINS;
27+
if (extraOrigins) {
28+
for (const o of extraOrigins.split(',')) {
29+
const trimmed = o.trim();
30+
if (trimmed) {
31+
allowedOrigins.add(trimmed);
32+
}
33+
}
34+
}
35+
36+
if (allowedOrigins.has(origin)) {
37+
return true;
38+
}
39+
40+
// Also allow file:// origins (which may have a path suffix)
41+
if (origin.startsWith('file://')) {
42+
return true;
43+
}
44+
45+
return false;
46+
}
47+
48+
/**
49+
* Get CORS options with origin whitelist based on server port.
50+
*/
51+
export function getDefaultCorsOptions(port: number): cors.CorsOptions {
1252
return {
13-
origin: '*',
53+
origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
54+
if (isAllowedOrigin(origin, port)) {
55+
callback(null, true);
56+
} else {
57+
callback(new Error(`Origin ${origin} not allowed by CORS policy`));
58+
}
59+
},
1460
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
15-
allowedHeaders: ['Content-Type', 'Authorization'],
61+
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
1662
};
1763
}
1864

65+
/**
66+
* Security headers middleware
67+
*/
68+
function securityHeadersMiddleware(
69+
_req: express.Request,
70+
res: express.Response,
71+
next: express.NextFunction,
72+
): void {
73+
res.setHeader('X-Content-Type-Options', 'nosniff');
74+
res.setHeader('X-Frame-Options', 'DENY');
75+
res.setHeader('X-XSS-Protection', '1; mode=block');
76+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
77+
next();
78+
}
79+
1980
/**
2081
* Setup API middleware and routes
2182
* @param app Express application instance
22-
* @param options Server options
83+
* @param options Server options including port for CORS configuration
2384
*/
2485
export function setupAPI(
2586
app: express.Application,
2687
options?: {
2788
workspacePath?: string;
2889
isDebug?: boolean;
90+
port?: number;
2991
},
3092
) {
31-
// Apply CORS middleware
32-
app.use(cors(getDefaultCorsOptions()));
93+
const port = options?.port ?? 3000;
94+
95+
// Apply security headers
96+
app.use(securityHeadersMiddleware);
97+
98+
// Apply CORS middleware with origin whitelist
99+
app.use(cors(getDefaultCorsOptions(port)));
33100

34101
// Apply JSON body parser middleware
35102
app.use(express.json({ limit: '20mb' }));
36103

104+
// Register CSRF token endpoint (before CSRF protection so GET is accessible)
105+
registerCsrfRoutes(app);
106+
107+
// Apply CSRF protection middleware (after body parser, before routes)
108+
app.use(csrfProtectionMiddleware);
109+
37110
// Add app.group method
38111
app.group = (
39112
prefix: string,

0 commit comments

Comments
 (0)