Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions docs/reviews/2026-09-04-flex-preview-extension-parity-review.md
Original file line number Diff line number Diff line change
@@ -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 は次サイクル可。
25 changes: 25 additions & 0 deletions extensions/line-flex-viewer/README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,21 @@ claude mcp add line-flex-viewer -- node <REPO>/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 を描画しないため、これはプレビュー専用の利便機能です。

## プレビューの対応範囲

| 分類 | 対応するもの |
Expand All @@ -104,3 +119,13 @@ claude mcp add line-flex-viewer -- node <REPO>/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
```
24 changes: 24 additions & 0 deletions extensions/line-flex-viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
```
15 changes: 15 additions & 0 deletions extensions/line-flex-viewer/extension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}/`;
}

Expand Down
Loading
Loading