From edd7816048e370cdc7e4adfcd81c80b34475fce8 Mon Sep 17 00:00:00 2001 From: kim Date: Thu, 16 Jul 2026 15:04:20 +0900 Subject: [PATCH] =?UTF-8?q?v4.1.12:=20=EB=B3=B4=EC=95=88=20=EC=B7=A8?= =?UTF-8?q?=EC=95=BD=EC=A0=90=20=EC=88=98=EC=A0=95=20=E2=80=94=20RCE,=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=ED=83=88=EC=B6=9C=202=EA=B1=B4,=20IDOR,=20XS?= =?UTF-8?q?S(=EC=8A=A4=ED=8A=9C=EB=94=94=EC=98=A4/=EC=96=B8=EC=96=B4=20?= =?UTF-8?q?=EC=95=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [CRITICAL] image_edit/imagegen 도구의 숫자 파라미터(x,y,width,height,degrees,opacity,quality,seed,guidance_scale,num_frames,fps)가 검증 없이 Python 소스에 직접 문자열 삽입되어 원격 코드 실행 가능했음. toFiniteNumber()로 전 지점 강제 숫자 변환 — 실제 페이로드로 라이브 검증 완료 - [HIGH] imageStyleTransformTool과 /api/imagegen/save-result에 경로 탈출 방어(isPathInsideDir) 누락 — 다른 사용자 워크스페이스/임의 파일 접근 가능했음. 둘 다 수정 후 실제 ../ 페이로드로 차단 확인 - [HIGH] 언어 앱 진행상황 저장 API가 URL의 :userId를 그대로 신뢰해 다른 사용자 진행상황을 읽고 덮어쓸 수 있었음(IDOR). 세션 기반으로 수정, 실제 다른 사용자명으로 테스트해 본인 워크스페이스에만 저장됨을 확인 - [MEDIUM] 언어 앱 플래시카드·단어장 렌더링(LLM 생성 콘텐츠)이 이스케이프 없이 innerHTML에 삽입되던 XSS 경로 수정(escHtml 추가) Co-Authored-By: Claude Sonnet 5 --- package.json | 2 +- src/gateway/routes-language.ts | 46 ++++- src/gateway/routes/imagegen.ts | 3 +- src/tools/image.ts | 29 ++- src/tools/imagegen.ts | 17 +- web-ui/language-app.html | 349 +++++++++++++++++++++++++++++++-- 6 files changed, 411 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index b22e537..5cb705b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smallclaw", - "version": "4.1.11", + "version": "4.1.12", "description": "Local AI agent framework powered by Ollama - OpenClaw alternative", "main": "dist/index.js", "bin": { diff --git a/src/gateway/routes-language.ts b/src/gateway/routes-language.ts index 50b40a2..fafd857 100644 --- a/src/gateway/routes-language.ts +++ b/src/gateway/routes-language.ts @@ -104,9 +104,14 @@ export function registerLanguageRoutes(app: express.Application): void { } }); + // NOTE: the :userId route param is intentionally ignored for workspace resolution — + // progress always belongs to the authenticated session, never an arbitrary URL param, + // otherwise any logged-in user could read/overwrite any other user's progress file. app.get('/api/language/progress/:userId', (req: express.Request, res: express.Response) => { try { - const workspace = getWorkspacePath(String(req.params.userId)); + const sessionUser = (req as any).user; + if (!sessionUser) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; } + const workspace = getWorkspacePath(sessionUser.username); const progressPath = path.join(workspace, 'language', 'progress.json'); if (!fs.existsSync(progressPath)) { res.json({ success: true, progress: null }); return; } res.json({ success: true, progress: JSON.parse(fs.readFileSync(progressPath, 'utf-8')) }); @@ -117,9 +122,11 @@ export function registerLanguageRoutes(app: express.Application): void { app.post('/api/language/progress/:userId', (req: express.Request, res: express.Response) => { try { + const sessionUser = (req as any).user; + if (!sessionUser) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; } const body = req.body; if (!body || typeof body !== 'object') { res.status(400).json({ success: false, error: 'body required' }); return; } - const workspace = getWorkspacePath(String(req.params.userId)); + const workspace = getWorkspacePath(sessionUser.username); const langDir = path.join(workspace, 'language'); fs.mkdirSync(langDir, { recursive: true }); fs.writeFileSync(path.join(langDir, 'progress.json'), JSON.stringify(body, null, 2), 'utf-8'); @@ -129,6 +136,41 @@ export function registerLanguageRoutes(app: express.Application): void { } }); + // POST /api/language/interpret — 실시간 한국어↔우즈베크어 통역 (언어 자동 판별) + app.post('/api/language/interpret', async (req: express.Request, res: express.Response) => { + try { + const text = String(req.body?.text || '').trim().slice(0, 500); + if (!text) { res.status(400).json({ success: false, error: 'text required' }); return; } + + const ollama = getOllamaClient(); + const prompt = `다음 텍스트가 한국어인지 우즈베크어인지 판별하고, 반대 언어로 번역하세요. +텍스트: "${text}" +JSON으로만 응답하세요 (코드 블록 없이): +{"source_lang":"ko 또는 uz","translated":"번역문","romanization":"번역문이 우즈베크어면 한글 발음 표기, 한국어면 빈 문자열"} +우즈베크어는 1993년 이후 표준 라틴 문자(oʻ, gʻ 포함)를 사용하세요. 자연스럽고 간결하게 번역하세요.`; + + const result = await ollama.chatWithThinking( + [{ role: 'user' as const, content: prompt }], + 'executor', + { num_predict: 400, temperature: 0.2, think: false } + ); + const raw = String(result.message?.content || '').trim(); + let parsed: any; + try { parsed = extractJson(raw, 'object'); } catch { parsed = null; } + if (!parsed?.translated) { + res.json({ success: false, error: '번역 실패', raw }); return; + } + res.json({ + success: true, + source_lang: parsed.source_lang === 'uz' ? 'uz' : 'ko', + translated: String(parsed.translated), + romanization: String(parsed.romanization || ''), + }); + } catch (err: any) { + res.status(500).json({ success: false, error: String(err?.message || err) }); + } + }); + // POST /api/language/tts — Uzbek TTS via edge-tts app.post('/api/language/tts', async (req: express.Request, res: express.Response) => { try { diff --git a/src/gateway/routes/imagegen.ts b/src/gateway/routes/imagegen.ts index 14efbf0..39bf215 100644 --- a/src/gateway/routes/imagegen.ts +++ b/src/gateway/routes/imagegen.ts @@ -2,7 +2,7 @@ import { Express, Request, Response } from 'express'; import fs from 'fs'; import path from 'path'; import { imageGenerateTool, videoGenerateTool, imageStyleTransformTool } from '../../tools/imagegen.js'; -import { imageEditTool } from '../../tools/image.js'; +import { imageEditTool, isPathInsideDir } from '../../tools/image.js'; const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']); const VIDEO_EXTS = new Set(['.mp4']); @@ -232,6 +232,7 @@ export function registerImagegenRoutes(app: Express): void { if (!srcRel) return res.status(400).json({ error: 'path is required' }); const srcFull = path.join(user.workspace, srcRel); + if (!isPathInsideDir(user.workspace, srcFull)) return res.status(403).json({ error: 'Access denied: path escapes workspace' }); if (!fs.existsSync(srcFull)) return res.status(404).json({ error: 'Source file not found' }); console.log(`[imagegen] save-result start user=${user.username} path=${srcRel} alsoUploads=${alsoUploads}`); diff --git a/src/tools/image.ts b/src/tools/image.ts index cf769a3..aaef8f9 100644 --- a/src/tools/image.ts +++ b/src/tools/image.ts @@ -4,7 +4,7 @@ import fs from 'fs'; import { ToolResult } from '../types.js'; import { getWorkspacePath } from '../config/paths.js'; -function isPathInsideDir(base: string, target: string): boolean { +export function isPathInsideDir(base: string, target: string): boolean { const resolvedBase = path.resolve(base); const resolvedTarget = path.resolve(target); if (resolvedBase === resolvedTarget) return true; @@ -12,6 +12,17 @@ function isPathInsideDir(base: string, target: string): boolean { return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); } +// EDIT_SCRIPT interpolates these directly into Python source (e.g. `int(${params.x})`) run +// via `python3 -c