Skip to content

Commit 938d369

Browse files
alari76claude
andcommitted
fix: harden path traversal, trust proxy, and image src allowlist
1. /api/opencode/models: validate workingDir with realpath + allowed-roots check, matching the existing guard in /api/sessions/create. 2. Express trust proxy: set app.set('trust proxy', true) when TRUST_PROXY is enabled so req.ip returns the real client IP for all rate limiters. 3. Markdown img renderer: restrict src to https: and data:image/ protocols to prevent tracking beacons and arbitrary protocol access from model output. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3d6f1b0 commit 938d369

File tree

3 files changed

+26
-3
lines changed

3 files changed

+26
-3
lines changed

server/session-routes.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,22 @@ export function createSessionRouter(
5353
router.get('/api/opencode/models', async (req, res) => {
5454
const token = extractToken(req)
5555
if (!verifyToken(token)) return res.status(401).json({ error: 'Unauthorized' })
56-
const workingDir = (req.query.workingDir as string) || osHomedir()
57-
const result = await fetchOpenCodeModels(workingDir)
56+
const rawDir = (req.query.workingDir as string) || osHomedir()
57+
58+
// Bounds-check: workingDir must be under home or REPOS_ROOT (same as /api/sessions/create)
59+
const home = osHomedir()
60+
const allowedRoots = [home, REPOS_ROOT]
61+
let resolvedDir: string
62+
try {
63+
resolvedDir = fsRealpathSync(pathResolve(rawDir))
64+
} catch {
65+
return res.status(400).json({ error: 'workingDir could not be resolved (path does not exist or is inaccessible)' })
66+
}
67+
if (!allowedRoots.some(root => resolvedDir === root || resolvedDir.startsWith(root + '/'))) {
68+
return res.status(403).json({ error: 'workingDir is outside allowed directories' })
69+
}
70+
71+
const result = await fetchOpenCodeModels(resolvedDir)
5872
res.json(result)
5973
})
6074

server/ws-server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,12 @@ const commitEventState: { handler: CommitEventHandler | undefined } = { handler:
195195
// ---------------------------------------------------------------------------
196196
const app = express()
197197

198+
// Trust X-Forwarded-For headers when behind a reverse proxy so that
199+
// req.ip returns the real client IP for rate limiters and logging.
200+
if (TRUST_PROXY) {
201+
app.set('trust proxy', true)
202+
}
203+
198204
// In Docker / standalone mode (FRONTEND_DIST set), the server is reached directly without nginx.
199205
// nginx normally strips the /cc prefix before proxying; we replicate that here so the frontend
200206
// (which hardcodes BASE = '/cc') works against the Node server without modification.

src/components/ChatView.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,12 @@ function AssistantMessage({ msg, fontSize, variant = 'default', repeatCount }: {
213213
return <a href={safeHref} target="_blank" rel="noopener noreferrer" {...props}>{children}</a>
214214
},
215215
img({ src, alt, ...props }) {
216+
// Only allow https: and data: URIs to prevent tracking beacons and arbitrary protocol access
217+
const safeSrc = src && /^(https:|data:image\/)/i.test(src) ? src : undefined
218+
if (!safeSrc) return <span className="text-neutral-5">[image blocked: untrusted source]</span>
216219
return (
217220
<img
218-
src={src}
221+
src={safeSrc}
219222
alt={alt || 'Image'}
220223
className="max-w-full max-h-96 rounded-lg border border-neutral-8 my-2"
221224
loading="lazy"

0 commit comments

Comments
 (0)