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:
kim
2026-07-16 15:04:20 +09:00
co-authored by Claude Sonnet 5
parent 5b089ac297
commit edd7816048
6 changed files with 411 additions and 35 deletions
+20 -9
View File
@@ -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',
};
+9 -8
View File
@@ -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,