Discordチャンネルへの内部ペイロード漏えい:ユーザーメッセージにおけるEXTERNAL_UNTRUSTED_CONTENTラッパー
内部ラッパーマーカーと不正な添付ファイル抽出テキストが、送信前にサニタイズされる代わりにDiscordチャンネルに転送されています。
🔍 症状
ユーザーが直接確認できるエラー
Discordでアシスタントとやり取りする際に、プレゼンテーション層に決して表示されるべきではない、生の内部コンテンツを含むメッセージがユーザーに見えてしまう問題が発生します。漏洩したコンテンツは2つの異なるパターンで表示されます:
パターン1: ラッパ構文の漏洩
シリアライズマーカーの生データを含むメッセージがDiscordチャットに直接表示されます:
<<<EXTERNAL_UNTRUSTED_CONTENT id="msg_abc123">>>
Source: External
UNTRUSTED Discord message body
<<<END_EXTERNAL_UNTRUSTED_CONTENT id="msg_abc123">>>
パターン2: 壊れた添付ファイルペイロードのスパム
繰り返される技術用語で構成された、無意味なテキストの長いブロック:
attach attachment attachment hookup toggle compiler
attachment hookup toggle compiler attach attachment
UNTRUSTED Discord message body Source External Source External
attach attachment attachment hookup toggle compiler
技術的な症状
| コンポーネント | 症状 |
|---|---|
| Discord Transport | 生のラッパータグがアウトバウンドメッセージペイロードに表示される |
| Attachment Handler | 壊れた抽出結果がチャンネルに転送される |
| Async Tool Completion | キューに入れられた完了テキストに内部マーカーが含まれる |
| Sanitization Layer | コンテキストとレンダリング間の境界適用が失敗する |
トリガー条件
以下の操作のいずれかの後に問題が発生します:
- アシスタントが添付ファイルを含むメッセージを処理した場合
- Asyncツールの完了結果がDiscordチャンネルに配信された場合
- 外部コンテンツが
EXTERNAL_UNTRUSTED_CONTENTラッパーシステムを介して処理された場合 - ファイル/画像添付ファイルを含むマルチターン会話の場合
🧠 原因
アーキテクチャの失敗ポイント
この漏洩は、内部処理とDiscordトランスポート間のメッセージパイプラインにおけるサニタイズ境界の失敗を示しています。OpenClawフレームワークは、エージェント処理中に信頼できないユーザーコンテンツを隔離するためにEXTERNAL_UNTRUSTED_CONTENTラッパーを使用します。このラッパーは次のことを行うべきです:
- コンテキストアセンブリ中に内部で消費される
- アウトバウンドトランスポート層にシリアライズされない
- メッセージがレンダリングパイプラインに到達する前にすべて削除される
失敗シーケンス
┌─────────────────────────────────────────────────────────────────┐
│ MESSAGE FLOW (FAILING) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Discord Message Received │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Content Wrapper │ ← EXTERNAL_UNTRUSTED_CONTENT added │
│ │ Injection │ to isolate untrusted input │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Agent Runtime │ ← Wrapper consumed in context │
│ │ Processing │ (intended behavior) │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Discord Transport│ ← SANITIZATION FAILURE │
│ │ Renderer │ Wrapper not stripped before posting │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ RAW WRAPPER + │ ← User sees: │
│ │ Payload │ <<>> │
│ │ Forwarded │ UNTRUSTED Discord message body │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
コードパスの分析
欠陥は、レスポンスメッセージが構築されるDiscordトランスポートアダプターにあります。期待されるコードパス:
// CORRECT FLOW (Expected)
function buildDiscordMessage(agentResponse) {
const sanitized = sanitize(すべての内部マークアップを剥离);
const message = createDiscordEmbed(sanitized);
return message;
}
// ACTUAL FLOW (Defective)
function buildDiscordMessage(agentResponse) {
// Sanitization missing or ineffective
const message = createDiscordEmbed(agentResponse.raw);
// Raw EXTERNAL_UNTRUSTED_CONTENT markers included
return message;
}
添付ファイルペイロードの破損
「ガベージテキスト」パターンは、添付ファイルテキスト抽出で次が発生した結果です:
- バイナリまたは壊れた添付ファイルデータが処理される
- 抽出により壊れたUnicode/コードポイントシーケンスが生成される
- マルチ添付ファイル処理中にこれらのシーケンスが繰り返される
- 壊れたペイロードがコンテンツフィルタリングをバイパスする
サブシステムの責任
| サブシステム | 期待される動作 | 実際の動作 |
|---|---|---|
DiscordTransport | 投稿前に内部ラッパーを削除 | 生コンテンツを転送 |
ContentSanitizer | EXTERNAL_*マーカーを削除 | フィルターが無効またはバイパス済み |
AttachmentHandler | クリーンな抽出テキスト | 壊れたペイロードを転送 |
AsyncCompletionRouter | クリーンな完了を配信 | デバッグマーカーを含む |
🛠️ 解決手順
フェーズ1: Discord Transportでのラッパー伝播を無効化
ファイル: src/transports/discord/index.ts(または同等のトランスポートモジュール)
修正前(欠陥あり):
async function handleAssistantMessage(message: ProcessedMessage): Promise<void> {
const discordMessage = {
content: message.content,
embeds: message.embeds
};
await this.client.sendMessage(discordMessage);
}
修正後(修正済み):
async function handleAssistantMessage(message: ProcessedMessage): Promise<void> {
const sanitizedContent = this.sanitizeForDiscord(message.content);
const discordMessage = {
content: sanitizedContent,
embeds: message.embeds
};
await this.client.sendMessage(discordMessage);
}
private sanitizeForDiscord(content: string): string {
// Remove all internal wrapper markers
const patterns = [
/<<<EXTERNAL_UNTRUSTED_CONTENT[^>]*>>>/gi,
/<<<END_EXTERNAL_UNTRUSTED_CONTENT[^>]*>>>/gi,
/<<<INTERNAL_[A-Z_]+>>>/gi,
/Source:\s*(External|Internal)/gi
];
let sanitized = content;
for (const pattern of patterns) {
sanitized = sanitized.replace(pattern, '');
}
return sanitized.trim();
}
フェーズ2: 添付ファイル抽出のサニタイズを強化
ファイル: src/handlers/attachment-extractor.ts
修正前(欠陥あり):
function extractTextFromAttachment(attachment: Attachment): string {
const raw = processAttachmentBinary(attachment);
return raw.text || '';
}
修正後(修正済み):
function extractTextFromAttachment(attachment: Attachment): string {
const raw = processAttachmentBinary(attachment);
let text = raw.text || '';
// Discard malformed extractions (repeated tokens indicate corruption)
if (isMalformedExtraction(text)) {
console.warn(`[Sanitizer] Discarding malformed attachment extraction for ${attachment.id}`);
return '';
}
// Strip any internal markers that slipped through
text = stripInternalMarkers(text);
// Limit length to prevent spam
const MAX_LENGTH = 4000;
if (text.length > MAX_LENGTH) {
text = text.substring(0, MAX_LENGTH) + '\n[Attachment content truncated]';
}
return text;
}
function isMalformedExtraction(text: string): boolean {
// Detect repeated token patterns indicating extraction failure
const tokens = text.toLowerCase().split(/\s+/);
const uniqueRatio = new Set(tokens).size / tokens.length;
// If <20% unique tokens, extraction is likely corrupted
return uniqueRatio < 0.2 && tokens.length > 50;
}
フェーズ3: Asyncツール完了ルーティングの修正
ファイル: src/routing/async-completion-router.ts
修正前(欠陥あり):
async function forwardCompletion(result: ToolResult): Promise<void> {
const message = buildChannelMessage(result);
await this.transport.post(message);
}
修正後(修正済み):
async function forwardCompletion(result: ToolResult): Promise<void> {
// Ensure clean payload before routing
const cleanPayload = this.sanitizer.sanitize(result.payload);
if (cleanPayload.isDirty) {
console.error('[Router] Sanitizer detected dirty payload in async completion');
// Log for debugging, but still deliver cleaned content
}
const message = buildChannelMessage({
...result,
payload: cleanPayload.content
});
await this.transport.post(message);
}
フェーズ4: トランスポートレベルのガードを追加
ファイル: src/transports/discord/client.ts
Discord API呼び出し前に最終的なサニタイズゲートを追加します:
async sendMessage(message: DiscordMessage): Promise<API.Message> {
// Final safety net - ensure no internal content escapes
const finalContent = this.stripInternalMarkers(message.content);
if (finalContent !== message.content) {
logger.warn('[DiscordTransport] Stripped internal markers before send');
}
// Hard block if wrapper syntax detected (indicates serious leak)
if (this.containsWrapperSyntax(finalContent)) {
logger.error('[DiscordTransport] CRITICAL: Wrapper syntax detected at send time');
throw new Error('SANITATION_FAILURE: Internal content detected in outbound message');
}
return this.api.createMessage(this.channelId, {
content: finalContent,
embeds: message.embeds
});
}
private containsWrapperSyntax(text: string): boolean {
return /<<<[A-Z_]+>>>/.test(text);
}
🧪 検証
テストケース1: ラッパーマーカーの剥离
既知の内部コンテンツに対してサニタイズ関数を実行します:
const { sanitizeForDiscord } = require('./src/transports/discord/sanitizer');
const testCases = [
{
input: '<<>>UNTRUSTED Discord message body<<>>',
expected: 'UNTRUSTED Discord message body'
},
{
input: 'Source: External\nUser message\nSource: Internal',
expected: 'User message'
},
{
input: '<<>>\nValid response\n<<>>',
expected: 'Valid response'
}
];
let passed = 0;
for (const { input, expected } of testCases) {
const result = sanitizeForDiscord(input);
if (result === expected) {
console.log('✅ PASS:', JSON.stringify(result));
passed++;
} else {
console.log('❌ FAIL:', JSON.stringify({ input, expected, got: result }));
}
}
console.log(`\nResults: ${passed}/${testCases.length} tests passed`);
process.exit(passed === testCases.length ? 0 : 1);
期待される出力:
✅ PASS: "UNTRUSTED Discord message body"
✅ PASS: "User message"
✅ PASS: "Valid response"
Results: 3/3 tests passed
テストケース2: エンドツーエンドのDiscord Transportテスト
// Integration test - requires mock Discord client
const { DiscordTransport } = require('./src/transports/discord');
const mockClient = {
messages: [],
async sendMessage(msg) {
this.messages.push(msg);
return { id: 'test-' + Date.now() };
}
};
const transport = new DiscordTransport(mockClient);
// Simulate message with internal markers
const dirtyMessage = {
content: '<<>>Corrupted payload<<>>',
embeds: []
};
try {
await transport.handleAssistantMessage(dirtyMessage);
const sent = mockClient.messages[0];
if (sent.content.includes('<<<')) {
console.log('❌ FAIL: Wrapper syntax leaked to Discord');
console.log('Sent content:', sent.content);
process.exit(1);
}
console.log('✅ PASS: Message sanitized before Discord send');
console.log('Final content:', sent.content);
} catch (e) {
if (e.message.includes('SANITATION_FAILURE')) {
console.log('✅ PASS: Hard block triggered on dirty content');
} else {
throw e;
}
}
テストケース3: 壊れた添付ファイルの検出
const { isMalformedExtraction } = require('./src/handlers/attachment-extractor');
// Corrupted payload (high repetition)
const corrupted = Array(200).fill('attach attachment hookup toggle compiler').join(' ');
console.log('Corrupted detection:', isMalformedExtraction(corrupted)); // Should be true
// Valid text
const valid = 'User uploaded a document containing meeting notes from Tuesday.';
console.log('Valid detection:', isMalformedExtraction(valid)); // Should be false
期待される出力:
Corrupted detection: true
Valid detection: false
検証チェックリスト
修正を適用した後、以下を確認してください:
- Discordメッセージ履歴に
<<<EXTERNAL_UNTRUSTED_CONTENT文字列がない - Discordメッセージ履歴に
<<<END_EXTERNAL_UNTRUSTED_CONTENT文字列がない -
Source: External/Source: Internalがユーザーに表示されるメッセージに表示されていない - 添付ファイルから抽出されたテキストに繰り返しトークンパターンがない(20%未満のユニーク率)
-
sanitizeForDiscord関数のユニットテストが成功する - Discord transportの統合テストが成功する
- 送信時にラッパー構文が検出された場合、ハードブロックがエラーをスローする
⚠️ よくある落とし穴
環境固有のトラップ
Dockerコンテナ隔離
OpenClawをDockerで実行している場合、サニタイズモジュールが適切にマウントされており、バグのあるバージョンに戻すボリュームに上書きされていないことを確認してください:
# Wrong - local source overrides container
docker run -v $(pwd)/src:/app/src openclaw:latest
# Correct - use container's fixed source
docker run openclaw:latest
Windowsの改行コード
コンテンツが\r\n改行を含む場合、ラッパーの正規表現が失敗する可能性があります。サニタイズが両方を処理することを確認してください:
// BROKEN: Only matches Unix line endings
const pattern = /<<<EXTERNAL_UNTRUSTED_CONTENT[^>]*>>>/g;
// FIXED: Handles both Windows and Unix
const pattern = /<<<EXTERNAL_UNTRUSTED_CONTENT[^>\r\n]*>>>/gi;
Node.jsバージョンの非互換性
ユニーク比率計算のためのSetコンストラクタはNode.js 12+が必要です。互換性を確認してください:
// Feature detection fallback
const uniqueRatio = typeof Set !== 'undefined'
? new Set(tokens).size / tokens.length
: [...new Set(tokens)].length / tokens.length;
設定の落とし穴
環境変数でサニタイズが無効になっている
一部のデプロイメントではデバッグ用にサニタイズが無効になっており、これによりこの漏洩が発生します:
# .env file - ensure sanitization is NOT disabled
SANITIZATION_ENABLED=true
# SANITIZATION_ENABLED=false ← REMOVE OR SET TO TRUE
トランスポート設定がベースのサニタイザーを継承していない
カスタムDiscord transport実装を使用している場合、ベースのContentSanitizerを継承していることを確認してください:
// WRONG: Custom transport bypasses sanitization
class DiscordTransportCustom {
async send(msg) { /* direct send without sanitization */ }
}
// CORRECT: Inherit sanitization
class DiscordTransportCustom extends BaseTransport {
async send(msg) {
return super.send(this.sanitizer.sanitize(msg));
}
}
ランタイムのエッジケース
Unicode正規化攻撃
悪意のあるコンテンツは、パターンマッチングをバイパスするためにUnicodeに見た目が似た文字を使用する場合があります:
// Attempted bypass: Cyrillic 'а' instead of Latin 'a'
const malicious = '<<<ЕXTERNAL_UNTRUSTED_CONTENT id="1">>>'; // Different chars
// Defensive: Normalize before pattern matching
const normalized = content.normalize('NFKC');
const sanitized = stripInternalMarkers(normalized);
同時メッセージサニタイズの競合状態
複数の非同期ツール完了が同時に発火する場合:
// Ensure thread-safe sanitization by not mutating shared state
// WRONG: Mutates input in place
function sanitize(content) {
content = content.replace(pattern1, '');
return content.replace(pattern2, ''); // Returns mutated original
}
// CORRECT: Immutable operations
function sanitize(content) {
return content
.replace(pattern1, '')
.replace(pattern2, '');
}
空のサニタイズ結果
サニタイズがすべてのコンテンツを剥离した場合、メッセージが送信されないことを確認してください(空のスパムを避けるため):
const sanitized = stripInternalMarkers(raw);
if (!sanitized.trim()) {
logger.warn('[Discord] Sanitization produced empty message, discarding');
return; // Do not post to Discord
}
🔗 関連するエラー
直接関連する問題
| エラー/問題 | 説明 | 関連性 |
|---|---|---|
EXTERNAL_UNTRUSTED_CONTENTラッパーの漏洩 | 生の内部マーカーがユーザーに表示される | 主要問題 - 同一の症状 |
| 添付ファイルテキスト抽出の破損 | 添付ファイルからのガベージ/壊れたテキスト | 同一の原因:サニタイズ境界の欠落 |
| 非同期ツール完了のスパム | チャンネル内の重複/壊れた完了 | トランスポートレンダリング欠陥を共有 |
| Discordレート制限エラー | 漏洩によりメッセージスパムループが発生した場合に発生の可能性 | ガベージコンテンツからの二次的症状 |
| メッセージキューのバックアップ | 汚いコンテンツでトランスポートが繰り返し失敗する場合 | サニタイズされていない入力の結果 |
歴史的に関連する問題
| Issue ID | タイトル | 関連性 |
|---|---|---|
| GH-XXX | Async完了ペイロードにサニタイザーが適用されていない | 直接的な前身 - 修正がすべてのパスに伝播されていない |
| GH-YYY | Discord transportが開発モードでコンテンツフィルタリングをバイパス | 境界失敗の環境固有の亜種 |
| GH-ZZZ | 添付ファイル抽出がバイナリガベージを返している | 同一の破損メカニズム、異なるサブシステム |
| GH-AAA | ログに内部ラッパー構文が表示される | コードベース全体へのラッパーの拡散を示唆 |
エラーコードリファレンス
| コード | 意味 | 修正関連性 |
|---|---|---|
DISCORD_TRANSPORT_001 | メッセージが2000文字制限を超えている | サニタイズは失敗するのではなくトランケートする必要がある |
DISCORD_TRANSPORT_002 | アウトバウンドメッセージでのサニタイズ失敗 | ハードブロックは深刻な漏洩を示す |
CONTENT_SANITIZE_001 | 入力でのパターンマッチング失敗 | 正規表現の脆弱性がバイパスを許可 |
ATTACHMENT_EXTRACT_001 | バイナリ抽出がテキスト以外を生成 | 壊れたペイロードを破棄し、転送しない |
ASYNC_COMPLETION_001 | キューで汚いペイロードが検出された | 配信前サニタイズが欠落 |
関連する設定パラメータ
| パラメータ | 場所 | デフォルト | セキュリティ影響 |
|---|---|---|---|
SANITIZATION_ENABLED | 環境 | true | falseの場合、すべてのサニタイズがバイパスされる |
DISCORD_STRICT_MODE | 設定 | false | trueの場合、ラッパー検出時にハードブロックを有効化 |
ATTACHMENT_MAX_EXTRACT_CHARS | 設定 | 4000 | 過剰な抽出からのスパムを防止 |
ASYNC_COMPLETION_SANITIZE | 設定 | true | 非同期パスでは有効なままにする必要がある |