diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e1445c..eac5368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,14 @@ They evolve on separate cadences, so each has its own version history below. ## Tools — `Line.OpenApi.Tools` +### [1.3.0] - 2026-09-04 + +Lets the Flex preview show your own local artwork and video without hosting. + +#### Added + +- **Local media serving for the Flex preview (`LINE_FLEX_MCP_ASSET_DIR`).** Set this environment variable to a folder and the loopback preview server serves media files from it, so a Flex message can reference them by a **relative** `url` (e.g. `assets/hero.png`) while designing — then swap only the origin for the production HTTPS URL later (the relative path stays the same). The served set matches what LINE renders in a Flex message: **JPEG/PNG** (APNG is `.png`) images and **`.mp4`** for the `video` component (other formats such as GIF/WebP are intentionally not served). Serving is **opt-in** (disabled unless the folder is set), confined to that folder (path traversal and out-of-directory symlinks are rejected), loopback-only, and read-only-safe. LINE itself renders neither local nor `data:` URLs, so this is a preview convenience. + ### [1.2.0] - 2026-09-03 Adds a live, LINE-faithful Flex Message preview to the tool. diff --git a/CHANGELOG_ja.md b/CHANGELOG_ja.md index 7361ab9..0ed01c7 100644 --- a/CHANGELOG_ja.md +++ b/CHANGELOG_ja.md @@ -19,6 +19,14 @@ English version: [`CHANGELOG.md`](CHANGELOG.md) ## ツール — `Line.OpenApi.Tools` +### [1.3.0] - 2026-09-04 + +Flex プレビューで自分のローカル画像・動画をホスティングなしに表示できるようにします。 + +#### 追加 + +- **Flex プレビューのローカルメディア配信(`LINE_FLEX_MCP_ASSET_DIR`)。** この環境変数にフォルダを指定すると、ループバックのプレビューサーバがそのフォルダ内のメディアファイルを配信します。デザイン中は Flex メッセージから **相対** `url`(例 `assets/hero.png`)で参照でき、本番移行時は origin だけを HTTPS の URL に差し替えれば相対パス部分はそのまま使えます。配信対象は LINE が Flex メッセージでレンダリングする形式に一致します=画像は **JPEG/PNG**(APNG は `.png`)、動画は `video` コンポーネント用の **`.mp4`**(GIF/WebP 等は意図的に配信しません)。配信は **opt-in**(フォルダ未設定なら無効)、指定フォルダ配下への封じ込め(パストラバーサル・フォルダ外シンボリックリンクを拒否)、ループバック限定、read-only 安全です。LINE 本体はローカル URL も `data:` URL もレンダリングしないため、あくまでプレビュー用の利便機能です。 + ### [1.2.0] - 2026-09-03 LINE アプリに近い見た目の Flex Message ライブプレビューを追加します。 diff --git a/docs/reviews/2026-09-04-flex-preview-local-image-serving-review.md b/docs/reviews/2026-09-04-flex-preview-local-image-serving-review.md new file mode 100644 index 0000000..e9b13f4 --- /dev/null +++ b/docs/reviews/2026-09-04-flex-preview-local-image-serving-review.md @@ -0,0 +1,64 @@ +# 2026-09-04 Flex プレビュー ローカル画像配信 レビュー記録 + +## 概要 + +`Line.OpenApi.Tools` の Flex プレビューサーバ(ループバック限定 `HttpListener`、`Services/FlexPreviewService.cs`)に、環境変数 `LINE_FLEX_MCP_IMAGE_DIR` で指定したフォルダ内の画像ファイル(`.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`)を配信する機能を追加。 + +**利用シーン:** 用意した画像をフォルダに配置 → Flex JSON からは相対 `url`(例 `"assets/hero.png"`)で参照 → 本番移行時は origin だけ HTTPS の CDN/ホストに差し替える(相対パス部分は不変=1:1 マッピング)。あくまでプレビュー用の利便機能(LINE 本体はローカル URL も `data:` URL もレンダリングしない)。 + +## 設計判断(ADR) + +- **配信ディレクトリは環境変数(人が out-of-band 設定・LLM 非制御)** を採用。runtime MCP tool 方式(LLM が任意ローカルパスを配信面にできる=プレビュー経路経由の任意ファイル読み取り)と default folder 方式(暗黙的で危険)を退けた。プロジェクトの「安全ゲートはクロージャ束縛で LLM に出さない」方針と一致。 +- 未設定なら完全無効(opt-in、404 フォールスルー)。 +- パス解決を純粋関数 `ResolveImagePath` に切り出し、HTTP を介さず封じ込めを単体テスト可能に。 +- `renderer.js` は無改修(ブラウザが相対 URL をページ origin に解決)= `FlexWebAssetsParityTests`(`tools/web` ⇄ `extensions/web` byte 一致)に無影響。 + +## 封じ込め(多層防御) + +1. `IsLoopbackHost(req.UserHostName)` ガード(`/api` と同軸の DNS リバインド対策) +2. 拡張子ホワイトリスト(画像種別のみ)+ content-type も `image/*` か `application/octet-stream` に限定(XSS 誘発の text/html 等を返せない) +3. 制御文字(`char.IsControl`)拒否=`%00` トリック無効 +4. `Path.GetFullPath` による字句正規化 → `fullBase + DirectorySeparatorChar` の Ordinal 前置比較(`../`・`..\`・`%2e%2e`・`..%2f`・`%5c`・rooted/UNC/ドライブレターをすべて捕捉。末尾セパレータ付与で兄弟プレフィックス誤許可も防止) +5. **シンボリックリンク物理封じ込め**(レビュー反映):`File.ResolveLinkTarget(returnFinalTarget:true)` で最終ターゲットを解決し、ベース配下でなければ拒否。検証不能なら refuse。 + +## 3 役ゲート結果(すべて PASS・BLOCKING なし) + +- **code-reviewer = PASS**(Low 4:e2e decode 経路未検証/シンボリックリンク/相対 env の CWD 解決/サイズ上限なし) +- **security-reviewer = PASS**(Low:シンボリックリンク/8.3 短縮名/サイズ上限なし。封じ込め中核は堅牢と実証。タイミング攻撃・トークン漏洩・ホスト誤送出・DNS リバインド/CSRF いずれも該当なし or 十分ガード) +- **test-arch-reviewer = PASS**(中:シンボリックリンク未カバー/e2e トラバーサルがクライアント正規化で退化。低:content-type 網羅/rooted・backslash/大文字拡張子) + +## 指摘への対応 + +- **シンボリックリンク(3 役収束・中):** `File.ResolveLinkTarget` で物理封じ込めを追加。テスト追加(作成不可環境では skip)。 +- **e2e トラバーサル退化(code L1 / test-arch T1・中):** クライアント `Uri`/`HttpClient` が dot-segment を送信前正規化するため、**raw ソケットで非正規化パス `%2e%2e%2f` を送る e2e** に置換(http.sys のコネクションリセットも「非配信」として許容)。あわせて**正常系のサブディレクトリ+`%20` エンコード e2e** を追加(使い勝手の回帰固定)。 +- **content-type 網羅(T2):** `ImageContentType` を internal 化し png/jpg/jpeg/gif/webp/大文字/未知 をパラメタ化検証。 +- **rooted/backslash/大文字拡張子(T3/T4):** 主張裏取りの回帰ケースを追加。 +- **相対 env の CWD 解決(L3):** ctor コメント+README 英日に「絶対パス推奨」を明記。 + +**見送り(follow-up):** ファイルサイズ上限なし(`File.ReadAllBytes`)— ループバック・人設定フォルダ・プレビュー用途で実害限定。既存の「POST ボディ上限未実装」follow-up と同カテゴリで将来まとめて対応。 + +## 検証 + +- ビルド 0 警告 +- `Line.OpenApi.Tools.Tests` 127/127 緑(+13:純粋関数封じ込め+ループバック/raw ソケット e2e+content-type) +- 変更は `/tools` 支援ティア内の HTTP プレビュー機能。生成コード・R1 ルーティング・form-urlencoded・webhook 多態・公開 API snapshot・Kiota 版ピンに非接触(pack 12 パッケージ契約は Tools 除外で不変)。 + +## 判定 + +**GO 推奨・人の go/no-go 待ち(未コミット)。** + +## 変更ファイル + +- 実装: `tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs` +- テスト: `tests/Line.OpenApi.Tools.Tests/FlexPreviewAssetServingTests.cs`(新規) +- ドキュメント: `tools/README.md` / `tools/README_ja.md` + +## 追補(2026-09-04・LINE Flex 実仕様への準拠) + +一次情報(https://developers.line.biz/ja/reference/messaging-api/#flex-message)に合わせ、配信対象を LINE が Flex メッセージで実際にレンダリングする形式に厳密化。ゲート後・未公開のため破壊的変更の懸念なし。 + +- **画像は JPEG/PNG(APNG=`.png`)のみ**に限定=`.gif`/`.webp` を配信対象から除外(LINE 非対応のため)。 +- **動画 `type:"video"`(mp4)に対応**=`.mp4` を配信対象に追加(content-type `video/mp4`)。renderer は video を `previewUrl`(JPEG/PNG のポスター)+▶ で描画し mp4 本体は取りに行かない(LINE アプリと同じ)ため、プレビュー表示は previewUrl 配信で足りるが、`url`(mp4)も相対参照で解決可能にした(アセット一式をフォルダに置く前提と一貫)。`renderer.js` は無改修(parity 維持)。 +- **名称を中立化**(対象が画像+動画になったため)=環境変数 `LINE_FLEX_MCP_IMAGE_DIR` → **`LINE_FLEX_MCP_ASSET_DIR`**、`ResolveImagePath`→`ResolveAssetPath`、`ImageContentType`→`AssetContentType`、`ImageExtensions`→`AssetExtensions`、`_imageDir`→`_assetDir`。 +- テスト更新: gif/webp は拒否・mp4 は受理・content-type に mp4 を追加、ループバック e2e に mp4(video/mp4)配信を追加。ファイルを `FlexPreviewAssetServingTests.cs` にリネーム。 +- ビルド 0 警告・Tools テスト **131/131** 緑(+4)。README 英日を JPEG/PNG+mp4・env var 改名・video コンポーネント例で更新。 diff --git a/tests/Line.OpenApi.Tools.Tests/FlexPreviewAssetServingTests.cs b/tests/Line.OpenApi.Tools.Tests/FlexPreviewAssetServingTests.cs new file mode 100644 index 0000000..f9ebcb0 --- /dev/null +++ b/tests/Line.OpenApi.Tools.Tests/FlexPreviewAssetServingTests.cs @@ -0,0 +1,311 @@ +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using Line.OpenApi.Tools.Services; +using Xunit; + +namespace Line.OpenApi.Tools.Tests; + +/// +/// Tests for local asset serving in : the pure path +/// confinement logic () and loopback +/// end-to-end fetches that prove a media file under LINE_FLEX_MCP_ASSET_DIR is actually +/// served while traversal and unsupported extensions are refused. The served set mirrors +/// what LINE renders in a Flex message: JPEG/PNG (incl. APNG) images and MP4 video. +/// +public sealed class FlexPreviewAssetServingTests +{ + // --- pure path confinement ---------------------------------------------- + + [Fact] + public void Disabled_when_no_directory_configured() + { + Assert.Null(FlexPreviewService.ResolveAssetPath(null, "/hero.png")); + Assert.Null(FlexPreviewService.ResolveAssetPath("", "/hero.png")); + } + + [Fact] + public void Resolves_a_file_directly_under_the_directory() + { + using var dir = new TempDir(); + var file = dir.WriteFile("hero.png", "img"); + + var resolved = FlexPreviewService.ResolveAssetPath(dir.Path, "/hero.png"); + + Assert.Equal(Path.GetFullPath(file), resolved); + } + + [Fact] + public void Resolves_a_file_in_a_subdirectory() + { + using var dir = new TempDir(); + var file = dir.WriteFile(Path.Combine("assets", "hero.png"), "img"); + + var resolved = FlexPreviewService.ResolveAssetPath(dir.Path, "/assets/hero.png"); + + Assert.Equal(Path.GetFullPath(file), resolved); + } + + [Theory] + [InlineData("clip.mp4")] // video component source + [InlineData("photo.jpg")] + [InlineData("photo.jpeg")] + public void Resolves_supported_media_extensions(string name) + { + using var dir = new TempDir(); + var file = dir.WriteFile(name, "data"); + + var resolved = FlexPreviewService.ResolveAssetPath(dir.Path, "/" + name); + + Assert.Equal(Path.GetFullPath(file), resolved); + } + + [Fact] + public void Percent_encoded_path_is_decoded() + { + using var dir = new TempDir(); + var file = dir.WriteFile(Path.Combine("my images", "a b.png"), "img"); + + var resolved = FlexPreviewService.ResolveAssetPath(dir.Path, "/my%20images/a%20b.png"); + + Assert.Equal(Path.GetFullPath(file), resolved); + } + + [Fact] + public void Missing_file_resolves_to_null() + { + using var dir = new TempDir(); + Assert.Null(FlexPreviewService.ResolveAssetPath(dir.Path, "/nope.png")); + } + + [Theory] + [InlineData("/notes.txt")] + [InlineData("/archive.zip")] + [InlineData("/config.json")] + [InlineData("/hero")] + [InlineData("/animation.gif")] // GIF is not a LINE Flex format → refused + [InlineData("/photo.webp")] // WebP is not a LINE Flex format → refused + public void Unsupported_extension_is_refused(string requestPath) + { + using var dir = new TempDir(); + // Even if such a file exists on disk, an unsupported extension must not be served. + dir.WriteFile(requestPath.TrimStart('/'), "secret"); + Assert.Null(FlexPreviewService.ResolveAssetPath(dir.Path, requestPath)); + } + + [Theory] + [InlineData("/../secret.png")] + [InlineData("/../../secret.png")] + [InlineData("/assets/../../secret.png")] + [InlineData("/%2e%2e/secret.png")] + [InlineData("/..%2fsecret.png")] + [InlineData("/..%5csecret.png")] // backslash-encoded traversal (Windows separator) + [InlineData("/%2e%2e%2fsecret.png")] // fully-encoded ../ + public void Traversal_outside_the_directory_is_refused(string requestPath) + { + using var dir = new TempDir(); + // Place the target one level above the served directory; it must stay unreachable. + File.WriteAllText(Path.Combine(dir.Parent, "secret.png"), "secret"); + + Assert.Null(FlexPreviewService.ResolveAssetPath(dir.Path, requestPath)); + } + + [Theory] + [InlineData("/C:/Windows/System32/drivers/etc/hosts.png")] // rooted second segment: Combine discards base + [InlineData("/\\\\server\\share\\x.png")] // UNC + public void Rooted_or_absolute_segment_is_refused(string requestPath) + { + using var dir = new TempDir(); + Assert.Null(FlexPreviewService.ResolveAssetPath(dir.Path, requestPath)); + } + + [Fact] + public void Uppercase_extension_is_accepted() + { + using var dir = new TempDir(); + var file = dir.WriteFile("LOGO.PNG", "img"); + + var resolved = FlexPreviewService.ResolveAssetPath(dir.Path, "/LOGO.PNG"); + + Assert.Equal(Path.GetFullPath(file), resolved); + } + + [Fact] + public void Symlink_pointing_outside_the_directory_is_refused() + { + using var dir = new TempDir(); + var outside = Path.Combine(dir.Parent, "secret.png"); + File.WriteAllText(outside, "secret"); + var link = Path.Combine(dir.Path, "evil.png"); + try { File.CreateSymbolicLink(link, outside); } + 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.Null(FlexPreviewService.ResolveAssetPath(dir.Path, "/evil.png")); + } + + [Fact] + public void Empty_or_root_path_is_refused() + { + using var dir = new TempDir(); + Assert.Null(FlexPreviewService.ResolveAssetPath(dir.Path, "/")); + Assert.Null(FlexPreviewService.ResolveAssetPath(dir.Path, "")); + } + + // --- content-type mapping ----------------------------------------------- + + [Theory] + [InlineData(".png", "image/png")] + [InlineData(".PNG", "image/png")] + [InlineData(".jpg", "image/jpeg")] + [InlineData(".jpeg", "image/jpeg")] + [InlineData(".mp4", "video/mp4")] + [InlineData(".bin", "application/octet-stream")] + public void Content_type_is_mapped_from_extension(string ext, string expected) + => Assert.Equal(expected, FlexPreviewService.AssetContentType(ext)); + + // --- loopback end-to-end ------------------------------------------------- + + [Fact] + public async Task Configured_media_is_served_over_loopback() + { + using var dir = new TempDir(); + var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; // PNG magic + var mp4 = new byte[] { 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70 }; // ftyp box start + var sub = dir.EnsureSub("assets"); + File.WriteAllBytes(Path.Combine(sub, "hero.png"), png); + File.WriteAllBytes(Path.Combine(sub, "night sky.png"), png); // a space in the name + File.WriteAllBytes(Path.Combine(sub, "promo.mp4"), mp4); + + using var scope = new EnvScope(("LINE_FLEX_MCP_NO_OPEN", "1"), ("LINE_FLEX_MCP_ASSET_DIR", dir.Path)); + using var service = new FlexPreviewService(); + var url = service.Open(); // starts the loopback server, returns http://127.0.0.1:/ + var baseUri = new Uri(url); + + using var http = new HttpClient(); + + // An image is served with the right bytes and content type. + var image = await http.GetAsync(new Uri(baseUri, "assets/hero.png")); + Assert.Equal(HttpStatusCode.OK, image.StatusCode); + Assert.Equal("image/png", image.Content.Headers.ContentType?.MediaType); + Assert.Equal(png, await image.Content.ReadAsByteArrayAsync()); + + // The video component's mp4 source is served as video/mp4. + var video = await http.GetAsync(new Uri(baseUri, "assets/promo.mp4")); + Assert.Equal(HttpStatusCode.OK, video.StatusCode); + Assert.Equal("video/mp4", video.Content.Headers.ContentType?.MediaType); + Assert.Equal(mp4, await video.Content.ReadAsByteArrayAsync()); + + // A percent-encoded relative path (subdirectory + space) resolves end-to-end. + var encoded = await http.GetAsync(new Uri(baseUri, "assets/night%20sky.png")); + Assert.Equal(HttpStatusCode.OK, encoded.StatusCode); + Assert.Equal(png, await encoded.Content.ReadAsByteArrayAsync()); + + var missing = await http.GetAsync(new Uri(baseUri, "nope.png")); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + + // Raw-socket traversal: HttpClient/Uri normalize dot-segments before sending, so + // assert confinement against a non-normalized wire path that reaches the server as-is. + // Anything other than a 200 means the secret was not served (a 400/404, or a + // connection reset from http.sys refusing the malformed target, all count as refused). + var status = await RawGetStatusAsync(baseUri, "/assets/%2e%2e%2f%2e%2e%2fsecret.png"); + Assert.NotEqual(200, status); + } + + [Fact] + public async Task Media_is_not_served_when_directory_unconfigured() + { + using var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "hero.png"), "img"); + + using var scope = new EnvScope(("LINE_FLEX_MCP_NO_OPEN", "1"), ("LINE_FLEX_MCP_ASSET_DIR", null)); + using var service = new FlexPreviewService(); + var url = service.Open(); + + using var http = new HttpClient(); + var res = await http.GetAsync(new Uri(new Uri(url), "hero.png")); + Assert.Equal(HttpStatusCode.NotFound, res.StatusCode); + } + + // Send a raw HTTP/1.1 GET with an exact request-target (no client-side normalization) + // and return the numeric status code, or -1 if the server refused/reset the connection. + private static async Task RawGetStatusAsync(Uri baseUri, string rawTarget) + { + try + { + using var client = new TcpClient(); + await client.ConnectAsync(baseUri.Host, baseUri.Port); + await using var stream = client.GetStream(); + var request = $"GET {rawTarget} HTTP/1.1\r\nHost: {baseUri.Host}:{baseUri.Port}\r\nConnection: close\r\n\r\n"; + var reqBytes = Encoding.ASCII.GetBytes(request); + await stream.WriteAsync(reqBytes); + using var reader = new StreamReader(stream, Encoding.ASCII); + var statusLine = await reader.ReadLineAsync() ?? ""; + var parts = statusLine.Split(' '); + return parts.Length >= 2 && int.TryParse(parts[1], out var code) ? code : -1; + } + catch (Exception e) when (e is SocketException or IOException) + { + return -1; // connection reset / refused — the target was not served. + } + } + + // --- helpers ------------------------------------------------------------- + + private sealed class TempDir : IDisposable + { + public string Path { get; } + public string Parent => Directory.GetParent(Path)!.FullName; + + public TempDir() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), + "line-flex-asset-tests", Guid.NewGuid().ToString("N"), "root"); + Directory.CreateDirectory(Path); + } + + public string EnsureSub(string sub) + { + var full = System.IO.Path.Combine(Path, sub); + Directory.CreateDirectory(full); + return full; + } + + public string WriteFile(string relative, string content) + { + var full = System.IO.Path.Combine(Path, relative); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(full)!); + File.WriteAllText(full, content); + return full; + } + + public void Dispose() + { + try { Directory.Delete(Directory.GetParent(Path)!.FullName, recursive: true); } + catch { /* best effort */ } + } + } + + private sealed class EnvScope : IDisposable + { + private readonly (string Key, string? Prev)[] _saved; + + public EnvScope(params (string Key, string? Value)[] vars) + { + _saved = new (string, string?)[vars.Length]; + for (var i = 0; i < vars.Length; i++) + { + _saved[i] = (vars[i].Key, Environment.GetEnvironmentVariable(vars[i].Key)); + Environment.SetEnvironmentVariable(vars[i].Key, vars[i].Value); + } + } + + public void Dispose() + { + foreach (var (key, prev) in _saved) + Environment.SetEnvironmentVariable(key, prev); + } + } +} diff --git a/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj b/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj index 586dab2..2d4e4e4 100644 --- a/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj +++ b/tools/Line.OpenApi.Tools/Line.OpenApi.Tools.csproj @@ -14,7 +14,7 @@ - 1.2.0 + 1.3.0 false diff --git a/tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs b/tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs index 31fdf4c..3330a40 100644 --- a/tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs +++ b/tools/Line.OpenApi.Tools/Services/FlexPreviewService.cs @@ -21,15 +21,31 @@ namespace Line.OpenApi.Tools.Services; /// It performs no LINE API calls and stores no secrets, so it is safe under /// --read-only. State (the current Flex JSON) is persisted to a temp file /// so a reopened tab restores the last preview. +/// +/// When LINE_FLEX_MCP_ASSET_DIR is set, media files under that directory are +/// also served, so a Flex message can reference local artwork by a relative url +/// (e.g. "assets/hero.png") during preview and swap only the origin for the +/// production HTTPS URL later. Serving is opt-in (disabled unless the directory is +/// configured) and confined to that directory (path traversal is rejected). 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. /// internal sealed class FlexPreviewService : IDisposable { private static readonly string[] StaticFiles = { "viewer.html", "viewer.js", "renderer.js", "flex.css", "samples.js" }; + // Only these extensions are served from the asset directory. The set mirrors the + // formats LINE renders in a Flex message: JPEG/PNG (incl. APNG) for the image + // component and MP4 for the video component. Other formats are intentionally excluded. + private static readonly HashSet AssetExtensions = + new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".mp4" }; + private readonly object _gate = new(); private readonly ConcurrentDictionary _clients = new(); private readonly string _stateFile; + private readonly string? _assetDir; private readonly bool _autoOpen; private HttpListener? _listener; @@ -43,6 +59,14 @@ public FlexPreviewService() ?? Path.Combine(Path.GetTempPath(), "line-flex-mcp"); _stateFile = Path.Combine(stateDir, "content.json"); _autoOpen = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("LINE_FLEX_MCP_NO_OPEN")); + + // Opt-in local asset serving. Normalize to a full path once so request-time + // confinement compares against a stable base. A relative value is resolved + // against the current directory at construction time, so an absolute path is + // recommended. The directory need not exist yet (files may be added later); + // a missing file simply 404s. + var assetDir = Environment.GetEnvironmentVariable("LINE_FLEX_MCP_ASSET_DIR"); + _assetDir = string.IsNullOrWhiteSpace(assetDir) ? null : SafeFullPath(assetDir); } // --- public API (called by the MCP tools) -------------------------------- @@ -308,6 +332,18 @@ private void HandleRequest(HttpListenerContext ctx) WriteText(res, 200, ReadResource(name), ContentType(name)); return; } + // Serve local media (opt-in via LINE_FLEX_MCP_ASSET_DIR) so a Flex + // message can reference artwork/video by a relative url. Apply the same + // loopback-host guard as /api to blunt DNS-rebinding reads. + if (_assetDir is not null && IsLoopbackHost(req.UserHostName)) + { + var file = ResolveAssetPath(_assetDir, path); + if (file is not null) + { + WriteBytes(res, 200, File.ReadAllBytes(file), AssetContentType(Path.GetExtension(file))); + return; + } + } WriteText(res, 404, "not found", "text/plain"); return; } @@ -423,8 +459,10 @@ private static void WriteRaw(HttpListenerResponse res, string text) } private static void WriteText(HttpListenerResponse res, int status, string text, string contentType) + => WriteBytes(res, status, Encoding.UTF8.GetBytes(text), contentType); + + private static void WriteBytes(HttpListenerResponse res, int status, byte[] bytes, string contentType) { - var bytes = Encoding.UTF8.GetBytes(text); res.StatusCode = status; res.ContentType = contentType; res.ContentLength64 = bytes.Length; @@ -449,6 +487,87 @@ private static string RenderIndex(string html) _ => "application/octet-stream", }; + // --- local asset serving ------------------------------------------------- + + // Internal for unit testing the extension → content-type mapping. + internal static string AssetContentType(string ext) => ext.ToLowerInvariant() switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".mp4" => "video/mp4", + _ => "application/octet-stream", + }; + + private static string? SafeFullPath(string path) + { + try { return Path.GetFullPath(path); } + catch { return null; } + } + + /// + /// Resolve an HTTP request path (e.g. "/assets/hero.png") to a file under the + /// configured asset directory, 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. Internal for unit testing. + /// + internal static string? ResolveAssetPath(string? assetDir, string requestPath) + { + if (string.IsNullOrEmpty(assetDir) || string.IsNullOrEmpty(requestPath)) return null; + + var decoded = Uri.UnescapeDataString(requestPath); + // Reject NUL / control characters defensively before touching the filesystem. + foreach (var c in decoded) + if (char.IsControl(c)) return null; + + var relative = decoded.TrimStart('/'); + if (relative.Length == 0) return null; + + if (!AssetExtensions.Contains(Path.GetExtension(relative))) return null; + + string fullBase, candidate; + try + { + fullBase = Path.GetFullPath(assetDir); + candidate = Path.GetFullPath(Path.Combine(fullBase, relative)); + } + catch + { + return null; + } + + // candidate is built from fullBase, so the base portion shares its casing; + // an Ordinal prefix check is both correct and case-safe here. + var baseWithSep = fullBase.EndsWith(Path.DirectorySeparatorChar) + ? fullBase + : fullBase + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(baseWithSep, StringComparison.Ordinal)) return null; + + if (!File.Exists(candidate)) return null; + + // The prefix check above is lexical (Path.GetFullPath does not resolve links). + // Defense in depth: if the entry is a symlink/junction, resolve its final target + // and require that to stay under the base too, so a link inside the directory + // cannot escape it. Non-links resolve to null and are served as-is. + try + { + var target = File.ResolveLinkTarget(candidate, returnFinalTarget: true); + if (target is not null) + { + var real = Path.GetFullPath(target.FullName); + if (!real.StartsWith(baseWithSep, StringComparison.Ordinal)) return null; + } + } + catch + { + return null; // if the link target cannot be verified, refuse to serve. + } + + return candidate; + } + // --- embedded web assets ------------------------------------------------- private static string ReadResource(string name) diff --git a/tools/README.md b/tools/README.md index dad44e7..3c198f2 100644 --- a/tools/README.md +++ b/tools/README.md @@ -383,7 +383,31 @@ before sending. No LINE API calls or credentials are involved, so these tools ar - `line_flex_validate` — structurally validate Flex JSON → `{ valid, warnings }` - `line_flex_open` — reopen the preview tab → `{ ok, url }` -Env: `LINE_FLEX_MCP_NO_OPEN` (URL only, no auto-open), `LINE_FLEX_MCP_STATE_DIR` (state location). +Env: `LINE_FLEX_MCP_NO_OPEN` (URL only, no auto-open), `LINE_FLEX_MCP_STATE_DIR` (state location), +`LINE_FLEX_MCP_ASSET_DIR` (serve local images/video for preview — see below). + +#### Previewing local images and video + +LINE requires a public **HTTPS** URL for real media delivery, but while designing you often just want +to see your own artwork. Set `LINE_FLEX_MCP_ASSET_DIR` to a folder (use an absolute path) and the +preview server serves media files from it, so a Flex message can reference them by a **relative** `url`: + +```jsonc +// with LINE_FLEX_MCP_ASSET_DIR=/path/to/flex-assets and flex-assets/assets/hero.png present +{ "type": "image", "url": "assets/hero.png", "size": "full" } + +// a video component: the poster (previewUrl) is what renders in the preview; url is the mp4 +{ "type": "video", "url": "assets/promo.mp4", "previewUrl": "assets/promo-poster.png", + "altContent": { "type": "image", "url": "assets/promo-poster.png" }, "aspectRatio": "20:13" } +``` + +The served set matches what LINE renders in a Flex message: images are **JPEG/PNG** (APNG is `.png`) +and video is **`.mp4`** — other formats (GIF/WebP, etc.) are intentionally not served. The browser +resolves `assets/hero.png` against the preview origin (`http://127.0.0.1:/`). When you move the +message to production, swap only the origin for your CDN/host — the relative path stays the same +(`https://cdn.example.com/assets/hero.png`). Serving is **opt-in** (disabled unless the folder is set), +**confined** to that folder (path traversal is rejected), loopback-only, and read-only-safe. This is a +preview convenience: LINE itself renders neither local nor `data:` URLs. The same browser renderer is also available as a Copilot App canvas extension (with a bundled zero-dependency Node MCP server as an alternative for Claude Desktop/Code) — see diff --git a/tools/README_ja.md b/tools/README_ja.md index bf8f1eb..6b95ceb 100644 --- a/tools/README_ja.md +++ b/tools/README_ja.md @@ -382,7 +382,31 @@ AI が `line_flex_preview` で JSON をレンダリングすると、ループ - `line_flex_validate` — Flex JSON を構造的に検証 → `{ valid, warnings }` - `line_flex_open` — プレビュータブを開き直す → `{ ok, url }` -環境変数: `LINE_FLEX_MCP_NO_OPEN`(自動で開かず URL のみ返す)、`LINE_FLEX_MCP_STATE_DIR`(状態の保存先)。 +環境変数: `LINE_FLEX_MCP_NO_OPEN`(自動で開かず URL のみ返す)、`LINE_FLEX_MCP_STATE_DIR`(状態の保存先)、 +`LINE_FLEX_MCP_ASSET_DIR`(プレビュー用にローカル画像/動画を配信=下記)。 + +#### ローカル画像・動画のプレビュー + +実機の配信には公開 **HTTPS** URL が必須ですが、デザイン中は自分で用意した画像の見た目だけ確認したい +ことがよくあります。`LINE_FLEX_MCP_ASSET_DIR` にフォルダ(絶対パス推奨)を指定すると、プレビューサーバが +そのフォルダ内のメディアファイルを配信するので、Flex メッセージから **相対** `url` で参照できます: + +```jsonc +// LINE_FLEX_MCP_ASSET_DIR=/path/to/flex-assets かつ flex-assets/assets/hero.png がある場合 +{ "type": "image", "url": "assets/hero.png", "size": "full" } + +// video コンポーネント: プレビューに映るのはポスター(previewUrl)で、url は mp4 本体 +{ "type": "video", "url": "assets/promo.mp4", "previewUrl": "assets/promo-poster.png", + "altContent": { "type": "image", "url": "assets/promo-poster.png" }, "aspectRatio": "20:13" } +``` + +配信対象は LINE が Flex メッセージでレンダリングする形式に一致します=画像は **JPEG/PNG**(APNG は `.png`)、 +動画は **`.mp4`**。それ以外(GIF/WebP 等)は配信しません。ブラウザは `assets/hero.png` をプレビューの +origin(`http://127.0.0.1:/`)に対して解決します。本番へ移す際は origin だけを CDN/ホストに差し替え +れば、相対パス部分はそのまま使えます(`https://cdn.example.com/assets/hero.png`)。配信は **opt-in** +(フォルダ未設定なら無効)、指定フォルダ配下への **封じ込め**(パストラバーサル拒否)、ループバック限定、 +read-only 安全です。あくまでプレビュー用の利便機能で、LINE 本体はローカル URL も `data:` URL もレンダリング +しません。 同じブラウザレンダラは Copilot App の canvas 拡張としても利用できます(`line` ツールを使わない 場合の代替として、依存パッケージのない Node MCP サーバも同梱)。詳細は