セキュリティ
Murasaki の信頼境界、組み込みのランタイム保護、アプリ側の責務。
デスクトップセキュリティは、プロセス境界から始まります。Murasaki は Node をレンダラーから切り離し、アプリの WebView の遷移先を自身のオリジンに制限し、特権を持つループバックエンドポイントを、ネイティブホストが発行するウィンドウ別の権限情報で保護します。これらの対策により、通常のレンダラーのバグによる影響は小さくなりますが、完全な権限システムというわけではありません。
レンダラーのネイティブコマンドはデフォルトですべて拒否され(deny-by-default)、型付きの capabilities リストで明示的に許可したものだけが実行できます。レンダラーから Node/API へのアクセスも、backendCapabilities によって別途、デフォルトで拒否されます。宣言したウィンドウごとに独立した許可リストがあり、リソースを受け取るコマンドでは URL、パス、対象ウィンドウ、OS の権限といった単位で範囲を絞り込めます。レンダラーから呼び出す Node の関数はすべて特権を持つアプリケーションコードとして扱い、その公開範囲を小さく保ってください。
信頼境界
| 境界 | 組み込み保護 | アプリ側の責務 |
|---|---|---|
| リモート URL → アプリの WebView | アプリのオリジンと完全に一致する遷移だけをウィンドウ内で許可。オリジンが異なる HTTP(S) はシステムのブラウザで開き、file: / data: / javascript: への遷移は拒否 | ナビゲーションの挙動を弱めず、リモートのスクリプトをレンダラーに注入しないこと |
| レンダラー → Rust のネイティブコマンド | 厳密なオリジン一致による IPC チェックと、呼び出し元のウィンドウごとに評価される、型付きでデフォルト拒否の許可リスト | そのレンダラーが実際に使うコマンドだけを許可すること。window:manage など影響範囲の広い操作では、アプリレベルの意図を検証すること |
| レンダラー → ローカルの Node | /api/* と /__murasaki/* は、ネイティブウィンドウのラベルから HMAC で導出した ID と、そのウィンドウの backendCapabilities の両方を要求。Host / Origin / Fetch Metadata と通信データの上限も検証 | 必要なモジュールのエクスポート、メソッド/パス、updater、イベント、診断情報だけを許可し、ハンドラー内でもオブジェクト単位の認可を行うこと |
| OS の URL / ファイルのアクティベーション → Node Main | 設定済みのスキーム/拡張子だけを正規化して openRequested() のターゲットにし、認証付きのアプリローカルなチャネルで配送 | URL / パスはすべて攻撃者に制御されうるものとして扱い、操作前に意図、識別子、ホスト、ファイルの内容を検証すること |
| アプリ → 更新ホスト | マニフェストの生バイト列に対する Ed25519 署名を検証し、続いてペイロードの SHA-256 を検証 | 秘密の更新鍵を保護し、ハッシュと署名を同時に公開すること |
| OS → インストール済みアプリ | macOS の Developer ID / 公証と、Windows の Authenticode オーケストレーションが、最終的なアプリ所有の成果物を署名・検証 | プラットフォームの証明書/プロバイダーを用意して保護し、クリーンな環境で信頼ポリシーとインストール済みの成果物をテストすること |
本番環境では、すべてのインターフェースではなく 127.0.0.1 だけでリッスンします。開発時はループバックの localhost / 127.0.0.1 / [::1] を許可し、クロスサイトの Fetch Metadata は拒否します。静的なドキュメントレスポンスには、ランタイムのベアラー認証情報を含めません。ネイティブホストは、ドキュメント開始時にそのウィンドウ専用の導出 ID だけを注入します。パッケージ化された ID はラベルとネイティブ側の世代値の両方に紐付けられ、ウィンドウを閉じると Node 側で失効し、同じラベルを再作成しても別の HMAC になります。どちらか一方でも書き換えると検証に失敗し、セカンダリウィンドウはバックエンドの許可を継承しません。サブフレーム内、またはドキュメントのオリジンが正確なアプリのループバックオリジンと異なる場合は、認証情報を定義する前に ID のブートストラップを終了します。この検査は、main-frame-only を指定していても WebView2 がフレームへスクリプトを注入する Windows でも適用されます。ネイティブのライフサイクルエンドポイントは、レンダラーの JavaScript には渡さない別のトークンを使用します。
WebView のネットワークとプライベートセッション
webview.userAgent、webview.incognito、webview.proxy は、アプリ全体に適用されるネイティブブラウザの設定であり、レンダラーの権限ではありません。ネイティブコマンドの許可リストを拡張することも、Murasaki の同一オリジンナビゲーション/IPC チェックを緩めることもありません。
- プライベート(シークレット)セッションは、WebView がアプリの永続的なブラウザプロファイルを使わないようにするものです。ユーザーの IP アドレスを隠すものではなく、Node Main が書き込んだファイルを消すものでもなく、アプリのサーバーに対する匿名性を提供するものでもありません。
- WebContext/プロファイルは、ウィンドウラベルごとに分離されます。これにより、セカンダリウィンドウの Service Worker、SharedWorker、Cookie、Web Storage から、プライマリウィンドウの認証済みバックエンドリクエストを観測することはできません。同じラベルを同一プロセス内で再作成した場合は、そのコンテキストを再利用します。シークレットのラベルは非永続であり、カスタムの永続ストアを使えない macOS 11〜13 では、セカンダリのラベルも非永続になります。
- カスタム User-Agent は、アプリがリクエストするオリジンへ送信されるため、フィンガープリンティングに使える情報を増やす場合があります。プラットフォームの User-Agent を置き換えることで、サーバーとの互換性や認証フローが壊れる可能性もあります。
- HTTP CONNECT や SOCKSv5 のプロキシは、宛先や通信のメタデータを観測でき、暗号化されていない HTTP 通信の内容も読み取れます。信頼できるプロキシ運用者の場合にのみ使用し、OS の WebView による TLS 検証は維持してください。
- プロキシの認証情報は受け付けません。
hostやバンドルのメタデータにシークレットを埋め込まないでください。Murasaki は起動前に URL、認証情報、未知のプロキシフィールドを拒否し、Rust 側でもエンドポイントを再検証します。
サイズ上限や macOS / WebView2 のバージョン要件については、設定を参照してください。
ネイティブコマンドを明示的に許可する
export default defineConfig({
appId: 'com.example.notes',
productName: 'Notes',
window: {
route: '/',
capabilities: [
'app:quit',
'dialog:openFile',
'secureStorage:get',
'secureStorage:set',
'secureStorage:delete',
'globalShortcut:register',
'globalShortcut:unregister',
{
permission: 'shell:openExternal',
allow: { urls: ['https://docs.example.com/**'] },
deny: { urls: ['https://docs.example.com/internal/**'] },
},
{ permission: 'window:open', allow: { windows: ['settings'] } },
],
},
windows: {
preview: { route: '/preview', capabilities: ['clipboard:writeText'] },
settings: { route: '/settings', capabilities: [] },
},
})ケイパビリティを省略すると、何も許可されません。ただしプライマリウィンドウは window.capabilities が無い場合に限り、従来のトップレベルのリストへフォールバックします(セカンダリウィンドウはこのリストを継承しません)。未知の文字列も何も許可せず、ブリッジは信頼されたアプリのオリジンと完全に一致する呼び出しだけを受け付けます。許可リストでは、次のリソースフィールド単位で範囲を指定できます。
| Permission | Scope field |
|---|---|
shell:openExternal、webview:readCookies、webview:writeCookies | 完全一致の URL、または HTTP(S) で末尾が /** となるサブツリーを指定する urls |
shell:showItemInFolder | パストラバーサルにならない絶対パスの paths。完全一致、または末尾 /** |
window:open、window:manage | 宣言済みのラベルと完全一致する windows |
systemPermission:status、systemPermission:request | permissions の名前と完全一致 |
secureStorage:get、secureStorage:set、secureStorage:delete | 完全一致の keys、または末尾が1つの * で終わるキーのプレフィックス |
明示的な deny は allow より優先されます。deny だけを指定した構造化された許可は、それ以外をすべて許可したことになり、従来の文字列形式の許可は、そのコマンドの対象を制限しません。ダイアログなど他のコマンドは、引き続きコマンド単位です。広い許可は信頼するレンダラーだけに付与し、より狭い意図は Node 側でも強制してください。アプリケーション全体のプログラムによるシャットダウンは、app:quit で別途制御されます。
バックエンドリソースを明示的に許可する
ネイティブケイパビリティとバックエンドケイパビリティは、それぞれ異なる境界を保護します。レンダラーから Node Main、Server Actions、API Routes、updater ルート、イベントストリーム、レンダラーの診断情報へアクセスする場合は、backendCapabilities を使います。
window: {
backendCapabilities: [
'main:src/backend/account.ts#loadAccount',
'action:src/actions/save.ts#saveDocument',
'api:POST:/api/documents/*',
'events:sync.*',
'diagnostics:renderer-error',
],
},
windows: {
preview: { route: '/preview', backendCapabilities: [] },
},許可は、末尾が1つの * で終わる場合だけプレフィックスワイルドカードとして扱われ、それ以外は完全一致です。API の許可には、大文字の HTTP メソッドを含めます。プライマリウィンドウは window.backendCapabilities ?? backendCapabilities ?? [] を使用し、セカンダリウィンドウはすべてデフォルトで [] です。main:*、action:*、api:*、updater:*、events:* は便利ですが範囲が広いため、本番環境では完全一致のリソースを優先してください。シャットダウン、アクティベーション、ウィンドウ制御といったネイティブ専用のエンドポイントは、レンダラーへ許可することはできません。
実行時のウィンドウ作成は、信頼された Node Main だけが、設定で宣言したラベルに対してのみ実行できます。プライベートなネイティブトランスポートが運ぶのはメソッドとラベルだけで、URL やケイパビリティのリストは受け取りません。Rust ホストは、設定済みテンプレートの不変なルートとポリシーを使用します。レンダラーの windows.open() は表示専用のままで、休眠状態や破棄済みのウィンドウを生成することはできません。
グローバルショートカットも、アクセラレーター単位ではなくコマンド単位です。globalShortcut:register では、上限付きのネイティブパーサーが受理する任意のアクセラレーターをリクエストでき、globalShortcut:unregister では、呼び出したレンダラーが所有する登録だけを解除できます。Murasaki は重複や予約済みのキーの組み合わせを拒否し、所有者のウィンドウを閉じると登録を解放しますが、他のアプリがすでに取得しているキーの組み合わせを奪うことはできません。登録は信頼できるレンダラーに任せ、安定した ID を使い、登録できない場合のエラーは通常の競合として扱ってください。
レンダラーが必要とするシークレットを OS の認証情報ストアへ保存する
murasaki/native の secureStorage.get/set/delete は、信頼済みのレンダラーが必要とする短い文字列値のためのものです。ネイティブホストは、macOS Keychain または Windows Credential Manager だけに保存します。サービス/アカウントの識別子は appId とキーから導出し、空文字・NUL・UTF-8 バイト数の上限を検証します。平文へのフォールバックはありません。未登録の値は null を返し、未登録キーの削除も成功として扱われ、Linux では unsupported を返します。
secureStorage:get、secureStorage:set、secureStorage:delete は、それぞれ個別に許可してください。構造化されたキーの範囲指定を使い、そのレンダラーが所有するエントリだけに限定することを推奨します。
{ permission: 'secureStorage:get', allow: { keys: ['account:*', 'theme'] } }互換性のため、文字列形式の許可はキーを無制限に扱います。OS の認証情報ストアは保存データを保護しますが、許可されたキーの範囲内で実行される XSS からは保護しません。
キーは 256 UTF-8 バイト以下、値は 2,048 UTF-8 バイト以下で、どちらも空文字と NUL を拒否します。ネイティブ IPC のボディ全体にも 256 KiB の上限があります。appId を変更すると、新しい名前空間からは既存のエントリを見つけられなくなるため、リリースをまたいで同じ値を維持してください。
サーバー専用のシークレットは Node に置く
レンダラーのコードからインポートしたものは、公開されるクライアント JavaScript になり得ます。API トークン、秘密鍵、DB の認証情報、ライセンス検証は、src/main.ts、'use main'、'use server'、またはサーバー専用の API Route モジュールに置いてください。
'use main'
export async function loadAccount(accountId: string) {
if (!/^[a-z0-9_-]{1,64}$/i.test(accountId)) {
throw new TypeError('invalid account id')
}
const token = process.env.ACCOUNT_API_TOKEN
if (!token) throw new Error('account service is not configured')
const response = await fetch(`https://api.example.com/accounts/${accountId}`, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) throw new Error(`account service returned ${response.status}`)
return response.json()
}ウィンドウの権限情報は、リクエストが宣言済みウィンドウのレンダラーから送られてきたことを示しますが、そのレンダラーが侵害されていないことまでは保証しません。XSS は、そのウィンドウに許可されたバックエンドリソースを引き続き実行できます。ハンドラー内でも、ユーザー/セッション/オブジェクト単位の認可を必ず行ってください。
パスと URL を検証する
レンダラーからの入力を、無制限のファイルシステムパスへそのまま連結しないでください。アプリが所有するディレクトリ配下に解決し、その結果が本当にディレクトリの内側に収まっていることを確認します。生のパスより不透明な ID を優先してください。
同様に、Node の関数が fetch() や URL のオープンを行う前には、プロトコルとホストを許可リストに登録してください。これにより、意図しないローカルネットワークへのアクセスや SSRF を防げます。レンダラーのリクエストヘッダーを、そのままリモートのサービスへ転送しないでください。
同じルールは openRequested() にも適用されます。登録済みのスキームはディスパッチの仕組みであって、認証の仕組みではありません。ローカルプロセス、ブラウザ、ドキュメントのいずれからも、オープンを試みることができます。ファイル関連付けもパスを示すだけであり、信頼できるファイルであることを示すものではありません。
import { defineMain } from 'murasaki/main'
export default defineMain({
async openRequested(_context, event) {
for (const target of event.targets) {
if (target.kind === 'url') {
const url = new URL(target.url)
if (url.protocol !== 'example-notes:' || url.hostname !== 'open') continue
const id = url.pathname.slice(1)
if (!/^[a-z0-9_-]{1,64}$/i.test(id)) continue
// 検証済み identifier を application code で解決する。
} else {
// target.path を parse する前に extension、access policy、size、
// file format を確認し、名前だけを根拠に content を実行しない。
}
}
},
})ディープリンク URL へレンダラーを自動的にナビゲーションさせたり、クエリ値を HTML に埋め込んだり、登録済みの拡張子があるという理由だけでファイルを実行したりしないでください。レンダラー側で結果が必要な場合は、Main のイベント API から、狭く検証済みの値だけを渡すようにします。
Content Security Policy
Murasaki はドキュメントに X-Content-Type-Options: nosniff と Referrer-Policy: no-referrer を設定し、解決済みの CSP を 2 つの経路で配信します。1 つは配信されるすべての HTML ドキュメントに付く Content-Security-Policy レスポンスヘッダーで(開発時の Vite ミドルウェアと、パッケージ済みアプリの Node サーバーの両方が設定します)、もう 1 つはフレームワーク側が管理する HTML とユーザー側が管理する HTML へ注入する CSP の meta タグです。どちらも同じ解決済みポリシーから生成されるため、内容が食い違うことはありません。本番環境のデフォルト値は script-src 'self' を使い、オブジェクト要素、フレーム、base URL の変更、インラインスクリプト属性、他オリジンへのフォーム送信を遮断し、このアプリのドキュメントを他のドキュメントへフレーム化させません(frame-ancestors 'none')。一方で、リモートに依存するアプリの HTTPS/WSS 接続は許可し、画像・フォント・メディアには一般的な HTTPS/data/blob のソースを許可します。React の style 属性とランタイムの CSS 互換性のため、style-src 'self' 'unsafe-inline' は残しています。
開発時には別のポリシーを使い、Vite / React Refresh が注入するインラインスクリプトと、HMR 用の ws: を追加で許可します。'unsafe-eval' は追加しません。この緩和は本番環境には含まれません。
ポリシー全体の上書き、または別のレイヤーが管理する場合の無効化を設定できます。
export default defineConfig({
appId: 'com.example.notes',
productName: 'Notes',
security: {
csp: "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self' https://api.example.com; object-src 'none'; base-uri 'none'; frame-src 'none'; frame-ancestors 'none'",
},
})
// headerとmeta tagの両方を無効化:
// security: { csp: false }文字列を指定した場合、それはディレクティブのマージではなく完全な上書きとなり、ヘッダーと meta タグの両方に同一に適用されます。ユーザー側が管理する index.html に CSP の meta タグがあれば、それを維持したまま <head> の先頭へ移動します。このケース(security.csp が未設定で CSP の meta タグが存在する場合)では、Murasaki は meta タグだけでポリシーを配信し、Content-Security-Policy レスポンスヘッダーは送信しません。両方を送ってしまうと、ブラウザ側で累積的に強制され、ユーザーが完全に把握しているはずの meta タグのポリシーを暗黙のうちに上書き(実質的にはさらに制限)してしまうためです。既存の meta タグと security.csp の文字列指定の両方を設定した場合は、どちらか一方を暗黙に選ぶのではなく、ビルドエラーとします。ブロックされたソースは、ブラウザコンソールの CSP 違反ログで確認し、そのソースだけを追加するか、アプリのオリジンへ移してください。
既存アプリを移行する際は、本番環境のインラインスクリプトを外部モジュールへ移してください。リモートのスクリプトやフレームはデフォルトでブロックされるため、必要であれば明示的に上書きしてください。HTTPS/WSS のバックエンドはデフォルトで利用できます。リモートの平文 HTTP バックエンドには上書きが必要です(同一オリジンのループバック呼び出しは許可されます)。
一部のディレクティブは、レスポンスヘッダーでしか有効になりません。ブラウザは <meta> タグ内の frame-ancestors や sandbox を無視し、report-to / report-uri もレポート先エンドポイントを紐付けるために実際の HTTP レスポンスを必要とします。そのため Murasaki は、これらのディレクティブを meta 用のポリシーから自動的に取り除き(エンジンによっては、無視したディレクティブに対してコンソール警告を出すため)、ヘッダーだけに残します。これにより、meta タグは仕様上クリーンなまま保たれ、ヘッダーがポリシー全体を運びます。デフォルトのポリシーに frame-ancestors 'none' を含めているのはこのためで、開発時でもパッケージ済みアプリでも、ヘッダーによって強制されます。meta タグは、これらのディレクティブを強制できないままフォールバックとして残り、ライブの HTTP レスポンスではなく、ビルド済みの file:// 出力を直接検査するツール向けに有用です。CSP は HTML をサニタイズするものではなく、Node の関数を認可するものでもありません。デフォルトのインラインスタイル許可も互換性上のトレードオフであり、XSS に対する安全性を保証するものではありません。引き続き dangerouslySetInnerHTML を避け、ユーザーが作成した HTML をサニタイズし、サードパーティのコードをバージョン固定してください。
実ネットワークサービスを公開する場合
Murasaki の API Route は、設計上アプリローカルであり、公開サービスとして外部から到達することはできません。Main プロセスから Node を使って TCP / WebSocket / HTTP のリスナーを作成することはできますが、その場合 Murasaki のランタイムセッション保護の外側に、新しいセキュリティ境界が生まれます。
- リモートクライアントへの対応が明示的な製品機能でない限り、
127.0.0.1にバインドする - メッセージを処理する前に認証する
- メッセージ/ペイロードの上限を定義する
- 負荷の高い処理はレート制限する
shutdown()でリスナーを閉じる- Murasaki のランタイムトークンを、独自プロトコルの認証情報として再利用しない
ページ内の権限リクエスト(getUserMedia / geolocation)
開発時とパッケージ済みアプリのレンダラードキュメントには、次のレスポンスヘッダーを付与します。
Permissions-Policy: camera=(), microphone=(), geolocation=()そのため、影響の大きいブラウザ API は WebKit/WebView2 固有のプロンプト挙動には流れず、フェイルクローズで拒否されます。これは、ネイティブ機能に対するホスト OS の同意を管理する systemPermission:* とは別の境界です。Wry 0.55 にはクロスプラットフォームなウィンドウ単位の権限コールバックが無いため、レンダラーの camera / microphone / geolocation を許可する設定エスケープハッチは現時点で提供していません。この境界をネイティブ側で一貫して強制できるようになるまでは、ケイパビリティで検査されるネイティブ機能を使うか、別途保護したサービスを利用してください。
コード署名と更新キーは別物
コード署名は、「この実行ファイルを作ったのは誰か」を OS に示します。Ed25519 の更新署名は、「このマニフェストをアプリの更新キー保有者が承認したか」を Murasaki に示します。本番環境のアップデーターでは、両方を使ってください。OS の成果物は Developer ID + 公証、または Murasaki の Windows Authenticode --sign フローで保護し、更新はマニフェスト署名とペイロードのハッシュで保護します。
既知のセキュリティ上の制限事項
- ネイティブケイパビリティはウィンドウ別で、URL、ファイルシステムパス、secureStorage のキー、対象ウィンドウ、OS の権限といった単位で範囲を指定できます。一方、ダイアログのデフォルト値、クリップボードの内容、トレイメニューの内容、権限昇格したプロセスの引数などは、まだコマンド単位のままで、ポリシーは独立した署名済みファイルではなく設定で管理されています
- バックエンドケイパビリティは、Server Actions、
'use main'、API/updater route、イベント、診断情報を、ネイティブウィンドウ別に隔離します。ブラウザプロファイルもウィンドウ別に分離されており、同一オリジンの Service Worker やブラウザの状態が、この権限情報の境界をまたぐことはできません。プライマリウィンドウは従来のプロファイルを維持し、セカンダリウィンドウの永続化には macOS 14 以降が必要です(macOS 11〜13 では、分離された非永続ストアへフェイルクローズします)。XSS は自身のウィンドウに許可されたリソースを引き続き使えるため、ハンドラー側でユーザー/セッション/オブジェクトを認可してください - シークレット(incognito)モードは永続プロファイルを使いませんが、複数のシークレットウィンドウが同じインメモリセッションを共有する保証はありません
- Linux のパッケージ署名(
murasaki installer --sign)は GPG による分離署名であり、apt/dnf のキーリングや distro リポジトリとの信頼統合はありません。Windows Authenticode には、開発者が用意した証明書または Artifact Signing プロバイダーと、Windows SDK の SignTool が必要です - Linux の
.debは.desktopのMimeTypeを使ってプロトコルとファイル関連付けを登録する。展開しただけの AppDir / AppImage と Windows のポータブルアーカイブは、意図的に OS へ自己登録しない - レンダラー用のネイティブ API は、
src/main.tsから直接呼び出すことはできない
正確な状況はプラットフォームと機能の状況で確認できます。
脆弱性を報告する
公開の issue は作らないでください。GitHub の非公開セキュリティアドバイザリを作成するか、[email protected] 宛てにメールしてください。1.0 未満では、最新のマイナーリリースがサポート対象です。