v4.1.12: 보안 취약점 수정 — RCE, 경로탈출 2건, IDOR, XSS(스튜디오/언어 앱)
- [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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
+20
-9
@@ -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 <script>` — an unvalidated string like `0)); import os; os.system(...); x=(0`
|
||||
// breaks out of the int()/float() call and executes as arbitrary Python. Every numeric field
|
||||
// that reaches that template MUST be coerced through this first; non-finite input silently
|
||||
// falls back to the default rather than erroring, since these are cosmetic edit params, not
|
||||
// something worth failing the whole request over.
|
||||
export function toFiniteNumber(value: any, fallback: number): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.tif', '.heic', '.heif', '.avif']);
|
||||
|
||||
function runPython(script: string, timeoutMs = 60_000): Promise<any> {
|
||||
@@ -717,18 +728,18 @@ export const imageEditTool = {
|
||||
src: resolved,
|
||||
dst: outPath,
|
||||
operation,
|
||||
x: args?.x ?? 0,
|
||||
y: args?.y ?? 0,
|
||||
width: args?.width ?? 0,
|
||||
height: args?.height ?? 0,
|
||||
x: toFiniteNumber(args?.x, 0),
|
||||
y: toFiniteNumber(args?.y, 0),
|
||||
width: toFiniteNumber(args?.width, 0),
|
||||
height: toFiniteNumber(args?.height, 0),
|
||||
keep_aspect: args?.keep_aspect ?? false,
|
||||
degrees: args?.degrees ?? 0,
|
||||
degrees: toFiniteNumber(args?.degrees, 0),
|
||||
direction: args?.direction ?? 'horizontal',
|
||||
value: args?.value ?? 1.0,
|
||||
value: toFiniteNumber(args?.value, 1.0),
|
||||
text: args?.text ?? '',
|
||||
position: args?.position ?? 'bottom-right',
|
||||
opacity: args?.opacity ?? 0.5,
|
||||
quality: args?.quality ?? 92,
|
||||
opacity: toFiniteNumber(args?.opacity, 0.5),
|
||||
quality: toFiniteNumber(args?.quality, 92),
|
||||
style: args?.style ?? 'painting',
|
||||
preset: args?.preset ?? 'warm',
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { ToolResult } from '../types.js';
|
||||
import { getWorkspacePath } from '../config/paths.js';
|
||||
import { buildImageMarkdown } from './image.js';
|
||||
import { buildImageMarkdown, toFiniteNumber, isPathInsideDir } from './image.js';
|
||||
import { getOllamaConfig } from './web.js';
|
||||
|
||||
// SDXL/LTX-Video's text encoders (CLIP/T5) are trained overwhelmingly on English
|
||||
@@ -334,6 +334,7 @@ export const imageStyleTransformTool = {
|
||||
|
||||
const workspacePath = getWorkspacePath(args);
|
||||
const srcPath = path.isAbsolute(imageArg) ? imageArg : path.resolve(workspacePath, imageArg);
|
||||
if (!isPathInsideDir(workspacePath, srcPath)) return { success: false, error: 'Access denied: path escapes workspace' };
|
||||
if (!fs.existsSync(srcPath)) return { success: false, error: `Source image not found: ${imageArg}` };
|
||||
|
||||
let outPath = String(args?.output || '').trim();
|
||||
@@ -349,8 +350,8 @@ export const imageStyleTransformTool = {
|
||||
negative_prompt: preset.negative,
|
||||
strength: Math.min(1, Math.max(0.05, args?.strength ?? preset.strength)),
|
||||
steps: Math.min(50, Math.max(15, args?.steps ?? 40)),
|
||||
guidance_scale: args?.guidance_scale ?? preset.guidance_scale,
|
||||
seed: args?.seed,
|
||||
guidance_scale: toFiniteNumber(args?.guidance_scale, preset.guidance_scale),
|
||||
seed: args?.seed != null ? toFiniteNumber(args.seed, 0) : undefined,
|
||||
face_lock: args?.face_lock ?? true,
|
||||
max_side: 1024,
|
||||
dst: outPath,
|
||||
@@ -438,7 +439,7 @@ export const imageGenerateTool = {
|
||||
prompt: translatedPrompt || prompt,
|
||||
width, height,
|
||||
steps: Math.min(8, Math.max(1, args?.steps ?? 4)),
|
||||
seed: args?.seed,
|
||||
seed: args?.seed != null ? toFiniteNumber(args.seed, 0) : undefined,
|
||||
dst: outPath,
|
||||
};
|
||||
result = await runVenvPython(FLUX_SCRIPT(params), 180_000);
|
||||
@@ -448,8 +449,8 @@ export const imageGenerateTool = {
|
||||
negative_prompt: translatedNegative || negativePromptRaw,
|
||||
width, height,
|
||||
steps: Math.min(50, Math.max(15, args?.steps ?? 30)),
|
||||
guidance_scale: args?.guidance_scale ?? 7.0,
|
||||
seed: args?.seed,
|
||||
guidance_scale: toFiniteNumber(args?.guidance_scale, 7.0),
|
||||
seed: args?.seed != null ? toFiniteNumber(args.seed, 0) : undefined,
|
||||
dst: outPath,
|
||||
};
|
||||
result = await runVenvPython(SDXL_SCRIPT(params), 180_000);
|
||||
@@ -566,8 +567,8 @@ export const videoGenerateTool = {
|
||||
negative_prompt: translatedNegative || negativePromptRaw,
|
||||
width: Math.round((args?.width ?? 704) / 32) * 32,
|
||||
height: Math.round((args?.height ?? 480) / 32) * 32,
|
||||
num_frames: args?.num_frames ?? 65,
|
||||
fps: args?.fps ?? 24,
|
||||
num_frames: toFiniteNumber(args?.num_frames, 65),
|
||||
fps: toFiniteNumber(args?.fps, 24),
|
||||
steps: Math.min(50, Math.max(15, args?.steps ?? 40)),
|
||||
guidance_scale: Math.min(10, Math.max(1, args?.guidance_scale ?? 3.0)),
|
||||
dst: outPath,
|
||||
|
||||
Reference in New Issue
Block a user