From 63b9efefed213034d2dc8ad57831aa88f0a886c6 Mon Sep 17 00:00:00 2001 From: Hirotada Kobayashi Date: Fri, 4 Sep 2026 15:51:29 +0900 Subject: [PATCH] feat(extensions): serve local media in Flex viewer canvas + bundled MCP server Ports the .NET FlexPreviewService local-media serving (LINE_FLEX_MCP_ASSET_DIR) to the two Node servers so a Flex message can reference local artwork/video by a relative url in every preview path, not just the .NET MCP tool. - Add shared lib/assets.mjs (resolveAssetPath / assetContentType / isLoopbackHost / resolveMediaRequest), a port of the .NET confinement logic kept in one place so the canvas (extension.mjs) and bundled MCP server (mcp/server.mjs) cannot diverge. - Wire both servers to serve assets under LINE_FLEX_MCP_ASSET_DIR (opt-in, loopback host-guarded, extension allowlist .png/.jpg/.jpeg/.mp4, path traversal/symlink escape refused). renderer.js and web assets unchanged. - Add zero-dependency node:test suite (42 tests): a superset of the .NET FlexPreviewAssetServingTests plus DNS-rebinding and resolveMediaRequest cases. Cleanup uses a single async fs.rm to avoid an rmSync recursive crash on Windows with non-ASCII temp paths. - Add npm test script and a CI job (node --test) so JS-specific regressions are caught; runs on Linux where the symlink-escape test is exercised. - Document local media serving in the extension README (en/ja). 3-role gate: security PASS / code PASS / test-arch PASS (non-blocking CONCERNS), no blocking findings. Record: docs/reviews/2026-09-04-flex-preview-extension-parity-review.md Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 19 + ...04-flex-preview-extension-parity-review.md | 55 +++ extensions/line-flex-viewer/README.ja.md | 25 ++ extensions/line-flex-viewer/README.md | 24 ++ extensions/line-flex-viewer/extension.mjs | 15 + extensions/line-flex-viewer/lib/assets.mjs | 155 ++++++++ .../line-flex-viewer/lib/assets.test.mjs | 332 ++++++++++++++++++ extensions/line-flex-viewer/mcp/package.json | 3 + extensions/line-flex-viewer/mcp/server.mjs | 16 + 9 files changed, 644 insertions(+) create mode 100644 docs/reviews/2026-09-04-flex-preview-extension-parity-review.md create mode 100644 extensions/line-flex-viewer/lib/assets.mjs create mode 100644 extensions/line-flex-viewer/lib/assets.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc8e9fc..2a948dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,25 @@ jobs: - name: Test run: dotnet test LineOpenApi.slnx --configuration Release --no-build --verbosity normal + # Node tests for the Flex viewer extension's shared local-media serving (lib/assets.mjs). + # This logic is a JS port of the .NET FlexPreviewService confinement; its implementation + # details (percent-decoding, string host:port parsing, path confinement) are distinct + # from the .NET code, so the .NET suite does not guard against JS regressions. Zero + # runtime deps — Node's built-in test runner only. Runs on Linux so the symlink-escape + # test (skipped on Windows without dev mode) is actually exercised. + extension-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '20' + + - name: Test Flex viewer extension + run: node --test extensions/line-flex-viewer/lib/assets.test.mjs + # Pack smoke test: guards the produced package layout (5 code packages with lib+snupkg, # 1 Bot meta-package with no lib/no snupkg/3 deps) against silent packaging regressions # that build+test cannot catch. See scripts/verify-packages.ps1. diff --git a/docs/reviews/2026-09-04-flex-preview-extension-parity-review.md b/docs/reviews/2026-09-04-flex-preview-extension-parity-review.md new file mode 100644 index 0000000..54b1016 --- /dev/null +++ b/docs/reviews/2026-09-04-flex-preview-extension-parity-review.md @@ -0,0 +1,55 @@ +# 2026-09-04 Flex プレビュー拡張 ローカルメディア配信 parity レビュー記録 + +## 概要 + +2026-09-04 に .NET 側(`Line.OpenApi.Tools` の `FlexPreviewService.cs`)へ追加したローカルメディア配信(環境変数 `LINE_FLEX_MCP_ASSET_DIR` 配下の画像/動画を相対 `url` で配信)を、**Node/JS 側の 2 サーバへ移植して機能 parity を取った**。 + +- **Copilot キャンバス拡張** `extensions/line-flex-viewer/extension.mjs` +- **同梱 Node MCP サーバ** `extensions/line-flex-viewer/mcp/server.mjs` + +両サーバとも、これまで GET は「静的ファイル whitelist + `/api/*`」しか処理せず相対 `url` のメディアは 404 になっていた(.NET MCP 経由のみ配信可=非対称)。本変更でこの非対称を解消。 + +**利用シーン:** 用意したメディアをフォルダに配置 → Flex JSON からは相対 `url`(例 `"assets/hero.png"`)で参照 → 本番移行時は origin だけ HTTPS の CDN に差し替える(相対パス部分は不変)。プレビュー専用の利便機能(LINE 本体はローカル/`data:` URL を描画しない)。 + +## 設計判断(ADR) + +- **封じ込めロジックを共有モジュール `lib/assets.mjs` に集約**し、2 サーバが同一の `resolveMediaRequest` を通す。セキュリティ上重要な封じ込めの二重持ちを避ける(リポジトリの `SpecNormalization.ps1` 二重持ちバグの教訓に倣う)。 +- `resolveMediaRequest({ path, host, boundPort, assetDir })` を「実サーバが呼ぶ実コードパス」とし、opt-in 判定 → `isLoopbackHost` ホストガード → `resolveAssetPath` 封じ込めの順(FS 接触前にホスト検証)。 +- 純粋関数(`resolveAssetPath`/`assetContentType`/`isLoopbackHost`/`resolveAssetDir`)は .NET の `ResolveAssetPath`/`AssetContentType`/`IsLoopbackHost` を移植し挙動を一致。 +- `renderer.js` ほか `web/` は無改修(ブラウザが相対 URL をページ origin に解決)。 +- テストは Node 内蔵 `node:test`(依存ゼロ・zero-dependency 方針維持)。 + +## 封じ込め(多層防御・.NET と parity) + +1. `isLoopbackHost(host, boundPort)` ガード(DNS リバインド読み取り対策・両サーバの配線で実バインドポートを使用) +2. 拡張子 allowlist(`.png`/`.jpg`/`.jpeg`/`.mp4`)+ content-type も `image/*`・`video/mp4`・`application/octet-stream` に限定 +3. 制御文字(C0+DEL)拒否=`%00` トリック無効 +4. `path.resolve` 正規化 → `fullBase + sep` の前置比較(`../`・`..\`・`%2e%2e`・`..%2f`・`%5c`・rooted/UNC をすべて捕捉。末尾セパレータ付与で兄弟プレフィックス誤許可も防止) +5. シンボリックリンク物理封じ込め(`lstatSync` で最終要素が symlink のとき `realpathSync` でベース配下を確認・非リンクは as-is) + +## テスト + +- 新規 `extensions/line-flex-viewer/lib/assets.test.mjs`(**42 tests 全緑**)。.NET の `FlexPreviewAssetServingTests` の**完全な上位集合**(純粋封じ込め・トラバーサル各種エンコード・rooted/UNC・拡張子 allowlist・大文字拡張子・symlink 越え・content-type・ループバック e2e・未設定時非配信)。 +- JS で追加した価値あるケース: `resolveAssetDir` 正規化、制御文字拒否、`isLoopbackHost` の accept/reject(文字列 `host:port` パースの独立検証)、`resolveMediaRequest` 直接テスト、**DNS リバインド e2e**(外部 Host → 404)。 +- クリーンアップは単一 async `after()` + `fs.rm` に集約(同期 `rmSync` 再帰が CJK パスの Windows/Node でハードクラッシュする環境バグ回避。CI の ASCII パスでは元々非該当)。 +- `mcp/package.json` に `npm test`(`node --test ../lib/assets.test.mjs`)を追加。 +- **実 `mcp/server.mjs` を stdio で起動した手動 e2e スモーク**で実配信を確認(画像 200/バイト一致・`.gif`→404・トラバーサル→404・外部 Host→404)。 + +## 3 役ゲート結果(すべて PASS・BLOCKING なし) + +- **security-reviewer = PASS**:単段デコード・ベース配下判定・rooted/UNC/制御文字・ホストガード順序・opt-in・情報漏洩いずれも .NET と等価と実証。指摘は非ブロッキング(Medium=既存 follow-up の `/api/*` 未ガード横展開、Low=中間ディレクトリ symlink 共有限界/`nosniff` 未設定/リソース枯渇既知)。 +- **code-reviewer = PASS**:content-type/allowlist/404/ホストガードの parity 厳密・両サーバ配線一貫・共有 import 整合・未使用 import なし・英語コメント規約遵守。指摘は Low/Info のみ(C1 制御文字未拒否/不正 `%` の扱い差=JS 側が厳格=安全側/中間 symlink 共有仕様/`lib/` 同梱前提)。 +- **test-arch-reviewer = PASS(非ブロッキング CONCERNS)**:カバレッジは .NET の上位集合で穴なし。集約アーキ妥当。CONCERNS=e2e が実サーバでなく共有 `resolveMediaRequest` の最小 harness(フェイルクローズ・拡張子非重複・手動スモーク済みで緩和)。 + +## 未対応(非ブロッキング follow-up) + +- **JS テストの CI 未組込み**(test-arch 推奨・最有力): `ci.yml` に `node --test extensions/line-flex-viewer/lib/assets.test.mjs` の軽量ジョブ追加(`actions/setup-node` のみ・依存ゼロ)。 +- **`/api/*`・静的配信へのホストガード横展開**(security Medium・既存 follow-up と同一)。または per-instance トークン化。 +- **実サーバ配線の e2e 化**(test-arch 提案): `handleRequest`/`startServer`/`sendFile` を SDK 非依存の `lib/preview-http.mjs` へ切り出し、raw ソケット harness で実経路を自動検証。 +- C1 制御文字の拒否・`X-Content-Type-Options: nosniff`(.NET も未対応=両実装同時対応が望ましい)。 +- 中間ディレクトリ symlink の無条件 realpath 化(.NET と共有の限界)。 +- ファイルサイズ上限なし(既知・プレビュー用途で実害限定)。 + +## 人の go/no-go + +未(本記録は 3 役ゲート完了時点)。BLOCKING なしのため GO 推奨。follow-up は次サイクル可。 diff --git a/extensions/line-flex-viewer/README.ja.md b/extensions/line-flex-viewer/README.ja.md index 927d3a4..6497238 100644 --- a/extensions/line-flex-viewer/README.ja.md +++ b/extensions/line-flex-viewer/README.ja.md @@ -92,6 +92,21 @@ claude mcp add line-flex-viewer -- node /extensions/line-flex-viewer/mcp/s 任意の設定として、`LINE_FLEX_MCP_NO_OPEN`(ブラウザを自動で開かず URL だけ返す)と `LINE_FLEX_MCP_STATE_DIR`(現在のプレビュー内容の保存先)があります。 +### ローカルの画像・動画をプレビューする + +`LINE_FLEX_MCP_ASSET_DIR` に自分で用意したメディアのフォルダを指定すると、プレビューサーバが +その配下のファイルを配信します。Flex メッセージからは **相対** `url`(例 `"assets/hero.png"`)で +参照でき、本番移行時は origin だけ HTTPS の CDN に差し替えれば Flex JSON はそのまま使えます。 +これは MCP サーバと Copilot キャンバス拡張の両方で有効です。 + +- **オプトイン** — `LINE_FLEX_MCP_ASSET_DIR` を設定したときだけ配信します(未設定なら無効、ファイルが + 無ければ 404)。 +- **封じ込め** — 配信されるのはそのディレクトリ配下のファイルだけ(パストラバーサル・絶対パス・ + ディレクトリ外へ抜けるシンボリックリンクは拒否)で、ループバックのプレビューサーバ経由に限られます。 +- **対応メディア** — LINE が Flex メッセージで実際に描画する形式に一致します。画像は + `.png`/`.jpg`/`.jpeg`(APNG は `.png`)、`video` コンポーネント用に `.mp4`。その他(GIF・WebP)は + 拒否します。LINE 自身はローカル/`data:` の url を描画しないため、これはプレビュー専用の利便機能です。 + ## プレビューの対応範囲 | 分類 | 対応するもの | @@ -104,3 +119,13 @@ claude mcp add line-flex-viewer -- node /extensions/line-flex-viewer/mcp/s > プレビューは LINE のレンダラを **CSS で近似**したものです。サイズは LINE の資料に沿っていますが、 > 厳密なピクセル値は LINE アプリと多少ずれることがあります。まずここで見た目を固め、最終確認は > 実機で行うのがおすすめです。 + +## 開発 + +キャンバス拡張(`extension.mjs`)と同梱の MCP サーバ(`mcp/server.mjs`)は、ローカルメディア配信の +ロジックを `lib/assets.mjs` で共有しています。パス封じ込めとホストガードの挙動は、依存ゼロの +テストスイート(Node 標準のテストランナー)で検証しています。 + +```bash +node --test lib/assets.test.mjs # または: cd mcp && npm test +``` diff --git a/extensions/line-flex-viewer/README.md b/extensions/line-flex-viewer/README.md index fed8d1c..e9bb04b 100644 --- a/extensions/line-flex-viewer/README.md +++ b/extensions/line-flex-viewer/README.md @@ -94,6 +94,20 @@ Then ask Claude to "preview this Flex Message." The tools it can call: Optional settings: `LINE_FLEX_MCP_NO_OPEN` (don't auto-open the browser; the URL is still returned) and `LINE_FLEX_MCP_STATE_DIR` (where the current preview content is saved). +### Previewing local images and video + +Set `LINE_FLEX_MCP_ASSET_DIR` to a folder of your own media and the preview server will serve +files under it, so a Flex message can reference local artwork by a **relative** `url` (e.g. +`"assets/hero.png"`). When you go to production, swap only the origin for your HTTPS CDN and the +Flex JSON is unchanged. This applies to both the MCP server and the Copilot canvas extension. + +- **Opt-in** — serving is off unless `LINE_FLEX_MCP_ASSET_DIR` is set; a missing file just 404s. +- **Confined** — only files under that directory are served (path traversal, rooted paths, and + symlinks that escape the directory are refused), and only over the loopback preview server. +- **Supported media** — the formats LINE actually renders in a Flex message: `.png`/`.jpg`/`.jpeg` + images (APNG is a `.png`) and `.mp4` for the `video` component. Other formats (GIF, WebP) are + refused. LINE itself never renders local/`data:` urls, so this is a preview-only convenience. + ## What the preview supports | Category | Supported | @@ -106,3 +120,13 @@ returned) and `LINE_FLEX_MCP_STATE_DIR` (where the current preview content is sa > The preview is a **CSS approximation** of LINE's renderer. Sizes follow LINE's documented > scale, but exact pixels may differ slightly from the LINE app. Use it to get the design > right, then confirm the final look on a real device. + +## Development + +The canvas extension (`extension.mjs`) and the bundled MCP server (`mcp/server.mjs`) share the +local-media serving logic in `lib/assets.mjs`. Its path-confinement and host-guard behavior is +covered by a zero-dependency test suite (Node's built-in runner): + +```bash +node --test lib/assets.test.mjs # or: cd mcp && npm test +``` diff --git a/extensions/line-flex-viewer/extension.mjs b/extensions/line-flex-viewer/extension.mjs index bae124f..a323b14 100644 --- a/extensions/line-flex-viewer/extension.mjs +++ b/extensions/line-flex-viewer/extension.mjs @@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join, extname, resolve as resolvePath } from "node:path"; import { homedir } from "node:os"; import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; +import { resolveAssetDir, resolveMediaRequest } from "./lib/assets.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const WEB_DIR = join(__dirname, "web"); @@ -23,6 +24,11 @@ const WEB_DIR = join(__dirname, "web"); const COPILOT_HOME = process.env.COPILOT_HOME || join(homedir(), ".copilot"); const ARTIFACT_DIR = join(COPILOT_HOME, "extensions", "line-flex-viewer", "artifacts"); +// Opt-in local media serving: when LINE_FLEX_MCP_ASSET_DIR is set, media files under it +// are served so a Flex message can reference local artwork/video by a relative url. +// Disabled (null) unless configured. Mirrors the .NET FlexPreviewService. +const ASSET_DIR = resolveAssetDir(process.env.LINE_FLEX_MCP_ASSET_DIR); + const STATIC_FILES = new Set(["viewer.html", "viewer.js", "renderer.js", "flex.css", "samples.js", "standalone.html", "standalone.js"]); const CONTENT_TYPES = { ".html": "text/html; charset=utf-8", @@ -198,6 +204,14 @@ function handleRequest(entry, req, res) { return; } } + // Local media (opt-in via LINE_FLEX_MCP_ASSET_DIR) so a Flex message can reference + // artwork/video by a relative url. The helper applies the loopback-host guard and + // path confinement; it returns null (→ 404) when serving is disabled or refused. + const media = resolveMediaRequest({ path, host: req.headers.host, boundPort: entry.port, assetDir: ASSET_DIR }); + if (media) { + sendFile(res, media.file, media.contentType); + return; + } res.writeHead(404); res.end("not found"); return; @@ -260,6 +274,7 @@ async function startServer(entry) { const addr = server.address(); const port = typeof addr === "object" && addr ? addr.port : 0; entry.server = server; + entry.port = port; entry.url = `http://127.0.0.1:${port}/`; } diff --git a/extensions/line-flex-viewer/lib/assets.mjs b/extensions/line-flex-viewer/lib/assets.mjs new file mode 100644 index 0000000..44276bf --- /dev/null +++ b/extensions/line-flex-viewer/lib/assets.mjs @@ -0,0 +1,155 @@ +// Shared local-media serving for the LINE Flex viewer's loopback servers. +// +// The Copilot canvas (../extension.mjs) and the standalone MCP server +// (../mcp/server.mjs) both let a Flex message reference local artwork/video by a +// relative url (e.g. "assets/hero.png") during preview, then swap only the origin +// for the production HTTPS URL later. This is opt-in via LINE_FLEX_MCP_ASSET_DIR +// and confined to that directory. +// +// This module mirrors the .NET FlexPreviewService (ResolveAssetPath / +// AssetContentType / IsLoopbackHost) so the two servers resolve requests +// identically. The confinement logic is security-sensitive: keep it here in one +// place so the servers cannot diverge (a lesson from the spec-normalization +// double-implementation bug elsewhere in this repo). +// +// The served set matches what LINE actually renders in a Flex message: images are +// JPEG/PNG (APNG is .png) and videos are .mp4 (the video component). LINE itself +// does not render data:/local urls, so this is a preview-only convenience. + +import { resolve as resolvePath, extname, sep } from "node:path"; +import { statSync, lstatSync, realpathSync } from "node:fs"; + +// Only these extensions are served from the asset directory. Other formats +// (GIF, WebP, ...) are intentionally excluded because LINE does not render them +// in a Flex message. +export const ASSET_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".mp4"]); + +/** Map a file extension to the content type LINE-renderable media is served with. */ +export function assetContentType(ext) { + switch (String(ext).toLowerCase()) { + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".mp4": + return "video/mp4"; + default: + return "application/octet-stream"; + } +} + +/** + * Normalize a LINE_FLEX_MCP_ASSET_DIR value to a full path once, or null when the + * value is empty/blank. A relative value is resolved against the current directory, + * so an absolute path is recommended. The directory need not exist yet (files may be + * added later); a missing file simply resolves to null at request time. + */ +export function resolveAssetDir(value) { + if (!value || !value.trim()) return null; + try { + return resolvePath(value); + } catch { + return null; + } +} + +/** + * The Host header must be the loopback authority we bound to (127.0.0.1:), + * accepting the "localhost" alias for the same port. Anything else (a rebound DNS + * name, another port) is rejected. Mirrors .NET IsLoopbackHost — used to blunt + * DNS-rebinding reads of local media. + */ +export function isLoopbackHost(hostHeader, boundPort) { + if (!hostHeader || !boundPort) return false; + const colon = hostHeader.lastIndexOf(":"); + if (colon < 0) return false; + const host = hostHeader.slice(0, colon); + const port = hostHeader.slice(colon + 1); + return ( + (host === "127.0.0.1" || host.toLowerCase() === "localhost") && + port === String(boundPort) + ); +} + +/** + * Resolve an HTTP request path (e.g. "/assets/hero.png") to a file under assetDir, + * or null when serving is disabled, the extension is not an allowed media type, the + * resolved path escapes the directory, or the file does not exist. + * + * Confinement is enforced by normalizing both the base and the combined path to full + * paths and requiring the candidate to stay under the base — this rejects ../ traversal, + * rooted/absolute segments, and backslash tricks regardless of the encoded form. + * Mirrors .NET FlexPreviewService.ResolveAssetPath. + */ +export function resolveAssetPath(assetDir, requestPath) { + if (!assetDir || !requestPath) return null; + + let decoded; + try { + decoded = decodeURIComponent(requestPath); + } catch { + return null; // malformed percent-encoding + } + + // Reject NUL / control characters defensively before touching the filesystem. + for (let i = 0; i < decoded.length; i++) { + const code = decoded.charCodeAt(i); + if (code < 0x20 || code === 0x7f) return null; + } + + // Strip leading path separators (both / and \) so the remainder is relative. + const relative = decoded.replace(/^[/\\]+/, ""); + if (relative.length === 0) return null; + + if (!ASSET_EXTENSIONS.has(extname(relative).toLowerCase())) return null; + + let fullBase, candidate; + try { + fullBase = resolvePath(assetDir); + candidate = resolvePath(fullBase, relative); + } catch { + return null; + } + + const baseWithSep = fullBase.endsWith(sep) ? fullBase : fullBase + sep; + if (!candidate.startsWith(baseWithSep)) return null; + + // Must be an existing regular file. + let st; + try { + st = statSync(candidate); + } catch { + return null; + } + if (!st.isFile()) return null; + + // Defense in depth: the prefix check above is lexical (resolvePath does not follow + // links). If the entry itself is a symlink, resolve its final target and require that + // to stay under the base too, so a link inside the directory cannot escape it. + // Non-links are served as-is (mirrors .NET ResolveLinkTarget == null). + try { + if (lstatSync(candidate).isSymbolicLink()) { + const real = realpathSync(candidate); + if (real !== fullBase && !real.startsWith(baseWithSep)) return null; + } + } catch { + return null; // if the link target cannot be verified, refuse to serve. + } + + return candidate; +} + +/** + * One-call helper for the loopback servers: given the request path, the incoming Host + * header, the bound port, and the configured asset dir, return { file, contentType } to + * serve, or null to fall through to 404. Encapsulates the loopback-host guard plus path + * confinement so the canvas and MCP servers share one identical, tested code path. + */ +export function resolveMediaRequest({ path, host, boundPort, assetDir }) { + if (!assetDir) return null; + if (!isLoopbackHost(host, boundPort)) return null; + const file = resolveAssetPath(assetDir, path); + if (!file) return null; + return { file, contentType: assetContentType(extname(file)) }; +} diff --git a/extensions/line-flex-viewer/lib/assets.test.mjs b/extensions/line-flex-viewer/lib/assets.test.mjs new file mode 100644 index 0000000..a094d03 --- /dev/null +++ b/extensions/line-flex-viewer/lib/assets.test.mjs @@ -0,0 +1,332 @@ +// Tests for local asset serving shared by the LINE Flex viewer's loopback servers +// (../extension.mjs canvas + ../mcp/server.mjs). Mirrors the .NET +// FlexPreviewAssetServingTests: the pure path-confinement logic (resolveAssetPath), +// the loopback host guard (isLoopbackHost / resolveMediaRequest), the content-type +// map, and loopback end-to-end fetches proving a media file under +// LINE_FLEX_MCP_ASSET_DIR is actually served while traversal, unsupported extensions, +// and cross-host (DNS-rebinding) reads are refused. +// +// Zero dependencies: run with `node --test lib/assets.test.mjs` (Node 18+). + +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { connect } from "node:net"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, symlinkSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve as resolvePath } from "node:path"; + +import { + resolveAssetPath, + resolveAssetDir, + assetContentType, + isLoopbackHost, + resolveMediaRequest, +} from "./assets.mjs"; + +// --- helpers --------------------------------------------------------------- + +// One temp base for the whole run, removed once at the end. Per-test cleanup is +// deliberately avoided: synchronous recursive removal (fs.rmSync) hard-crashes some +// Windows/Node builds when the temp path contains non-ASCII characters, so we defer +// to a single async fs.rm in after(). +const BASE = mkdtempSync(join(tmpdir(), "line-flex-asset-")); + +after(async () => { + await rm(BASE, { recursive: true, force: true }); +}); + +// A temp tree per test: `root` is the served directory, `parent` sits one level above +// it (where "escaped" targets are planted so traversal must not reach them). +function makeTempDir() { + const parent = mkdtempSync(join(BASE, "d-")); + const root = join(parent, "root"); + mkdirSync(root, { recursive: true }); + return { + path: root, + parent, + writeFile(relative, content) { + const full = join(root, relative); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); + return full; + }, + }; +} + +function rawRequest(port, rawTarget, hostHeader) { + return new Promise((resolve, reject) => { + const socket = connect(port, "127.0.0.1", () => { + const host = hostHeader ?? `127.0.0.1:${port}`; + socket.write(`GET ${rawTarget} HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`); + }); + const chunks = []; + socket.on("data", (c) => chunks.push(c)); + socket.on("end", () => { + const buf = Buffer.concat(chunks); + const sepIdx = buf.indexOf("\r\n\r\n"); + const headerText = buf.slice(0, sepIdx < 0 ? buf.length : sepIdx).toString("latin1"); + const body = sepIdx < 0 ? Buffer.alloc(0) : buf.slice(sepIdx + 4); + const lines = headerText.split("\r\n"); + const status = parseInt((lines[0] || "").split(" ")[1], 10) || -1; + const headers = {}; + for (let i = 1; i < lines.length; i++) { + const idx = lines[i].indexOf(":"); + if (idx > 0) headers[lines[i].slice(0, idx).trim().toLowerCase()] = lines[i].slice(idx + 1).trim(); + } + resolve({ status, headers, body }); + }); + socket.on("error", reject); + socket.setTimeout(5000, () => { socket.destroy(); reject(new Error("timeout")); }); + }); +} + +// A minimal loopback server wired with the exact helper both real servers use. +function startMediaServer(assetDir) { + return new Promise((resolve) => { + const server = createServer((req, res) => { + const path = new URL(req.url, "http://127.0.0.1").pathname; + const media = resolveMediaRequest({ + path, + host: req.headers.host, + boundPort: server.address().port, + assetDir, + }); + if (media) { + // Set Content-Length so the test's raw-socket reader gets the body verbatim + // (without it Node uses chunked transfer-encoding, which the reader does not decode). + const buf = readFileSync(media.file); + res.writeHead(200, { "Content-Type": media.contentType, "Content-Length": buf.length }); + res.end(buf); + return; + } + res.writeHead(404); + res.end("not found"); + }); + server.listen(0, "127.0.0.1", () => resolve({ server, port: server.address().port })); + }); +} + +// --- resolveAssetDir ------------------------------------------------------- + +test("resolveAssetDir: null/blank disables serving", () => { + assert.equal(resolveAssetDir(undefined), null); + assert.equal(resolveAssetDir(""), null); + assert.equal(resolveAssetDir(" "), null); +}); + +test("resolveAssetDir: a value normalizes to a full path", () => { + assert.equal(resolveAssetDir("."), resolvePath(".")); +}); + +// --- pure path confinement ------------------------------------------------- + +test("disabled when no directory configured", () => { + assert.equal(resolveAssetPath(null, "/hero.png"), null); + assert.equal(resolveAssetPath("", "/hero.png"), null); +}); + +test("resolves a file directly under the directory", () => { + const dir = makeTempDir(); + const file = dir.writeFile("hero.png", "img"); + assert.equal(resolveAssetPath(dir.path, "/hero.png"), resolvePath(file)); +}); + +test("resolves a file in a subdirectory", () => { + const dir = makeTempDir(); + const file = dir.writeFile(join("assets", "hero.png"), "img"); + assert.equal(resolveAssetPath(dir.path, "/assets/hero.png"), resolvePath(file)); +}); + +for (const name of ["clip.mp4", "photo.jpg", "photo.jpeg"]) { + test(`resolves supported media extension: ${name}`, () => { + const dir = makeTempDir(); + const file = dir.writeFile(name, "data"); + assert.equal(resolveAssetPath(dir.path, "/" + name), resolvePath(file)); + }); +} + +test("percent-encoded path is decoded", () => { + const dir = makeTempDir(); + const file = dir.writeFile(join("my images", "a b.png"), "img"); + assert.equal(resolveAssetPath(dir.path, "/my%20images/a%20b.png"), resolvePath(file)); +}); + +test("missing file resolves to null", () => { + const dir = makeTempDir(); + assert.equal(resolveAssetPath(dir.path, "/nope.png"), null); +}); + +for (const requestPath of ["/notes.txt", "/archive.zip", "/config.json", "/hero", "/animation.gif", "/photo.webp"]) { + test(`unsupported extension is refused: ${requestPath}`, () => { + const dir = makeTempDir(); + // Even if such a file exists on disk, an unsupported extension must not be served. + dir.writeFile(requestPath.replace(/^\//, ""), "secret"); + assert.equal(resolveAssetPath(dir.path, requestPath), null); + }); +} + +for (const requestPath of [ + "/../secret.png", + "/../../secret.png", + "/assets/../../secret.png", + "/%2e%2e/secret.png", + "/..%2fsecret.png", + "/..%5csecret.png", // backslash-encoded traversal (Windows separator) + "/%2e%2e%2fsecret.png", // fully-encoded ../ +]) { + test(`traversal outside the directory is refused: ${requestPath}`, () => { + const dir = makeTempDir(); + writeFileSync(join(dir.parent, "secret.png"), "secret"); + assert.equal(resolveAssetPath(dir.path, requestPath), null); + }); +} + +for (const requestPath of [ + "/C:/Windows/System32/drivers/etc/hosts.png", // rooted second segment + "/\\\\server\\share\\x.png", // UNC +]) { + test(`rooted or absolute segment is refused: ${requestPath}`, () => { + const dir = makeTempDir(); + assert.equal(resolveAssetPath(dir.path, requestPath), null); + }); +} + +test("control characters are refused", () => { + const dir = makeTempDir(); + assert.equal(resolveAssetPath(dir.path, "/hero%00.png"), null); +}); + +test("uppercase extension is accepted", () => { + const dir = makeTempDir(); + const file = dir.writeFile("LOGO.PNG", "img"); + assert.equal(resolveAssetPath(dir.path, "/LOGO.PNG"), resolvePath(file)); +}); + +test("symlink pointing outside the directory is refused", () => { + const dir = makeTempDir(); + const outside = join(dir.parent, "secret.png"); + writeFileSync(outside, "secret"); + const link = join(dir.path, "evil.png"); + try { symlinkSync(outside, link); } + catch { return; } // symlink creation not permitted here (no admin/dev mode) → skip + // The lexical prefix check passes (the link sits under the dir), but resolving the + // final target must reveal it escapes the directory and refuse it. + assert.equal(resolveAssetPath(dir.path, "/evil.png"), null); +}); + +test("empty or root path is refused", () => { + const dir = makeTempDir(); + assert.equal(resolveAssetPath(dir.path, "/"), null); + assert.equal(resolveAssetPath(dir.path, ""), null); +}); + +// --- content-type mapping -------------------------------------------------- + +for (const [ext, expected] of [ + [".png", "image/png"], + [".PNG", "image/png"], + [".jpg", "image/jpeg"], + [".jpeg", "image/jpeg"], + [".mp4", "video/mp4"], + [".bin", "application/octet-stream"], +]) { + test(`content type from extension: ${ext} → ${expected}`, () => { + assert.equal(assetContentType(ext), expected); + }); +} + +// --- loopback host guard --------------------------------------------------- + +test("isLoopbackHost accepts the bound loopback authority", () => { + assert.equal(isLoopbackHost("127.0.0.1:8080", 8080), true); + assert.equal(isLoopbackHost("localhost:8080", 8080), true); + assert.equal(isLoopbackHost("LocalHost:8080", 8080), true); +}); + +test("isLoopbackHost rejects other hosts, ports, and missing values", () => { + assert.equal(isLoopbackHost("evil.example.com:8080", 8080), false); + assert.equal(isLoopbackHost("127.0.0.1:9999", 8080), false); + assert.equal(isLoopbackHost("127.0.0.1", 8080), false); // no port + assert.equal(isLoopbackHost("", 8080), false); + assert.equal(isLoopbackHost("127.0.0.1:8080", null), false); +}); + +// --- resolveMediaRequest (the shared server code path) --------------------- + +test("resolveMediaRequest: null when serving disabled", () => { + assert.equal(resolveMediaRequest({ path: "/hero.png", host: "127.0.0.1:80", boundPort: 80, assetDir: null }), null); +}); + +test("resolveMediaRequest: null when host is not the bound loopback", () => { + const dir = makeTempDir(); + dir.writeFile("hero.png", "img"); + // File exists and would resolve, but a foreign Host header must refuse it. + assert.equal( + resolveMediaRequest({ path: "/hero.png", host: "evil.example.com:80", boundPort: 80, assetDir: dir.path }), + null + ); +}); + +test("resolveMediaRequest: returns file and content type when allowed", () => { + const dir = makeTempDir(); + const file = dir.writeFile("hero.png", "img"); + const media = resolveMediaRequest({ path: "/hero.png", host: "127.0.0.1:80", boundPort: 80, assetDir: dir.path }); + assert.deepEqual(media, { file: resolvePath(file), contentType: "image/png" }); +}); + +// --- loopback end-to-end --------------------------------------------------- + +test("configured media is served over loopback", async () => { + const dir = makeTempDir(); + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG magic + const mp4 = Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]); // ftyp box start + dir.writeFile(join("assets", "hero.png"), png); + dir.writeFile(join("assets", "night sky.png"), png); // a space in the name + dir.writeFile(join("assets", "promo.mp4"), mp4); + writeFileSync(join(dir.parent, "secret.png"), "secret"); // one level above the served dir + + const { server, port } = await startMediaServer(dir.path); + try { + const image = await rawRequest(port, "/assets/hero.png"); + assert.equal(image.status, 200); + assert.equal(image.headers["content-type"], "image/png"); + assert.deepEqual(image.body, png); + + const video = await rawRequest(port, "/assets/promo.mp4"); + assert.equal(video.status, 200); + assert.equal(video.headers["content-type"], "video/mp4"); + assert.deepEqual(video.body, mp4); + + const encoded = await rawRequest(port, "/assets/night%20sky.png"); + assert.equal(encoded.status, 200); + assert.deepEqual(encoded.body, png); + + const missing = await rawRequest(port, "/nope.png"); + assert.equal(missing.status, 404); + + // Raw-socket traversal that reaches the server as-is (no client normalization): + // anything other than 200 means the secret was not served. + const traversal = await rawRequest(port, "/assets/%2e%2e%2f%2e%2e%2fsecret.png"); + assert.notEqual(traversal.status, 200); + + // DNS-rebinding read: a foreign Host header for the same loopback port is refused. + const rebind = await rawRequest(port, "/assets/hero.png", `evil.example.com:${port}`); + assert.equal(rebind.status, 404); + } finally { + server.close(); + } +}); + +test("media is not served when directory unconfigured", async () => { + const dir = makeTempDir(); + dir.writeFile("hero.png", "img"); + const { server, port } = await startMediaServer(null); // assetDir disabled + try { + const res = await rawRequest(port, "/hero.png"); + assert.equal(res.status, 404); + } finally { + server.close(); + } +}); diff --git a/extensions/line-flex-viewer/mcp/package.json b/extensions/line-flex-viewer/mcp/package.json index c0c6a79..9ac3348 100644 --- a/extensions/line-flex-viewer/mcp/package.json +++ b/extensions/line-flex-viewer/mcp/package.json @@ -7,6 +7,9 @@ "line-flex-viewer-mcp": "server.mjs" }, "main": "server.mjs", + "scripts": { + "test": "node --test ../lib/assets.test.mjs" + }, "engines": { "node": ">=18" }, diff --git a/extensions/line-flex-viewer/mcp/server.mjs b/extensions/line-flex-viewer/mcp/server.mjs index 9eab590..b038994 100644 --- a/extensions/line-flex-viewer/mcp/server.mjs +++ b/extensions/line-flex-viewer/mcp/server.mjs @@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url"; import { dirname, join, extname, resolve as resolvePath } from "node:path"; import { tmpdir } from "node:os"; import { spawn } from "node:child_process"; +import { resolveAssetDir, resolveMediaRequest } from "../lib/assets.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const WEB_DIR = join(__dirname, "..", "web"); @@ -27,6 +28,11 @@ const STATE_FILE = join(STATE_DIR, "content.json"); const HTML_ENTRY = process.env.LINE_FLEX_MCP_HTML || "viewer.html"; // viewer.html = live push const AUTO_OPEN = process.env.LINE_FLEX_MCP_NO_OPEN ? false : true; +// Opt-in local media serving: when LINE_FLEX_MCP_ASSET_DIR is set, media files under it +// are served so a Flex message can reference local artwork/video by a relative url. +// Disabled (null) unless configured. Mirrors the .NET FlexPreviewService. +const ASSET_DIR = resolveAssetDir(process.env.LINE_FLEX_MCP_ASSET_DIR); + const STATIC_FILES = new Set([ "viewer.html", "viewer.js", "renderer.js", "flex.css", "samples.js", "standalone.html", "standalone.js", @@ -41,6 +47,7 @@ const CONTENT_TYPES = { const state = { server: null, url: null, + port: null, content: null, clients: new Set(), opened: false, @@ -200,6 +207,14 @@ function handleRequest(req, res) { return; } } + // Local media (opt-in via LINE_FLEX_MCP_ASSET_DIR) so a Flex message can reference + // artwork/video by a relative url. The helper applies the loopback-host guard and + // path confinement; it returns null (→ 404) when serving is disabled or refused. + const media = resolveMediaRequest({ path, host: req.headers.host, boundPort: state.port, assetDir: ASSET_DIR }); + if (media) { + sendFile(res, media.file, media.contentType); + return; + } res.writeHead(404); res.end("not found"); return; @@ -261,6 +276,7 @@ async function ensureServer() { const addr = server.address(); const port = typeof addr === "object" && addr ? addr.port : 0; state.server = server; + state.port = port; state.url = `http://127.0.0.1:${port}/`; log("preview server listening at", state.url); return state.url;