') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(sec): prevent tenant path traversal and bound MCP request body by opaopa6969 · Pull Request #94 · opaopa6969/tinyexpression · GitHub
Skip to content

fix(sec): prevent tenant path traversal and bound MCP request body - #94

Merged
opaopa6969 merged 1 commit into
masterfrom
sec/path-traversal-and-mcp-body-limit
Aug 29, 2026
Merged

fix(sec): prevent tenant path traversal and bound MCP request body#94
opaopa6969 merged 1 commit into
masterfrom
sec/path-traversal-and-mcp-body-limit

Conversation

@opaopa6969

Copy link
Copy Markdown
Owner

背景

セキュリティ観点の横断確認で見つかった、再現経路が成立する2件を修正。

修正1: パストラバーサル (FileBaseTinyExpressionInstancesCache)

instances/FileBaseTinyExpressionInstancesCache.javarootFolder.resolve(tenantId.asString()) でテナント別の formulaInfo.txt を開く。TenantID は interface で asString() の override を許すため、"../../etc""/etc" を返す実装を渡すと root 配下を脱出できる。

再現手順:

  1. TenantID を独自実装し asString()"../../etc" を返すようにする
  2. new FileBaseTinyExpressionInstancesCache(rootPath, fields).cache(tenantId, ...) を呼ぶ
  3. rootPath 配下ではなく rootPath の親方向のパスが開かれる

修正:resolveUnderRootrootFolder.toAbsolutePath().normalize() 配下に制限し、脱出する場合は IllegalArgumentException で拒否。

修正2: MCP リクエストbodyの無制限読み込み (McpServer)

mcp/McpServer.javareadBodyis.readAllBytes() で無制限に読み込む。サーバは 0.0.0.0 にバインドされるため、ネットワークから巨大 POST を送ると OOM。

再現手順:

  1. McpServer を起動(既定ポート 9237)
  2. curl -X POST http://host:9237/mcp --data-binary @/dev/zero のように巨大 body を送信
  3. readAllBytes() でヒープを消費し OOM

修正:Content-Length ヘッダと実際の読み込みを 16 MiB(MAX_REQUEST_BODY_BYTES)で制限。超過時は 413 を返す。

テスト

  • FileBaseTinyExpressionInstancesCacheTest: 通常 acceptance / traversal 拒否 / 絶対パス拒否 / null root 拒否 の4件
  • McpServerTest#oversizedBody_rejectedWith413: 上限超過で 413 が返ることを確認
  • 既存 TinyExpressionsExecutorTest, FormulaInfoParserTest, McpServerTest 全通過

範囲外(別 issue で起票)

  • MCP server の 0.0.0.0 バインド + 認証なしは破壊的判断が必要なため別 issue で扱う

FileBaseTinyExpressionInstancesCache: resolve tenant paths strictly
under the configured rootFolder. Any TenantID implementation whose
asString() returns traversal sequences (../, absolute paths) is now
rejected before opening the file, so a custom TenantID cannot escape
the formula root directory.
McpServer: replace unbounded readAllBytes() with a 16 MiB cap on both
the Content-Length header and the actual stream read, returning 413
when exceeded. Prevents OOM from huge POST bodies on the 0.0.0.0-bound
HTTP listener.
@opaopa6969
opaopa6969 merged commit 248d4bc into masterAug 29, 2026
2 checks passed
@opaopa6969
opaopa6969 deleted the sec/path-traversal-and-mcp-body-limit branch August 29, 2026 19:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@opaopa6969