diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..842343c1 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,17 @@ +# Security Policy + +byteflow.tools is a privacy-first, browser-local developer tools project. Most tool payloads should stay in the user's browser and must not be included in public reports. + +## Reporting a Vulnerability + +Report suspected vulnerabilities privately through GitHub Security Advisories: + +https://github.com/baixiangcpp/byteflow.tools/security/advisories/new + +Do not open a public issue for exploitable behavior, data exposure, bypasses, or reports that require private reproduction details. Do not include production secrets, API keys, JWTs, certificates, private keys, customer data, logs, HAR files, uploaded files, screenshots with private data, or full request/response bodies. + +For non-security bugs and feature requests, use GitHub Issues. + +## Supported Version + +Security fixes target the hosted site and the current `main` branch. diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt new file mode 100644 index 00000000..f63cf29b --- /dev/null +++ b/public/.well-known/security.txt @@ -0,0 +1,5 @@ +Contact: https://github.com/baixiangcpp/byteflow.tools/security/advisories/new +Policy: https://github.com/baixiangcpp/byteflow.tools/security/policy +Canonical: https://byteflow.tools/.well-known/security.txt +Preferred-Languages: en +Expires: 2027-06-24T00:00:00Z diff --git a/public/_redirects b/public/_redirects index a53cbd1e..71bb5347 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1,3 +1,4 @@ +/security.txt /.well-known/security.txt 301 /en/cron-expression-generator /en/crontab-generator 301 /zh-CN/cron-expression-generator /zh-CN/crontab-generator 301 /zh-TW/cron-expression-generator /zh-TW/crontab-generator 301 diff --git a/scripts/generators/generate-legacy-routes.js b/scripts/generators/generate-legacy-routes.js index 4b4bb06f..04a5480b 100644 --- a/scripts/generators/generate-legacy-routes.js +++ b/scripts/generators/generate-legacy-routes.js @@ -9,6 +9,9 @@ const TOOL_ALIASES_PATH = path.join(ROOT, "src/core/registry/tool-aliases.json") const REDIRECTS_PATH = path.join(ROOT, "public/_redirects") const LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "de", "fr"] const REDIRECT_STATUSES = new Set([301, 302]) +const STATIC_REDIRECT_LINES = [ + "/security.txt /.well-known/security.txt 301", +] const CHECK_ONLY = process.argv.includes("--check") function readText(filePath) { @@ -90,6 +93,7 @@ export function loadLegacyTaxonomyRedirects() { function buildRedirectLines(routes, taxonomyRedirects = loadLegacyTaxonomyRedirects()) { return [ + ...STATIC_REDIRECT_LINES, ...routes.flatMap((route) => { if (!REDIRECT_STATUSES.has(route.status)) return [] return LOCALES.map((locale) => `/${locale}/${route.sourceSlug} /${locale}/${route.targetSlug} ${route.status}`) diff --git a/src/app/[lang]/contact/page.tsx b/src/app/[lang]/contact/page.tsx index 6ab8727e..9f01c4fa 100644 --- a/src/app/[lang]/contact/page.tsx +++ b/src/app/[lang]/contact/page.tsx @@ -3,12 +3,13 @@ import { useLang } from "@/core/i18n/lang-provider" import { toast } from "sonner" import { Button } from "@/components/ui/button" -import { Github, Mail, MessageSquare, ExternalLink } from "lucide-react" +import { Github, Mail, MessageSquare, ExternalLink, ShieldCheck } from "lucide-react" import { safeClipboardWrite } from "@/core/clipboard/clipboard" const EMAIL_ADDRESS = "contact@byteflow.tools" const GITHUB_REPOSITORY_URL = "https://github.com/baixiangcpp/byteflow.tools" const GITHUB_ISSUES_URL = "https://github.com/baixiangcpp/byteflow.tools/issues" +const SECURITY_ADVISORY_URL = "https://github.com/baixiangcpp/byteflow.tools/security/advisories/new" export default function ContactPage() { const { t } = useLang() @@ -17,6 +18,7 @@ export default function ContactPage() { const links = [ { icon: Github, title: "GitHub", desc: p.contact_github_desc, href: GITHUB_REPOSITORY_URL, external: true }, { icon: MessageSquare, title: p.contact_issues_title, desc: p.contact_issues_desc, href: GITHUB_ISSUES_URL, external: true }, + { icon: ShieldCheck, title: p.contact_security_title, desc: p.contact_security_desc, href: SECURITY_ADVISORY_URL, external: true }, ] const handleCopyEmail = async () => { @@ -37,7 +39,7 @@ export default function ContactPage() {

{p.contact_intro}

-
+
{links.map((link) => ( + +
@@ -281,6 +289,13 @@ export default async function TrustCenterPage({
diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index ed214884..30071e50 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -165,6 +165,10 @@ "hosts_label": "Zielhosts", "purpose_label": "Zweck", "data_sent_label": "Gesendete Daten", + "confirm_title": "Externe Anfrage bestaetigen", + "confirm_checkbox": "Mir ist bewusst, dass diese Aktion das angegebene externe Asset aus meinem Browser anfordern kann.", + "confirm_rights": "Verwenden Sie nur Assets, die Ihnen gehoeren, fuer die Sie berechtigt sind oder die laut Plattformbedingungen zulaessig sind.", + "confirm_required": "Bestaetigen Sie die externe Anfrage vor Vorschau oder Download.", "purposes": { "authorized_media_download": "Medien von einer von Ihnen angegebenen URL herunterladen, nachdem Sie die Nutzungsberechtigung bestätigt haben.", "thumbnail_preview": "Öffentliche Thumbnail-Bild-URLs aus dem eingegebenen Videolink erzeugen und anzeigen." @@ -1051,7 +1055,10 @@ "capture_groups_label": "Erfassungsgruppen", "group_prefix": "Gruppe", "error_invalid_regex": "Ungueltiger regulaerer Ausdruck", - "error_match_limit": "Trefferlimit ueberschritten. Der Ausdruck koennte endlos leere Zeichenfolgen finden." + "error_match_limit": "Trefferlimit ueberschritten. Der Ausdruck koennte endlos leere Zeichenfolgen finden.", + "timing_label": "Dauer: {ms} ms", + "evaluating_label": "Wird ausgewertet...", + "performance_warning_title": "Leistungswarnung" }, "regex_generator": { "title": "Regex-Generator", @@ -2387,10 +2394,14 @@ "option_awsAccessKeys": "AWS-Zugriffsschlüssel", "option_privateKeys": "Private-Key-Blöcke", "option_urlCredentials": "URL-Zugangsdaten", + "option_cookies": "Cookie-Header", + "option_sessionIds": "Sitzungs- und Anfrage-IDs", "scrub_action": "Logs bereinigen", "scrubbed": "{count} Wert(e) redigiert", "redactions": "Redaktionen", "types": "Typen", + "summary_title": "Redaktionsübersicht", + "manual_review_note": "Automatische Redaktion ist eine Sicherheitsstufe, keine Garantie. Prüfen Sie die bereinigte Ausgabe vor dem Teilen.", "findings_title": "Erkannte sensible Werte", "table_type": "Typ", "table_position": "Originalzeile:Spalte", @@ -2510,6 +2521,8 @@ "option_responseContent": "Antwortinhalte", "requests": "Anfragen", "redactions": "Redaktionen", + "summary_title": "Sanitizing-Übersicht", + "review_warning": "Der Standardexport redigiert riskante HAR-Felder. Prüfen Sie die bereinigte HAR-Datei vor dem Teilen.", "entries_title": "HAR-Einträge", "method": "Methode", "status": "HTTP-Status", @@ -2871,11 +2884,14 @@ "contact_issues_desc": "Erstellen Sie ein GitHub-Issue für Bugmeldungen oder Feature-Anfragen.", "contact_email_title": "E-Mail", "contact_email_desc": "Für geschäftliche Anfragen oder Partnerschaften kontaktieren Sie uns per E-Mail.", + "contact_security_title": "Sicherheitslücke", + "contact_security_desc": "Melde vermutete Schwachstellen über GitHub Security Advisories. Füge öffentlichen Issues keine Geheimnisse oder privaten Payloads hinzu.", "privacy_external_request_tools_title": "Tools mit externer Anfrage", "privacy_external_request_tools_desc": "Diese Liste wird aus Tool-Manifests generiert. Diese Tools kontaktieren die aufgeführten Domains nur für den offengelegten Zweck und erst nach Auswahl der externen Anfrageaktion.", "trust_center_title": "Datenschutz- und Vertrauenszentrum", "trust_center_intro": "Hier wird erklärt, wie byteflow.tools browser-lokale Tools, externe Anfragen, sensible Eingaben, Speicherung, Analytics, PWA-Cache, Sicherheitsheader und Meldungen von Schwachstellen kennzeichnet.", "trust_center_source_link": "Quellcode auf GitHub ansehen", + "trust_center_securitytxt_link": "security.txt ansehen", "trust_center_summary_local_title": "Local-first Laufzeit", "trust_center_summary_local_desc": "Die meisten Tools verarbeiten Eingabe und Ausgabe im Browser. Tools mit externen Anfragen werden vor Netzwerkzugriff gekennzeichnet.", "trust_center_summary_network_title": "Transparente Netzwerkzugriffe", @@ -2910,7 +2926,7 @@ "trust_center_xss_title": "Darstellung von Nutzerinhalten", "trust_center_xss_desc": "Tools mit Vorschau für Markdown, HTML, SVG oder Metadaten sollten nutzergesteuertes Markup bereinigen und CSP nicht lockern, nur damit Vorschauen funktionieren.", "trust_center_vulnerability_title": "Meldung von Schwachstellen", - "trust_center_vulnerability_desc": "Melde vermutete Schwachstellen über GitHub Security Advisories oder den Issue-Prozess des Repositorys. Füge öffentlichen Meldungen keine Produktionsgeheimnisse oder privaten Payloads hinzu.", + "trust_center_vulnerability_desc": "Melde vermutete Schwachstellen über private GitHub Security Advisories. Nutze öffentliche Issues nur für nicht sicherheitsrelevante Bugs oder Feature-Anfragen und füge öffentlichen Meldungen niemals Produktionsgeheimnisse oder private Payloads hinzu.", "trust_center_faq_title": "FAQ zum Vertrauenszentrum", "trust_center_faq_q1": "Laufen alle Tools vollständig offline?", "trust_center_faq_a1": "Nein. Browser-lokale Werkzeuge können nach dem Caching der Assets offline weiterlaufen, aber Werkzeuge mit externer Anfrage benötigen Netzwerkzugriff für die offengelegte Aktion.", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index ff8519dc..d366ebc0 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -169,6 +169,10 @@ "hosts_label": "Hosts", "purpose_label": "Purpose", "data_sent_label": "Data sent", + "confirm_title": "Confirm external request", + "confirm_checkbox": "I understand this action may request the disclosed external asset from my browser.", + "confirm_rights": "Use only assets you own, are authorized to use, or are allowed to access under the platform terms.", + "confirm_required": "Confirm the external request before previewing or downloading.", "purposes": { "authorized_media_download": "Download media from a URL you provide after you confirm you are allowed to use it.", "thumbnail_preview": "Generate and preview public thumbnail image URLs derived from the video link you enter." @@ -1079,7 +1083,10 @@ "capture_groups_label": "Capture Groups", "group_prefix": "Group", "error_invalid_regex": "Invalid regex", - "error_match_limit": "Match limit exceeded. The regex may be matching endless zero-length strings." + "error_match_limit": "Match limit exceeded. The regex may be matching endless zero-length strings.", + "timing_label": "{ms} ms", + "evaluating_label": "Evaluating...", + "performance_warning_title": "Performance warning" }, "regex_generator": { "title": "Regex Generator", @@ -2407,10 +2414,14 @@ "option_awsAccessKeys": "AWS access keys", "option_privateKeys": "Private key blocks", "option_urlCredentials": "URL credentials", + "option_cookies": "Cookie headers", + "option_sessionIds": "Session and request IDs", "scrub_action": "Scrub logs", "scrubbed": "{count} item(s) redacted", "redactions": "Redactions", "types": "Types", + "summary_title": "Redaction summary", + "manual_review_note": "Automated redaction is a safety layer, not a guarantee. Review the scrubbed output before sharing.", "findings_title": "Detected sensitive values", "table_type": "Type", "table_position": "Original line:column", @@ -2530,6 +2541,8 @@ "option_responseContent": "Response content", "requests": "Requests", "redactions": "Redactions", + "summary_title": "Sanitization summary", + "review_warning": "Default export redacts risky HAR fields. Review the sanitized HAR before sharing.", "entries_title": "HAR entries", "method": "Method", "status": "Status", @@ -2871,11 +2884,14 @@ "contact_issues_desc": "Open a GitHub issue for bug reports or feature requests.", "contact_email_title": "Email", "contact_email_desc": "For business inquiries or partnership opportunities.", + "contact_security_title": "Security vulnerability", + "contact_security_desc": "Report suspected vulnerabilities through GitHub Security Advisories. Do not include secrets or private payloads in public issues.", "privacy_external_request_tools_title": "External request tools", "privacy_external_request_tools_desc": "This list is generated from tool manifests. These tools may contact the listed domains only for the disclosed purpose and only after the external-request action is chosen.", "trust_center_title": "Privacy and Trust Center", "trust_center_intro": "Understand how byteflow.tools labels browser-local tools, external requests, sensitive inputs, storage, analytics, PWA cache behavior, security headers, and vulnerability reporting.", "trust_center_source_link": "View source on GitHub", + "trust_center_securitytxt_link": "View security.txt", "trust_center_summary_local_title": "Local-first runtime", "trust_center_summary_local_desc": "Most tools process input and output in your browser. External-request tools are labeled before network access.", "trust_center_summary_network_title": "Network transparency", @@ -2910,7 +2926,7 @@ "trust_center_xss_title": "User content rendering", "trust_center_xss_desc": "Tools that preview Markdown, HTML, SVG, or metadata should sanitize user-controlled markup and avoid relaxing CSP to make previews work.", "trust_center_vulnerability_title": "Vulnerability reporting", - "trust_center_vulnerability_desc": "Report suspected vulnerabilities through GitHub Security Advisories or the repository issue process. Do not include production secrets or private payloads in public reports.", + "trust_center_vulnerability_desc": "Report suspected vulnerabilities through private GitHub Security Advisories. Use public issues only for non-security bugs or feature requests, and never include production secrets or private payloads in public reports.", "trust_center_faq_title": "Trust Center FAQ", "trust_center_faq_q1": "Do all tools run completely offline?", "trust_center_faq_a1": "No. Browser-local tools can continue offline after assets are cached, but tools marked External request need network access for the disclosed action.", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index d65ef8c7..34ccb1ed 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -169,6 +169,10 @@ "hosts_label": "Hôtes", "purpose_label": "Objectif", "data_sent_label": "Données envoyées", + "confirm_title": "Confirmer la requête externe", + "confirm_checkbox": "Je comprends que cette action peut demander la ressource externe indiquée depuis mon navigateur.", + "confirm_rights": "Utilisez uniquement des ressources que vous possédez, que vous êtes autorisé à utiliser ou que les conditions de la plateforme permettent d'accéder.", + "confirm_required": "Confirmez la requête externe avant l'aperçu ou le téléchargement.", "purposes": { "authorized_media_download": "Télécharger un média depuis une URL que vous fournissez après confirmation de votre droit d’utilisation.", "thumbnail_preview": "Générer et prévisualiser des URL d’images miniatures publiques dérivées du lien vidéo saisi." @@ -1051,7 +1055,10 @@ "capture_groups_label": "Groupes capturés", "group_prefix": "Groupe", "error_invalid_regex": "Expression régulière invalide", - "error_match_limit": "Limite de correspondances dépassée. L'expression peut faire des correspondances infinies sur une chaîne vide." + "error_match_limit": "Limite de correspondances dépassée. L'expression peut faire des correspondances infinies sur une chaîne vide.", + "timing_label": "Duree : {ms} ms", + "evaluating_label": "Evaluation...", + "performance_warning_title": "Avertissement de performance" }, "regex_generator": { "title": "Générateur Regex", @@ -2387,10 +2394,14 @@ "option_awsAccessKeys": "Clés d’accès AWS", "option_privateKeys": "Blocs de clé privée", "option_urlCredentials": "Identifiants d’URL", + "option_cookies": "En-têtes Cookie", + "option_sessionIds": "ID de session et de requête", "scrub_action": "Nettoyer les logs", "scrubbed": "{count} élément(s) masqué(s)", "redactions": "Masquages", "types": "Catégories", + "summary_title": "Résumé du masquage", + "manual_review_note": "Le masquage automatique est une couche de sécurité, pas une garantie. Vérifiez la sortie nettoyée avant de la partager.", "findings_title": "Valeurs sensibles détectées", "table_type": "Catégorie", "table_position": "Ligne:colonne d’origine", @@ -2510,6 +2521,8 @@ "option_responseContent": "Contenu de réponse", "requests": "Requêtes", "redactions": "Masquages", + "summary_title": "Résumé de la sanitisation", + "review_warning": "L'export par défaut masque les champs HAR risqués. Vérifiez le HAR nettoyé avant de le partager.", "entries_title": "Entrées HAR", "method": "Méthode", "status": "Statut", @@ -2871,11 +2884,14 @@ "contact_issues_desc": "Ouvrez une issue GitHub pour signaler un bug ou proposer une fonctionnalité.", "contact_email_title": "E-mail", "contact_email_desc": "Pour les demandes commerciales ou de partenariat, contactez-nous par e-mail.", + "contact_security_title": "Vulnérabilité de sécurité", + "contact_security_desc": "Signalez les vulnérabilités suspectées via GitHub Security Advisories. N’incluez pas de secrets ni de payloads privés dans les issues publiques.", "privacy_external_request_tools_title": "Outils à requête externe", "privacy_external_request_tools_desc": "Cette liste est générée depuis les manifests d'outils. Ces outils peuvent contacter les domaines listés uniquement pour l'objectif indiqué et après avoir choisi l'action de requête externe.", "trust_center_title": "Centre de confidentialité et de confiance", "trust_center_intro": "Cette page explique comment byteflow.tools signale les outils exécutés dans le navigateur, les requêtes externes, les entrées sensibles, le stockage, l’analytics, le cache PWA, les en-têtes de sécurité et le signalement de vulnérabilités.", "trust_center_source_link": "Voir le code source sur GitHub", + "trust_center_securitytxt_link": "Voir security.txt", "trust_center_summary_local_title": "Exécution local-first", "trust_center_summary_local_desc": "La plupart des outils traitent les entrées et sorties dans le navigateur. Les outils avec requête externe sont signalés avant l’accès réseau.", "trust_center_summary_network_title": "Transparence réseau", @@ -2910,7 +2926,7 @@ "trust_center_xss_title": "Rendu du contenu utilisateur", "trust_center_xss_desc": "Les outils qui prévisualisent Markdown, HTML, SVG ou métadonnées doivent assainir le balisage contrôlé par l’utilisateur et ne pas assouplir la CSP pour faire fonctionner les aperçus.", "trust_center_vulnerability_title": "Signalement de vulnérabilités", - "trust_center_vulnerability_desc": "Signalez les vulnérabilités suspectées via GitHub Security Advisories ou le processus d’issues du dépôt. N’incluez pas de secrets de production ni de payloads privés dans un rapport public.", + "trust_center_vulnerability_desc": "Signalez les vulnérabilités suspectées via GitHub Security Advisories en mode privé. Utilisez les issues publiques uniquement pour les bugs non liés à la sécurité ou les demandes de fonctionnalités, et n’incluez jamais de secrets de production ni de payloads privés dans un rapport public.", "trust_center_faq_title": "FAQ du Centre de confiance", "trust_center_faq_q1": "Tous les outils fonctionnent-ils entièrement hors ligne ?", "trust_center_faq_a1": "Non. Les outils exécutés dans le navigateur peuvent continuer hors ligne après la mise en cache des assets, mais les outils marqués requête externe ont besoin du réseau pour l’action déclarée.", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index 95ae6d8f..a5fd88a6 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -169,6 +169,10 @@ "hosts_label": "ホスト", "purpose_label": "目的", "data_sent_label": "送信されるデータ", + "confirm_title": "外部リクエストを確認", + "confirm_checkbox": "この操作により、開示された外部アセットがブラウザからリクエストされる可能性があることを理解しています。", + "confirm_rights": "所有している、使用許可がある、またはプラットフォーム規約で許可されたアセットだけを使用してください。", + "confirm_required": "プレビューまたはダウンロードの前に外部リクエストを確認してください。", "purposes": { "authorized_media_download": "利用権限を確認した後、入力した URL からメディアをダウンロードします。", "thumbnail_preview": "入力した動画リンクから公開サムネイル画像 URL を生成してプレビューします。" @@ -1051,7 +1055,10 @@ "capture_groups_label": "キャプチャグループ", "group_prefix": "グループ", "error_invalid_regex": "無効な正規表現", - "error_match_limit": "一致数が上限を超えました。空文字列への無限一致が発生している可能性があります。" + "error_match_limit": "一致数が上限を超えました。空文字列への無限一致が発生している可能性があります。", + "timing_label": "{ms} ミリ秒", + "evaluating_label": "評価中...", + "performance_warning_title": "パフォーマンス警告" }, "regex_generator": { "title": "正規表現ジェネレーター", @@ -2387,10 +2394,14 @@ "option_awsAccessKeys": "AWS アクセスキー", "option_privateKeys": "秘密鍵ブロック", "option_urlCredentials": "URL 認証情報", + "option_cookies": "Cookie ヘッダー", + "option_sessionIds": "セッション ID とリクエスト ID", "scrub_action": "ログをスクラブ", "scrubbed": "{count} 件を伏せました", "redactions": "伏せ字数", "types": "種類", + "summary_title": "伏せ字処理の概要", + "manual_review_note": "自動伏せ字処理は安全層であり、完全性を保証するものではありません。共有前に処理後の出力を確認してください。", "findings_title": "検出された機密値", "table_type": "種類", "table_position": "元の行:列", @@ -2510,6 +2521,8 @@ "option_responseContent": "レスポンス内容", "requests": "リクエスト", "redactions": "マスク数", + "summary_title": "サニタイズ概要", + "review_warning": "デフォルトのエクスポートではリスクの高い HAR フィールドを伏せます。共有前にサニタイズ済み HAR を確認してください。", "entries_title": "HAR エントリ", "method": "メソッド", "status": "ステータス", @@ -2871,11 +2884,14 @@ "contact_issues_desc": "バグ報告や機能要望は GitHub Issue でお知らせください。", "contact_email_title": "メール", "contact_email_desc": "ビジネスに関するお問い合わせや提携のご相談はこちら。", + "contact_security_title": "セキュリティ脆弱性", + "contact_security_desc": "疑わしい脆弱性は GitHub Security Advisories で報告してください。公開 issue に秘密情報や非公開 payload を含めないでください。", "privacy_external_request_tools_title": "外部リクエストツール", "privacy_external_request_tools_desc": "この一覧はツール manifest から自動生成されます。これらのツールは、表示された目的のために、外部リクエスト操作を選んだ後だけ記載ドメインへ接続します。", "trust_center_title": "プライバシーと信頼センター", "trust_center_intro": "byteflow.tools がブラウザー内処理、外部リクエスト、機密入力、保存、分析、PWA キャッシュ、セキュリティヘッダー、脆弱性報告をどう表示するかを確認できます。", "trust_center_source_link": "GitHub でソースを見る", + "trust_center_securitytxt_link": "security.txt を表示", "trust_center_summary_local_title": "ローカル優先の実行環境", "trust_center_summary_local_desc": "ほとんどのツールは入力と出力をブラウザー内で処理します。外部リクエストが必要なツールは通信前に表示されます。", "trust_center_summary_network_title": "ネットワークの透明性", @@ -2910,7 +2926,7 @@ "trust_center_xss_title": "ユーザー内容のレンダリング", "trust_center_xss_desc": "Markdown、HTML、SVG、メタデータをプレビューするツールは、ユーザー制御のマークアップをサニタイズし、プレビューのために CSP を緩めない方針です。", "trust_center_vulnerability_title": "脆弱性報告", - "trust_center_vulnerability_desc": "疑わしい脆弱性は GitHub Security Advisories またはリポジトリの issue 手順で報告してください。公開報告に本番秘密情報や非公開 payload を含めないでください。", + "trust_center_vulnerability_desc": "疑わしい脆弱性は非公開の GitHub Security Advisories で報告してください。公開 issue はセキュリティ以外のバグや機能要望に限り、公開報告に本番秘密情報や非公開 payload を含めないでください。", "trust_center_faq_title": "信頼センター FAQ", "trust_center_faq_q1": "すべてのツールは完全にオフラインで動きますか?", "trust_center_faq_a1": "いいえ。ブラウザー内ツールはアセットのキャッシュ後にオフライン継続できますが、外部リクエスト表示のツールは表示された操作にネットワークが必要です。", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index b101b2f6..27ed6a29 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -169,6 +169,10 @@ "hosts_label": "호스트", "purpose_label": "목적", "data_sent_label": "전송되는 데이터", + "confirm_title": "외부 요청 확인", + "confirm_checkbox": "이 작업이 내 브라우저에서 공개된 외부 자산을 요청할 수 있음을 이해합니다.", + "confirm_rights": "소유했거나 사용 권한이 있거나 플랫폼 약관상 접근이 허용된 자산만 사용하세요.", + "confirm_required": "미리보기 또는 다운로드 전에 외부 요청을 확인하세요.", "purposes": { "authorized_media_download": "사용 권한을 확인한 뒤 사용자가 제공한 URL에서 미디어를 다운로드합니다.", "thumbnail_preview": "입력한 동영상 링크에서 공개 썸네일 이미지 URL을 생성하고 미리 봅니다." @@ -1051,7 +1055,10 @@ "capture_groups_label": "캡처 그룹", "group_prefix": "그룹", "error_invalid_regex": "잘못된 정규식", - "error_match_limit": "일치 개수가 한도를 초과했습니다. 빈 문자열을 무한히 매칭하고 있을 수 있습니다." + "error_match_limit": "일치 개수가 한도를 초과했습니다. 빈 문자열을 무한히 매칭하고 있을 수 있습니다.", + "timing_label": "시간: {ms} ms", + "evaluating_label": "평가 중...", + "performance_warning_title": "성능 경고" }, "regex_generator": { "title": "정규식 생성기", @@ -2387,10 +2394,14 @@ "option_awsAccessKeys": "AWS 액세스 키", "option_privateKeys": "개인 키 블록", "option_urlCredentials": "URL 자격 증명", + "option_cookies": "Cookie 헤더", + "option_sessionIds": "세션 및 요청 ID", "scrub_action": "로그 가리기", "scrubbed": "{count}개 항목을 가렸습니다", "redactions": "가림 수", "types": "유형 수", + "summary_title": "가림 처리 요약", + "manual_review_note": "자동 가림 처리는 안전 계층일 뿐 완전성을 보장하지 않습니다. 공유 전에 처리된 출력을 직접 확인하세요.", "findings_title": "감지된 민감 값", "table_type": "유형", "table_position": "원본 줄:열", @@ -2510,6 +2521,8 @@ "option_responseContent": "응답 내용", "requests": "요청", "redactions": "마스킹", + "summary_title": "정리 요약", + "review_warning": "기본 내보내기는 위험한 HAR 필드를 가립니다. 공유 전에 정리된 HAR을 확인하세요.", "entries_title": "HAR 항목", "method": "메서드", "status": "상태", @@ -2871,11 +2884,14 @@ "contact_issues_desc": "버그 신고나 기능 요청은 GitHub Issue로 등록해 주세요.", "contact_email_title": "이메일", "contact_email_desc": "비즈니스 문의나 파트너십 제안은 이메일로 연락해 주세요.", + "contact_security_title": "보안 취약점", + "contact_security_desc": "의심되는 취약점은 GitHub Security Advisories로 신고하세요. 공개 issue에는 비밀값이나 비공개 payload를 포함하지 마세요.", "privacy_external_request_tools_title": "외부 요청 도구", "privacy_external_request_tools_desc": "이 목록은 도구 manifest에서 자동 생성됩니다. 이러한 도구는 공개된 목적에 한해 나열된 도메인에 접근하며, 외부 요청 작업을 선택한 후에만 실행됩니다.", "trust_center_title": "개인정보 및 신뢰 센터", "trust_center_intro": "byteflow.tools가 브라우저 로컬 도구, 외부 요청, 민감한 입력, 저장소, 분석, PWA 캐시, 보안 헤더, 취약점 신고를 어떻게 표시하는지 설명합니다.", "trust_center_source_link": "GitHub에서 소스 보기", + "trust_center_securitytxt_link": "security.txt 보기", "trust_center_summary_local_title": "로컬 우선 실행 환경", "trust_center_summary_local_desc": "대부분의 도구는 입력과 출력을 브라우저 안에서 처리합니다. 외부 요청이 필요한 도구는 네트워크 접근 전에 표시됩니다.", "trust_center_summary_network_title": "네트워크 투명성", @@ -2910,7 +2926,7 @@ "trust_center_xss_title": "사용자 콘텐츠 렌더링", "trust_center_xss_desc": "Markdown, HTML, SVG, 메타데이터를 미리 보는 도구는 사용자 제어 마크업을 sanitize해야 하며, 미리 보기를 위해 CSP를 완화하지 않아야 합니다.", "trust_center_vulnerability_title": "취약점 신고", - "trust_center_vulnerability_desc": "의심되는 취약점은 GitHub Security Advisories 또는 저장소 issue 절차로 신고하세요. 공개 신고에는 운영 비밀값이나 비공개 payload를 포함하지 마세요.", + "trust_center_vulnerability_desc": "의심되는 취약점은 비공개 GitHub Security Advisories로 신고하세요. 공개 issue는 보안과 관련 없는 버그나 기능 요청에만 사용하고, 공개 신고에는 운영 비밀값이나 비공개 payload를 포함하지 마세요.", "trust_center_faq_title": "신뢰 센터 FAQ", "trust_center_faq_q1": "모든 도구가 완전히 오프라인으로 작동하나요?", "trust_center_faq_a1": "아니요. 브라우저 로컬 도구는 자산이 캐시된 뒤 오프라인으로 계속 사용할 수 있지만, 외부 요청 표시 도구는 공개된 작업에 네트워크가 필요합니다.", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index ff5b2fe5..baf9501a 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -169,6 +169,10 @@ "hosts_label": "主机", "purpose_label": "用途", "data_sent_label": "发送的数据", + "confirm_title": "确认外部请求", + "confirm_checkbox": "我理解此操作可能会从我的浏览器请求已披露的外部资源。", + "confirm_rights": "仅使用你拥有、获授权使用,或平台条款允许访问的资源。", + "confirm_required": "请先确认外部请求,再预览或下载。", "purposes": { "authorized_media_download": "在你确认有权使用后,从你提供的 URL 下载媒体。", "thumbnail_preview": "根据你输入的视频链接生成并预览公开缩略图地址。" @@ -1051,7 +1055,10 @@ "capture_groups_label": "捕获组", "group_prefix": "分组", "error_invalid_regex": "正则表达式无效", - "error_match_limit": "匹配数量超限,正则可能正在无限匹配空字符串。" + "error_match_limit": "匹配数量超限,正则可能正在无限匹配空字符串。", + "timing_label": "{ms} 毫秒", + "evaluating_label": "正在评估...", + "performance_warning_title": "性能警告" }, "regex_generator": { "title": "正则表达式生成器", @@ -2387,10 +2394,14 @@ "option_awsAccessKeys": "AWS Access Key", "option_privateKeys": "私钥块", "option_urlCredentials": "URL 凭据", + "option_cookies": "Cookie 头", + "option_sessionIds": "会话和请求 ID", "scrub_action": "脱敏日志", "scrubbed": "已脱敏 {count} 项", "redactions": "脱敏数", "types": "类型数", + "summary_title": "脱敏摘要", + "manual_review_note": "自动脱敏只是安全层,并不能保证完整。分享前请人工检查清理后的输出。", "findings_title": "检测到的敏感值", "table_type": "类型", "table_position": "原始行:列", @@ -2510,6 +2521,8 @@ "option_responseContent": "响应内容", "requests": "请求数", "redactions": "脱敏项", + "summary_title": "清理摘要", + "review_warning": "默认导出会脱敏高风险 HAR 字段。分享前请检查清理后的 HAR。", "entries_title": "HAR 条目", "method": "方法", "status": "状态", @@ -2871,11 +2884,14 @@ "contact_issues_desc": "如需反馈 bug 或功能建议,请在 GitHub 提交 issue。", "contact_email_title": "邮箱", "contact_email_desc": "商务合作或伙伴关系咨询,请通过邮件联系。", + "contact_security_title": "安全漏洞", + "contact_security_desc": "请通过 GitHub Security Advisories 报告疑似漏洞。不要在公开 issue 中包含密钥或私有 payload。", "privacy_external_request_tools_title": "外部请求工具", "privacy_external_request_tools_desc": "此清单由工具 manifest 自动生成。这些工具只会为声明用途访问列出的域名,并且只会在你选择外部请求操作后执行。", "trust_center_title": "隐私与信任中心", "trust_center_intro": "了解 byteflow.tools 如何标记浏览器本地工具、外部请求、敏感输入、存储、分析、PWA 缓存、安全响应头和漏洞报告流程。", "trust_center_source_link": "在 GitHub 查看源码", + "trust_center_securitytxt_link": "查看 security.txt", "trust_center_summary_local_title": "本地优先运行方式", "trust_center_summary_local_desc": "大多数工具在浏览器中处理输入和输出。需要外部请求的工具会在联网前明确标记。", "trust_center_summary_network_title": "网络访问透明", @@ -2910,7 +2926,7 @@ "trust_center_xss_title": "用户内容渲染", "trust_center_xss_desc": "预览 Markdown、HTML、SVG 或元数据的工具应清理用户可控标记,并且不能为了预览功能放宽 CSP。", "trust_center_vulnerability_title": "漏洞报告", - "trust_center_vulnerability_desc": "请通过 GitHub Security Advisories 或仓库 issue 流程报告疑似漏洞。不要在公开报告中包含生产密钥或私有 payload。", + "trust_center_vulnerability_desc": "请通过私有 GitHub Security Advisories 报告疑似漏洞。公开 issue 仅用于非安全 bug 或功能请求,且不要在公开报告中包含生产密钥或私有 payload。", "trust_center_faq_title": "信任中心常见问题", "trust_center_faq_q1": "所有工具都能完全离线运行吗?", "trust_center_faq_a1": "不是。浏览器本地工具在资源缓存后可继续离线使用,但标记为外部请求的工具需要网络来完成已披露的操作。", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index cdd018d0..5ceff950 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -169,6 +169,10 @@ "hosts_label": "主機", "purpose_label": "用途", "data_sent_label": "傳送的資料", + "confirm_title": "確認外部請求", + "confirm_checkbox": "我了解此操作可能會從我的瀏覽器請求已揭露的外部資源。", + "confirm_rights": "僅使用你擁有、已獲授權使用,或平台條款允許存取的資源。", + "confirm_required": "請先確認外部請求,再預覽或下載。", "purposes": { "authorized_media_download": "在你確認有權使用後,從你提供的 URL 下載媒體。", "thumbnail_preview": "根據你輸入的影片連結產生並預覽公開縮圖網址。" @@ -1051,7 +1055,10 @@ "capture_groups_label": "擷取群組", "group_prefix": "群組", "error_invalid_regex": "正則表達式無效", - "error_match_limit": "比對數量超出上限,正則可能正在無限比對空字串。" + "error_match_limit": "比對數量超出上限,正則可能正在無限比對空字串。", + "timing_label": "{ms} 毫秒", + "evaluating_label": "正在評估...", + "performance_warning_title": "效能警告" }, "regex_generator": { "title": "正規表達式產生器", @@ -2387,10 +2394,14 @@ "option_awsAccessKeys": "AWS 存取金鑰", "option_privateKeys": "私鑰區塊", "option_urlCredentials": "URL 憑證", + "option_cookies": "Cookie 標頭", + "option_sessionIds": "工作階段與請求 ID", "scrub_action": "脫敏日誌", "scrubbed": "已脫敏 {count} 項", "redactions": "脫敏數", "types": "類型數", + "summary_title": "脫敏摘要", + "manual_review_note": "自動脫敏只是安全層,無法保證完整。分享前請人工檢查清理後的輸出。", "findings_title": "偵測到的敏感值", "table_type": "類型", "table_position": "原始行:欄", @@ -2510,6 +2521,8 @@ "option_responseContent": "回應內容", "requests": "請求數", "redactions": "脫敏項", + "summary_title": "清理摘要", + "review_warning": "預設匯出會脫敏高風險 HAR 欄位。分享前請檢查清理後的 HAR。", "entries_title": "HAR 條目", "method": "方法", "status": "狀態", @@ -2871,11 +2884,14 @@ "contact_issues_desc": "若要回報 bug 或提出功能建議,請在 GitHub 提交 issue。", "contact_email_title": "電子郵件", "contact_email_desc": "商務合作或夥伴關係洽詢,請透過郵件聯繫。", + "contact_security_title": "安全漏洞", + "contact_security_desc": "請透過 GitHub Security Advisories 回報疑似漏洞。不要在公開 issue 中包含金鑰或私有 payload。", "privacy_external_request_tools_title": "外部請求工具", "privacy_external_request_tools_desc": "此清單由工具 manifest 自動產生。這些工具只會為聲明用途存取列出的網域,且只會在你選擇外部請求操作後執行。", "trust_center_title": "隱私與信任中心", "trust_center_intro": "了解 byteflow.tools 如何標示瀏覽器本地工具、外部請求、敏感輸入、儲存、分析、PWA 快取、安全回應標頭和漏洞回報流程。", "trust_center_source_link": "在 GitHub 查看原始碼", + "trust_center_securitytxt_link": "查看 security.txt", "trust_center_summary_local_title": "本地優先執行方式", "trust_center_summary_local_desc": "大多數工具在瀏覽器中處理輸入和輸出。需要外部請求的工具會在連線前明確標示。", "trust_center_summary_network_title": "網路存取透明", @@ -2910,7 +2926,7 @@ "trust_center_xss_title": "使用者內容渲染", "trust_center_xss_desc": "預覽 Markdown、HTML、SVG 或中繼資料的工具應清理使用者可控標記,且不能為了預覽功能放寬 CSP。", "trust_center_vulnerability_title": "漏洞回報", - "trust_center_vulnerability_desc": "請透過 GitHub Security Advisories 或倉庫 issue 流程回報疑似漏洞。不要在公開回報中包含正式金鑰或私有 payload。", + "trust_center_vulnerability_desc": "請透過私有 GitHub Security Advisories 回報疑似漏洞。公開 issue 僅用於非安全 bug 或功能請求,且不要在公開回報中包含正式金鑰或私有 payload。", "trust_center_faq_title": "信任中心常見問題", "trust_center_faq_q1": "所有工具都能完全離線執行嗎?", "trust_center_faq_a1": "不是。瀏覽器本地工具在資源快取後可繼續離線使用,但標示為外部請求的工具需要網路來完成已披露的操作。", diff --git a/src/core/security/sanitize.ts b/src/core/security/sanitize.ts index ccce52f3..90b0d727 100644 --- a/src/core/security/sanitize.ts +++ b/src/core/security/sanitize.ts @@ -41,11 +41,24 @@ const ACTIVE_EVENT_ATTRIBUTES = [ ] as const export function sanitizeHtml(html: string): string { - return DOMPurify.sanitize(html, { + const sanitized = DOMPurify.sanitize(html, { USE_PROFILES: { html: true }, FORBID_TAGS: [...ACTIVE_HTML_TAGS, "svg", "math"], FORBID_ATTR: [...ACTIVE_EVENT_ATTRIBUTES, "style"], - }).trim() + }) + + if (typeof document === "undefined") return sanitized.trim() + + const template = document.createElement("template") + template.innerHTML = sanitized + for (const image of Array.from(template.content.querySelectorAll("img"))) { + const src = image.getAttribute("src") ?? "" + if (!isSafeMarkdownImageSrc(src)) { + image.removeAttribute("src") + } + } + + return template.innerHTML.trim() } export function sanitizeMarkdownHtml(html: string): string { diff --git a/src/core/utils/log-scrubber-utils.ts b/src/core/utils/log-scrubber-utils.ts index b2d97067..538955ad 100644 --- a/src/core/utils/log-scrubber-utils.ts +++ b/src/core/utils/log-scrubber-utils.ts @@ -1,12 +1,18 @@ export type ScrubFindingType = | "email" | "ipv4" + | "ipv6" | "jwt" | "bearer-token" | "api-key" | "aws-access-key" + | "aws-secret-key" + | "cloud-token" | "private-key" + | "certificate" | "url-credential" + | "cookie" + | "session-id" export interface ScrubOptions { emails: boolean @@ -17,6 +23,8 @@ export interface ScrubOptions { awsAccessKeys: boolean privateKeys: boolean urlCredentials: boolean + cookies: boolean + sessionIds: boolean } export interface ScrubFinding { @@ -45,6 +53,8 @@ export const DEFAULT_SCRUB_OPTIONS: ScrubOptions = { awsAccessKeys: true, privateKeys: true, urlCredentials: true, + cookies: true, + sessionIds: true, } type ScrubRule = { @@ -72,6 +82,13 @@ const RULES: ScrubRule[] = [ replacement: "[PRIVATE_KEY_REDACTED]", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, }, + { + enabled: "privateKeys", + type: "certificate", + label: "Certificate block", + replacement: "[CERTIFICATE_REDACTED]", + pattern: /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g, + }, { enabled: "urlCredentials", type: "url-credential", @@ -101,14 +118,45 @@ const RULES: ScrubRule[] = [ replacement: "[AWS_ACCESS_KEY_REDACTED]", pattern: /\b(A3T[A-Z0-9]|AKIA|ASIA|AGPA|AIDA|AROA|AIPA|ANPA)[A-Z0-9]{16}\b/g, }, + { + enabled: "awsAccessKeys", + type: "aws-secret-key", + label: "AWS secret key", + replacement: "$1[SECRET_REDACTED]", + pattern: /\b((?:aws[_-]?secret[_-]?access[_-]?key|AWS_SECRET_ACCESS_KEY)\s*[:=]\s*)(["']?)[A-Za-z0-9/+=]{32,}(\2)/g, + replace: (match) => match.replace(/(:|=)\s*(["']?)[A-Za-z0-9/+=]{32,}(\2)$/i, "$1 $2[SECRET_REDACTED]$3"), + }, + { + enabled: "apiKeys", + type: "cloud-token", + label: "Cloud or SaaS token", + replacement: "[TOKEN_REDACTED]", + pattern: /\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|sk_(?:live|test)_[A-Za-z0-9]{16,}|SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}|AIza[0-9A-Za-z_-]{35}|xox[baprs]-[A-Za-z0-9-]{20,})\b/g, + }, { enabled: "apiKeys", type: "api-key", label: "Key/value secret", replacement: "$1[SECRET_REDACTED]", - pattern: /\b((?:api[_-]?key|access[_-]?token|auth[_-]?token|secret|password|passwd|pwd|client[_-]?secret)\s*[:=]\s*)(["']?)[^\s"',;]+(\2)/gi, + pattern: /\b((?:api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|id[_-]?token|secret|password|passwd|pwd|client[_-]?secret|private[_-]?token)\s*[:=]\s*)(["']?)[^\s"',;]+(\2)/gi, replace: (match) => match.replace(/(:|=)\s*(["']?)[^\s"',;]+(\2)$/i, "$1 $2[SECRET_REDACTED]$3"), }, + { + enabled: "cookies", + type: "cookie", + label: "Cookie header", + replacement: "$1[COOKIE_REDACTED]", + pattern: /\b((?:cookie|set-cookie)\s*:\s*)[^\r\n]+/gi, + replace: (match) => match.replace(/(:\s*)[^\r\n]+$/i, "$1[COOKIE_REDACTED]"), + }, + { + enabled: "sessionIds", + type: "session-id", + label: "Session identifier", + replacement: "$1[SESSION_REDACTED]", + pattern: /\b((?:session[_-]?id|sid|xsrf[_-]?token|csrf[_-]?token|trace[_-]?id|request[_-]?id)\s*[:=]\s*)(["']?)[A-Za-z0-9._~+/=-]{8,}(\2)/gi, + replace: (match) => match.replace(/(:|=)\s*(["']?)[A-Za-z0-9._~+/=-]{8,}(\2)$/i, "$1 $2[SESSION_REDACTED]$3"), + }, { enabled: "emails", type: "email", @@ -123,6 +171,13 @@ const RULES: ScrubRule[] = [ replacement: "[IP_REDACTED]", pattern: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, }, + { + enabled: "ipAddresses", + type: "ipv6", + label: "IPv6 address", + replacement: "[IP_REDACTED]", + pattern: /\b(?:[A-F0-9]{1,4}:){2,7}[A-F0-9]{1,4}\b/gi, + }, ] function getLineColumn(text: string, index: number): { line: number; column: number } { diff --git a/src/features/tool-shell/external-request-confirmation.tsx b/src/features/tool-shell/external-request-confirmation.tsx new file mode 100644 index 00000000..c6d90f12 --- /dev/null +++ b/src/features/tool-shell/external-request-confirmation.tsx @@ -0,0 +1,69 @@ +"use client" + +import { ExternalLink, ShieldCheck } from "lucide-react" +import type { ToolExternalDataSent } from "@/core/registry/types" +import { requireTranslationValue } from "@/core/i18n/i18n" +import { useLang } from "@/core/i18n/lang-provider" +import { Checkbox } from "@/components/ui/checkbox" + +type ExternalRequestConfirmationProps = { + hosts: readonly string[] + purposeKey: string + dataSent: ToolExternalDataSent + confirmed: boolean + onConfirmedChange: (confirmed: boolean) => void + rightsGuidance?: string +} + +export function ExternalRequestConfirmation({ + hosts, + purposeKey, + dataSent, + confirmed, + onConfirmedChange, + rightsGuidance, +}: ExternalRequestConfirmationProps) { + const { t } = useLang() + const labels = t.common.external_network_notice + const purpose = labels.purposes?.[purposeKey as keyof typeof labels.purposes] + const dataSentLabel = labels.external_data?.[dataSent as keyof typeof labels.external_data] + + return ( +
+
+
+
+ ) +} diff --git a/src/features/tools/har-viewer-sanitizer/page.tsx b/src/features/tools/har-viewer-sanitizer/page.tsx index 34513be4..bf53a04d 100644 --- a/src/features/tools/har-viewer-sanitizer/page.tsx +++ b/src/features/tools/har-viewer-sanitizer/page.tsx @@ -140,6 +140,22 @@ export function HarViewerSanitizerPage() {
{text("requests")}
{summary?.totalRequests ?? 0}
{text("redactions")}
{sanitized?.redactionCount ?? 0}
+
+ {text("review_warning")} +
+ {sanitized && Object.keys(sanitized.summary).length > 0 && ( +
+

{text("summary_title")}

+ +
+ )} diff --git a/src/features/tools/har-viewer-sanitizer/utils.ts b/src/features/tools/har-viewer-sanitizer/utils.ts index f48ea0b2..1acbb100 100644 --- a/src/features/tools/har-viewer-sanitizer/utils.ts +++ b/src/features/tools/har-viewer-sanitizer/utils.ts @@ -36,6 +36,7 @@ export interface HarSanitizeResult { output: string findings: HarFinding[] redactionCount: number + summary: Partial> error?: string } @@ -55,6 +56,8 @@ const SENSITIVE_HEADER_NAMES = new Set([ "x-api-key", "x-auth-token", "x-csrf-token", + "x-xsrf-token", + "x-amz-security-token", ]) type HarNameValue = { @@ -88,6 +91,7 @@ type HarEntryRecord = { type HarFileRecord = { log: { + _byteflowSanitizerSummary?: unknown entries: HarEntryRecord[] } } @@ -210,6 +214,13 @@ function sanitizeUrl(rawUrl: string, path: string, findings: HarFinding[]): stri } } +function summarizeHarFindings(findings: HarFinding[]): Partial> { + return findings.reduce((summary, finding) => { + summary[finding.type] = (summary[finding.type] || 0) + 1 + return summary + }, {} as Partial>) +} + export function sanitizeHar(input: string, options: HarSanitizeOptions = DEFAULT_HAR_SANITIZE_OPTIONS): HarSanitizeResult { try { const har = parseHar(input) @@ -238,17 +249,33 @@ export function sanitizeHar(input: string, options: HarSanitizeOptions = DEFAULT findings.push({ path: `${base}.response.content.text`, type: "content", label: "response content" }) } }) + const summary = summarizeHarFindings(findings) + har.log._byteflowSanitizerSummary = { + generatedBy: "byteflow.tools HAR Viewer / Sanitizer", + redactionCount: findings.length, + categories: summary, + defaults: { + headers: DEFAULT_HAR_SANITIZE_OPTIONS.headers, + cookies: DEFAULT_HAR_SANITIZE_OPTIONS.cookies, + queryStrings: DEFAULT_HAR_SANITIZE_OPTIONS.queryStrings, + postData: DEFAULT_HAR_SANITIZE_OPTIONS.postData, + responseContent: DEFAULT_HAR_SANITIZE_OPTIONS.responseContent, + }, + reviewRequired: true, + } return { output: JSON.stringify(har, null, 2), findings, redactionCount: findings.length, + summary, } } catch (error) { return { output: "", findings: [], redactionCount: 0, + summary: {}, error: error instanceof Error ? error.message : "Unable to sanitize HAR.", } } diff --git a/src/features/tools/html-to-markdown/utils.ts b/src/features/tools/html-to-markdown/utils.ts index e809ab9e..378ed1b2 100644 --- a/src/features/tools/html-to-markdown/utils.ts +++ b/src/features/tools/html-to-markdown/utils.ts @@ -1,4 +1,5 @@ import TurndownService from "turndown" +import { sanitizeHtml } from "@/core/security/sanitize" const turndown = new TurndownService({ headingStyle: "atx", @@ -18,6 +19,6 @@ export function convertHtmlToMarkdown(html: string): string { const normalized = html.trim() if (!normalized) return "" - const markdown = turndown.turndown(normalized) + const markdown = turndown.turndown(sanitizeHtml(normalized)) return markdown.replace(/\n{3,}/g, "\n\n").trim() } diff --git a/src/features/tools/instagram-photo-downloader/page.tsx b/src/features/tools/instagram-photo-downloader/page.tsx index e8c50c0d..a9905c87 100644 --- a/src/features/tools/instagram-photo-downloader/page.tsx +++ b/src/features/tools/instagram-photo-downloader/page.tsx @@ -7,6 +7,7 @@ import { useLang } from "@/core/i18n/lang-provider" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" +import { ExternalRequestConfirmation } from "@/features/tool-shell/external-request-confirmation" import { ToolPreviewArea } from "@/features/tool-shell/tool-preview-area" import { safeClipboardWrite } from "@/core/clipboard/clipboard" import { openExternalUrl } from "@/core/security/external-url" @@ -17,6 +18,7 @@ import { } from "@/core/utils/instagram-tool-utils" const SAMPLE_URL = "https://www.instagram.com/p/C5M0YfJt5gX/" +const EXTERNAL_HOSTS = ["instagram.com"] as const export function InstagramPhotoDownloaderPage() { const { t } = useLang() @@ -27,6 +29,7 @@ export function InstagramPhotoDownloaderPage() { const [rightsConfirmed, setRightsConfirmed] = React.useState(false) const [statusNote, setStatusNote] = React.useState("") const [previewApproved, setPreviewApproved] = React.useState(false) + const [externalRequestConfirmed, setExternalRequestConfirmed] = React.useState(false) const statusReadyLine = text("status_ready_line") const statusPendingLine = text("status_pending_line") @@ -127,6 +130,7 @@ export function InstagramPhotoDownloaderPage() { setInputUrl(SAMPLE_URL) setRightsConfirmed(false) setPreviewApproved(false) + setExternalRequestConfirmed(false) } const handleReset = () => { @@ -134,6 +138,7 @@ export function InstagramPhotoDownloaderPage() { setRightsConfirmed(false) setStatusNote("") setPreviewApproved(false) + setExternalRequestConfirmed(false) } const handleLoadPreview = () => { @@ -141,6 +146,10 @@ export function InstagramPhotoDownloaderPage() { toast.error(t.common.download_blocked_until_checks_pass) return } + if (!externalRequestConfirmed) { + toast.error(t.common.external_network_notice.confirm_required) + return + } setPreviewApproved(true) } @@ -158,6 +167,10 @@ export function InstagramPhotoDownloaderPage() { toast.error(t.common.download_blocked_until_checks_pass) return } + if (!externalRequestConfirmed) { + toast.error(t.common.external_network_notice.confirm_required) + return + } const filename = getInstagramMediaFilename(parsed.normalizedUrl) @@ -184,9 +197,27 @@ export function InstagramPhotoDownloaderPage() { const actions: ToolAction[] = [ { id: "sample", label: t.common.sample, icon: TestTube2, onClick: handleSample }, { id: "reset", label: t.common.reset, icon: Eraser, onClick: handleReset }, - { id: "preview", label: t.common.preview, icon: ImageDown, onClick: handleLoadPreview, disabled: !canDownload }, + { + id: "preview", + label: t.common.preview, + icon: ImageDown, + onClick: handleLoadPreview, + disabled: !canDownload || !externalRequestConfirmed, + disabledReason: !canDownload + ? t.common.download_blocked_until_checks_pass + : t.common.external_network_notice.confirm_required, + }, { id: "copy", label: t.common.copy, icon: Copy, onClick: () => void handleCopy() }, - { id: "download", label: t.common.download, icon: Download, onClick: () => void handleDownload(), disabled: !canDownload }, + { + id: "download", + label: t.common.download, + icon: Download, + onClick: () => void handleDownload(), + disabled: !canDownload || !externalRequestConfirmed, + disabledReason: !canDownload + ? t.common.download_blocked_until_checks_pass + : t.common.external_network_notice.confirm_required, + }, ] return ( @@ -214,6 +245,7 @@ export function InstagramPhotoDownloaderPage() { onChange={(event) => { setInputUrl(event.target.value) setPreviewApproved(false) + setExternalRequestConfirmed(false) }} placeholder="https://…" spellCheck={false} @@ -225,6 +257,7 @@ export function InstagramPhotoDownloaderPage() { onChange={(event) => { setRightsConfirmed(event.target.checked) setPreviewApproved(false) + setExternalRequestConfirmed(false) }} className="mt-0.5 h-4 w-4" /> @@ -238,6 +271,17 @@ export function InstagramPhotoDownloaderPage() {
{t.common.compliance_guidance}
+ { + setExternalRequestConfirmed(confirmed) + if (!confirmed) setPreviewApproved(false) + }} + rightsGuidance={text("compliance_notice")} + />
{text("compliance_notice")}
diff --git a/src/features/tools/log-scrubber/page.tsx b/src/features/tools/log-scrubber/page.tsx index 7367203a..5f96b479 100644 --- a/src/features/tools/log-scrubber/page.tsx +++ b/src/features/tools/log-scrubber/page.tsx @@ -31,6 +31,8 @@ const OPTION_KEYS: OptionKey[] = [ "awsAccessKeys", "privateKeys", "urlCredentials", + "cookies", + "sessionIds", ] export function LogScrubberPage() { @@ -180,6 +182,22 @@ password=hunter2`)
{Object.keys(summary).length}
+
+ {text("manual_review_note")} +
+ {Object.keys(summary).length > 0 && ( +
+

{text("summary_title")}

+
    + {Object.entries(summary).map(([type, count]) => ( +
  • + {type} + {count} +
  • + ))} +
+
+ )} diff --git a/src/features/tools/regex-tester/page.tsx b/src/features/tools/regex-tester/page.tsx index ca9113ee..724332fb 100644 --- a/src/features/tools/regex-tester/page.tsx +++ b/src/features/tools/regex-tester/page.tsx @@ -1,12 +1,13 @@ "use client" import * as React from "react" -import { Regex, Eraser, TestTube2 } from "lucide-react" +import { Regex, Eraser, TestTube2, Clock3, AlertTriangle } from "lucide-react" import { useLang } from "@/core/i18n/lang-provider" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" -import { testRegexPattern, type RegexMatchSummary } from "./utils" +import { runRegexTestTask } from "./regex-test-task" +import { type RegexMatchSummary } from "./utils" const SAMPLE_PATTERN = "[A-Z][a-z]+" const SAMPLE_FLAGS = "g" @@ -20,21 +21,58 @@ export function RegexTesterPage() { const [testString, setTestString] = React.useState(SAMPLE_TEST_STRING) const [matches, setMatches] = React.useState([]) const [error, setError] = React.useState(null) + const [warnings, setWarnings] = React.useState([]) + const [elapsedMs, setElapsedMs] = React.useState(0) + const [isEvaluating, setIsEvaluating] = React.useState(false) + const evaluationAbortControllerRef = React.useRef(null) React.useEffect(() => { + evaluationAbortControllerRef.current?.abort() + if (!pattern) { setMatches([]) setError(null) + setWarnings([]) + setElapsedMs(0) + setIsEvaluating(false) return } - const result = testRegexPattern(pattern, flags, testString) - if (result.ok) { - setMatches(result.matches) - setError(result.limited ? toolT.error_match_limit : null) - } else { - setError(result.error || toolT.error_invalid_regex) - setMatches([]) + const controller = new AbortController() + evaluationAbortControllerRef.current = controller + setIsEvaluating(true) + + void runRegexTestTask(pattern, flags, testString, { + signal: controller.signal, + timeoutMs: 1_000, + }) + .then((result) => { + if (controller.signal.aborted) return + setElapsedMs(result.elapsedMs) + setWarnings(result.warnings) + if (result.ok) { + setMatches(result.matches) + setError(result.limited ? toolT.error_match_limit : null) + } else { + setError(result.error || toolT.error_invalid_regex) + setMatches([]) + } + }) + .catch((taskError) => { + if (controller.signal.aborted) return + setMatches([]) + setWarnings([]) + setElapsedMs(0) + setError(taskError instanceof Error ? taskError.message : toolT.error_invalid_regex) + }) + .finally(() => { + if (controller.signal.aborted) return + setIsEvaluating(false) + evaluationAbortControllerRef.current = null + }) + + return () => { + controller.abort() } }, [flags, pattern, testString, toolT.error_invalid_regex, toolT.error_match_limit]) @@ -44,6 +82,9 @@ export function RegexTesterPage() { setTestString("") setMatches([]) setError(null) + setWarnings([]) + setElapsedMs(0) + setIsEvaluating(false) } const handleSample = () => { @@ -140,15 +181,32 @@ export function RegexTesterPage() {
{toolT.match_results_label} - - {matches.length} + + + {isEvaluating ? toolT.evaluating_label : toolT.timing_label.replace("{ms}", elapsedMs.toFixed(1))} + + {matches.length} +
+ {warnings.length > 0 && ( +
+
+ + {toolT.performance_warning_title} +
+
    + {warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ )} {matches.length === 0 ? (
-

{error ? toolT.empty_state_error : toolT.empty_state_no_match}

+

{isEvaluating ? toolT.evaluating_label : error ? toolT.empty_state_error : toolT.empty_state_no_match}

) : ( matches.map((m, i) => ( diff --git a/src/features/tools/regex-tester/regex-test-task.ts b/src/features/tools/regex-tester/regex-test-task.ts new file mode 100644 index 00000000..a9e417ca --- /dev/null +++ b/src/features/tools/regex-tester/regex-test-task.ts @@ -0,0 +1,53 @@ +import { runWorkerTask, WorkerTaskError } from "@/core/workers/run-worker-task" +import { testRegexPattern, type RegexTestResult } from "./utils" + +type RegexTestWorkerInput = { + pattern: string + flags: string + testString: string + maxMatches?: number +} + +type RegexTestTaskOptions = { + signal?: AbortSignal + timeoutMs?: number + maxMatches?: number +} + +function timeoutResult(message: string): RegexTestResult { + return { + ok: false, + error: message, + matches: [], + limited: false, + elapsedMs: 0, + warnings: [message], + } +} + +export async function runRegexTestTask( + pattern: string, + flags: string, + testString: string, + options: RegexTestTaskOptions = {}, +): Promise { + if (typeof Worker === "undefined") { + return testRegexPattern(pattern, flags, testString, options.maxMatches) + } + + try { + return await runWorkerTask( + () => new Worker(new URL("./regex-test-worker.ts", import.meta.url), { type: "module" }), + { pattern, flags, testString, maxMatches: options.maxMatches }, + { signal: options.signal, timeoutMs: options.timeoutMs ?? 1_000 }, + ) + } catch (error) { + if (error instanceof WorkerTaskError && error.code === "WORKER_TIMEOUT") { + return timeoutResult("Regex evaluation was stopped after the safety timeout. Simplify the pattern or test a smaller input.") + } + if (error instanceof WorkerTaskError && error.code === "WORKER_ABORTED") { + throw error + } + return testRegexPattern(pattern, flags, testString, options.maxMatches) + } +} diff --git a/src/features/tools/regex-tester/regex-test-worker.ts b/src/features/tools/regex-tester/regex-test-worker.ts new file mode 100644 index 00000000..65c4c34d --- /dev/null +++ b/src/features/tools/regex-tester/regex-test-worker.ts @@ -0,0 +1,29 @@ +import { testRegexPattern } from "./utils" + +type RegexTestWorkerInput = { + pattern: string + flags: string + testString: string + maxMatches?: number +} + +self.onmessage = (event: MessageEvent) => { + const { pattern, flags, testString, maxMatches } = event.data + + try { + self.postMessage({ + ok: true, + value: testRegexPattern(pattern, flags, testString, maxMatches), + }) + } catch (error) { + self.postMessage({ + ok: false, + error: { + code: "REGEX_EVALUATION_FAILED", + message: error instanceof Error ? error.message : "Regex evaluation failed.", + }, + }) + } +} + +export {} diff --git a/src/features/tools/regex-tester/utils.ts b/src/features/tools/regex-tester/utils.ts index 45f6e3c6..c24b4b14 100644 --- a/src/features/tools/regex-tester/utils.ts +++ b/src/features/tools/regex-tester/utils.ts @@ -10,18 +10,30 @@ export type RegexTestResult = ok: true matches: RegexMatchSummary[] limited: boolean + elapsedMs: number + warnings: string[] } | { ok: false error: string matches: [] limited: false + elapsedMs: number + warnings: string[] } const MAX_PATTERN_LENGTH = 500 const MAX_TEST_STRING_LENGTH = 20_000 +const SLOW_EVALUATION_MS = 50 const NESTED_QUANTIFIER_RE = /\((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*\)\s*[+*{]/ const REPEATED_GROUP_WITH_RANGE_RE = /\((?:[^()\\]|\\.)*\{(?:\d+,?\d*|,\d+)\}(?:[^()\\]|\\.)*\)\s*[+*{]/ +const AMBIGUOUS_ALTERNATION_RE = /\((?:[^()\\]|\\.)*\|(?:[^()\\]|\\.)*\)\s*[+*{]/ + +function getNow(): number { + return typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : Date.now() +} export function assessRegexSafety(pattern: string, testString: string): string | null { if (pattern.length > MAX_PATTERN_LENGTH) { @@ -36,14 +48,40 @@ export function assessRegexSafety(pattern: string, testString: string): string | return null } +export function getRegexPerformanceWarnings(pattern: string, testString: string, elapsedMs = 0): string[] { + const warnings: string[] = [] + if (NESTED_QUANTIFIER_RE.test(pattern) || REPEATED_GROUP_WITH_RANGE_RE.test(pattern)) { + warnings.push("Pattern contains nested quantifiers that can cause catastrophic backtracking.") + } + if (AMBIGUOUS_ALTERNATION_RE.test(pattern)) { + warnings.push("Pattern contains repeated alternation that may backtrack on long input.") + } + if (testString.length > MAX_TEST_STRING_LENGTH * 0.75) { + warnings.push("Large test input increases regex evaluation cost.") + } + if (elapsedMs >= SLOW_EVALUATION_MS) { + warnings.push(`Evaluation took ${Math.round(elapsedMs)} ms. Consider simplifying the pattern or testing a smaller sample.`) + } + return warnings +} + export function testRegexPattern(pattern: string, flags: string, testString: string, maxMatches = 5_000): RegexTestResult { + const startedAt = getNow() if (!pattern || testString === "") { - return { ok: true, matches: [], limited: false } + return { ok: true, matches: [], limited: false, elapsedMs: 0, warnings: [] } } const safetyError = assessRegexSafety(pattern, testString) if (safetyError) { - return { ok: false, error: safetyError, matches: [], limited: false } + const elapsedMs = getNow() - startedAt + return { + ok: false, + error: safetyError, + matches: [], + limited: false, + elapsedMs, + warnings: getRegexPerformanceWarnings(pattern, testString, elapsedMs), + } } try { @@ -74,13 +112,23 @@ export function testRegexPattern(pattern: string, flags: string, testString: str } } - return { ok: true, matches, limited } + const elapsedMs = getNow() - startedAt + return { + ok: true, + matches, + limited, + elapsedMs, + warnings: getRegexPerformanceWarnings(pattern, testString, elapsedMs), + } } catch (error) { + const elapsedMs = getNow() - startedAt return { ok: false, error: error instanceof Error ? error.message : "Invalid regular expression.", matches: [], limited: false, + elapsedMs, + warnings: getRegexPerformanceWarnings(pattern, testString, elapsedMs), } } } diff --git a/src/features/tools/vimeo-thumbnail-grabber/page.tsx b/src/features/tools/vimeo-thumbnail-grabber/page.tsx index eff155ba..abffc9ed 100644 --- a/src/features/tools/vimeo-thumbnail-grabber/page.tsx +++ b/src/features/tools/vimeo-thumbnail-grabber/page.tsx @@ -7,6 +7,7 @@ import { useLang } from "@/core/i18n/lang-provider" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" +import { ExternalRequestConfirmation } from "@/features/tool-shell/external-request-confirmation" import { ToolPreviewArea } from "@/features/tool-shell/tool-preview-area" import { safeClipboardWrite } from "@/core/clipboard/clipboard" import { @@ -17,6 +18,7 @@ import { } from "@/core/utils/thumbnail-grabber-utils" const SAMPLE_URL = "https://vimeo.com/76979871" +const EXTERNAL_HOSTS = ["vimeo.com", "player.vimeo.com", "vumbnail.com"] as const export function VimeoThumbnailGrabberPage() { const { t } = useLang() @@ -40,6 +42,7 @@ export function VimeoThumbnailGrabberPage() { const [selectedUrl, setSelectedUrl] = React.useState("") const [status, setStatus] = React.useState(statusIdle) const [previewApproved, setPreviewApproved] = React.useState(false) + const [externalRequestConfirmed, setExternalRequestConfirmed] = React.useState(false) React.useEffect(() => { const id = parseVimeoVideoId(url) @@ -48,6 +51,7 @@ export function VimeoThumbnailGrabberPage() { setCandidates([]) setSelectedUrl("") setPreviewApproved(false) + setExternalRequestConfirmed(false) setStatus(url.trim() ? statusInvalid : statusIdle) return } @@ -57,6 +61,7 @@ export function VimeoThumbnailGrabberPage() { setCandidates(next) setSelectedUrl(next[0]?.url || "") setPreviewApproved(false) + setExternalRequestConfirmed(false) setStatus(statusReady) }, [statusAutoSelectedTemplate, statusIdle, statusInvalid, statusReady, statusUnreachable, url]) @@ -75,9 +80,16 @@ export function VimeoThumbnailGrabberPage() { ) const handleSample = () => setUrl(SAMPLE_URL) - const handleReset = () => setUrl("") + const handleReset = () => { + setUrl("") + setExternalRequestConfirmed(false) + } const handleLoadPreview = () => { if (candidates.length === 0) return + if (!externalRequestConfirmed) { + toast.error(t.common.external_network_notice.confirm_required) + return + } setPreviewApproved(true) setStatus(statusReady) void (async () => { @@ -102,6 +114,10 @@ export function VimeoThumbnailGrabberPage() { const handleDownload = () => { if (!selectedUrl) return + if (!externalRequestConfirmed) { + toast.error(t.common.external_network_notice.confirm_required) + return + } const filename = `vimeo-thumbnail-${videoId || "image"}.jpg` const anchor = document.createElement("a") anchor.href = selectedUrl @@ -120,8 +136,10 @@ export function VimeoThumbnailGrabberPage() { label: t.common.preview, icon: ImageDown, onClick: handleLoadPreview, - disabled: candidates.length === 0, - disabledReason: t.common.action_disabled_input_required, + disabled: candidates.length === 0 || !externalRequestConfirmed, + disabledReason: candidates.length === 0 + ? t.common.action_disabled_input_required + : t.common.external_network_notice.confirm_required, }, { id: "copy", label: t.common.copy, icon: Copy, onClick: () => void handleCopy() }, { @@ -129,10 +147,12 @@ export function VimeoThumbnailGrabberPage() { label: t.common.download, icon: Download, onClick: handleDownload, - disabled: !selectedUrl || !previewApproved, + disabled: !selectedUrl || !previewApproved || !externalRequestConfirmed, disabledReason: !selectedUrl ? t.common.action_disabled_no_output - : t.common.action_disabled_preview_required, + : !externalRequestConfirmed + ? t.common.external_network_notice.confirm_required + : t.common.action_disabled_preview_required, }, ] @@ -166,6 +186,17 @@ export function VimeoThumbnailGrabberPage() {
{t.common.compliance_guidance}
+ { + setExternalRequestConfirmed(confirmed) + if (!confirmed) setPreviewApproved(false) + }} + rightsGuidance={t.common.thumbnail_public_only_notice} + />
{t.common.thumbnail_public_only_notice} diff --git a/src/features/tools/youtube-thumbnail-grabber/page.tsx b/src/features/tools/youtube-thumbnail-grabber/page.tsx index 17398ebb..457837a4 100644 --- a/src/features/tools/youtube-thumbnail-grabber/page.tsx +++ b/src/features/tools/youtube-thumbnail-grabber/page.tsx @@ -7,6 +7,7 @@ import { useLang } from "@/core/i18n/lang-provider" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" +import { ExternalRequestConfirmation } from "@/features/tool-shell/external-request-confirmation" import { ToolPreviewArea } from "@/features/tool-shell/tool-preview-area" import { safeClipboardWrite } from "@/core/clipboard/clipboard" import { @@ -17,6 +18,7 @@ import { } from "@/core/utils/thumbnail-grabber-utils" const SAMPLE_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" +const EXTERNAL_HOSTS = ["youtube.com", "youtube-nocookie.com", "youtu.be", "i.ytimg.com"] as const export function YouTubeThumbnailGrabberPage() { const { t } = useLang() @@ -40,6 +42,7 @@ export function YouTubeThumbnailGrabberPage() { const [selectedUrl, setSelectedUrl] = React.useState("") const [status, setStatus] = React.useState(statusIdle) const [previewApproved, setPreviewApproved] = React.useState(false) + const [externalRequestConfirmed, setExternalRequestConfirmed] = React.useState(false) React.useEffect(() => { const id = parseYouTubeVideoId(url) @@ -48,6 +51,7 @@ export function YouTubeThumbnailGrabberPage() { setCandidates([]) setSelectedUrl("") setPreviewApproved(false) + setExternalRequestConfirmed(false) setStatus(url.trim() ? statusInvalid : statusIdle) return } @@ -57,6 +61,7 @@ export function YouTubeThumbnailGrabberPage() { setCandidates(next) setSelectedUrl(next[0]?.url || "") setPreviewApproved(false) + setExternalRequestConfirmed(false) setStatus(statusReady) }, [statusAutoSelectedTemplate, statusIdle, statusInvalid, statusReady, statusUnreachable, url]) @@ -75,9 +80,16 @@ export function YouTubeThumbnailGrabberPage() { ) const handleSample = () => setUrl(SAMPLE_URL) - const handleReset = () => setUrl("") + const handleReset = () => { + setUrl("") + setExternalRequestConfirmed(false) + } const handleLoadPreview = () => { if (candidates.length === 0) return + if (!externalRequestConfirmed) { + toast.error(t.common.external_network_notice.confirm_required) + return + } setPreviewApproved(true) setStatus(statusReady) void (async () => { @@ -102,6 +114,10 @@ export function YouTubeThumbnailGrabberPage() { const handleDownload = () => { if (!selectedUrl) return + if (!externalRequestConfirmed) { + toast.error(t.common.external_network_notice.confirm_required) + return + } const filename = `youtube-thumbnail-${videoId || "image"}.jpg` const anchor = document.createElement("a") anchor.href = selectedUrl @@ -120,8 +136,10 @@ export function YouTubeThumbnailGrabberPage() { label: t.common.preview, icon: ImageDown, onClick: handleLoadPreview, - disabled: candidates.length === 0, - disabledReason: t.common.action_disabled_input_required, + disabled: candidates.length === 0 || !externalRequestConfirmed, + disabledReason: candidates.length === 0 + ? t.common.action_disabled_input_required + : t.common.external_network_notice.confirm_required, }, { id: "copy", label: t.common.copy, icon: Copy, onClick: () => void handleCopy() }, { @@ -129,10 +147,12 @@ export function YouTubeThumbnailGrabberPage() { label: t.common.download, icon: Download, onClick: handleDownload, - disabled: !selectedUrl || !previewApproved, + disabled: !selectedUrl || !previewApproved || !externalRequestConfirmed, disabledReason: !selectedUrl ? t.common.action_disabled_no_output - : t.common.action_disabled_preview_required, + : !externalRequestConfirmed + ? t.common.external_network_notice.confirm_required + : t.common.action_disabled_preview_required, }, ] @@ -166,6 +186,17 @@ export function YouTubeThumbnailGrabberPage() {
{t.common.compliance_guidance}
+ { + setExternalRequestConfirmed(confirmed) + if (!confirmed) setPreviewApproved(false) + }} + rightsGuidance={t.common.thumbnail_public_only_notice} + />
{t.common.thumbnail_public_only_notice} diff --git a/src/lib/sitemap-lastmod.json b/src/lib/sitemap-lastmod.json index eefd235c..998820b1 100644 --- a/src/lib/sitemap-lastmod.json +++ b/src/lib/sitemap-lastmod.json @@ -211,13 +211,13 @@ "fr": "2026-06-22T00:00:00.000Z" }, "contact": { - "en": "2026-06-14T00:00:00.000Z", - "zh-CN": "2026-06-14T00:00:00.000Z", - "zh-TW": "2026-06-14T00:00:00.000Z", - "ja": "2026-06-14T00:00:00.000Z", - "ko": "2026-06-14T00:00:00.000Z", - "de": "2026-06-14T00:00:00.000Z", - "fr": "2026-06-14T00:00:00.000Z" + "en": "2026-06-24T00:00:00.000Z", + "zh-CN": "2026-06-24T00:00:00.000Z", + "zh-TW": "2026-06-24T00:00:00.000Z", + "ja": "2026-06-24T00:00:00.000Z", + "ko": "2026-06-24T00:00:00.000Z", + "de": "2026-06-24T00:00:00.000Z", + "fr": "2026-06-24T00:00:00.000Z" }, "privacy": { "en": "2026-06-20T00:00:00.000Z", @@ -229,13 +229,13 @@ "fr": "2026-06-20T00:00:00.000Z" }, "trust-center": { - "en": "2026-06-20T00:00:00.000Z", - "zh-CN": "2026-06-20T00:00:00.000Z", - "zh-TW": "2026-06-20T00:00:00.000Z", - "ja": "2026-06-20T00:00:00.000Z", - "ko": "2026-06-20T00:00:00.000Z", - "de": "2026-06-20T00:00:00.000Z", - "fr": "2026-06-20T00:00:00.000Z" + "en": "2026-06-24T00:00:00.000Z", + "zh-CN": "2026-06-24T00:00:00.000Z", + "zh-TW": "2026-06-24T00:00:00.000Z", + "ja": "2026-06-24T00:00:00.000Z", + "ko": "2026-06-24T00:00:00.000Z", + "de": "2026-06-24T00:00:00.000Z", + "fr": "2026-06-24T00:00:00.000Z" }, "terms": { "en": "2026-06-14T00:00:00.000Z", diff --git a/tests/component/external-request-media-tools.test.tsx b/tests/component/external-request-media-tools.test.tsx new file mode 100644 index 00000000..d4024f9b --- /dev/null +++ b/tests/component/external-request-media-tools.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { LangProvider } from "@/core/i18n/lang-provider" +import { getTranslation } from "@/core/i18n/translations/catalog" +import { InstagramPhotoDownloaderPage } from "@/features/tools/instagram-photo-downloader/page" +import { VimeoThumbnailGrabberPage } from "@/features/tools/vimeo-thumbnail-grabber/page" +import { YouTubeThumbnailGrabberPage } from "@/features/tools/youtube-thumbnail-grabber/page" + +const toastErrorMock = vi.fn() +const toastSuccessMock = vi.fn() +const toastInfoMock = vi.fn() + +vi.mock("next/navigation", () => ({ + usePathname: () => "/en/youtube-thumbnail-grabber", +})) + +vi.mock("sonner", () => ({ + toast: { + error: (...args: unknown[]) => toastErrorMock(...args), + success: (...args: unknown[]) => toastSuccessMock(...args), + info: (...args: unknown[]) => toastInfoMock(...args), + }, +})) + +class MockImage { + onload: (() => void) | null = null + onerror: (() => void) | null = null + src = "" +} + +function renderEnglish(ui: React.ReactNode) { + return render( + + {ui} + , + ) +} + +describe("external request media tools", () => { + beforeEach(() => { + toastErrorMock.mockClear() + toastSuccessMock.mockClear() + toastInfoMock.mockClear() + vi.stubGlobal("Image", MockImage) + vi.stubGlobal("fetch", vi.fn()) + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined) + window.history.replaceState(null, "", "/en/youtube-thumbnail-grabber") + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it("keeps YouTube thumbnail requests behind explicit confirmation", async () => { + renderEnglish() + + fireEvent.click(screen.getByRole("button", { name: "Sample" })) + + expect(screen.getByText("Confirm external request")).toBeInTheDocument() + expect(screen.getByText("youtube.com, youtube-nocookie.com, youtu.be, i.ytimg.com")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Preview", description: ": Confirm the external request before previewing or downloading." })).toBeDisabled() + expect(globalThis.fetch).not.toHaveBeenCalled() + + fireEvent.click(screen.getByLabelText("I understand this action may request the disclosed external asset from my browser.")) + fireEvent.click(screen.getByRole("button", { name: "Preview" })) + + await waitFor(() => { + expect(screen.queryByRole("button", { name: "Preview", description: ": Confirm the external request before previewing or downloading." })).not.toBeInTheDocument() + }) + expect(globalThis.fetch).not.toHaveBeenCalled() + }) + + it("keeps Vimeo thumbnail requests behind explicit confirmation", () => { + renderEnglish() + + fireEvent.click(screen.getByRole("button", { name: "Sample" })) + + expect(screen.getByText("Confirm external request")).toBeInTheDocument() + expect(screen.getByText("vimeo.com, player.vimeo.com, vumbnail.com")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Preview", description: ": Confirm the external request before previewing or downloading." })).toBeDisabled() + + fireEvent.click(screen.getByLabelText("I understand this action may request the disclosed external asset from my browser.")) + + expect(screen.getByRole("button", { name: "Preview" })).not.toBeDisabled() + expect(globalThis.fetch).not.toHaveBeenCalled() + }) + + it("requires Instagram rights confirmation and external request confirmation before fetch download", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(["image"], { type: "image/jpeg" })), + }) + vi.stubGlobal("fetch", fetchMock) + URL.createObjectURL = vi.fn(() => "blob:byteflow-test") + URL.revokeObjectURL = vi.fn() + + renderEnglish() + + fireEvent.change(screen.getByPlaceholderText("https://…"), { target: { value: "https://cdn.instagram.com/public/photo.jpg" } }) + fireEvent.click(screen.getByLabelText("I confirm this media URL is mine or I have explicit permission to download and use it.")) + + expect(screen.getByRole("button", { name: "Download", description: ": Confirm the external request before previewing or downloading." })).toBeDisabled() + expect(fetchMock).not.toHaveBeenCalled() + + fireEvent.click(screen.getByLabelText("I understand this action may request the disclosed external asset from my browser.")) + fireEvent.click(screen.getByRole("button", { name: "Download" })) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith("https://cdn.instagram.com/public/photo.jpg") + }) + }) +}) diff --git a/tests/guards/external-request-confirmation-guard.test.ts b/tests/guards/external-request-confirmation-guard.test.ts new file mode 100644 index 00000000..3ca1d991 --- /dev/null +++ b/tests/guards/external-request-confirmation-guard.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs" +import { describe, expect, it } from "vitest" + +function read(path: string) { + return readFileSync(path, "utf8") +} + +describe("external request confirmation guard", () => { + it("keeps media external actions gated by the shared confirmation component", () => { + const component = read("src/features/tool-shell/external-request-confirmation.tsx") + const files = [ + "src/features/tools/youtube-thumbnail-grabber/page.tsx", + "src/features/tools/vimeo-thumbnail-grabber/page.tsx", + "src/features/tools/instagram-photo-downloader/page.tsx", + ] + + expect(component).toContain("confirm_title") + expect(component).toContain("hosts.join") + expect(component).toContain("confirm_checkbox") + for (const file of files) { + const source = read(file) + expect(source, file).toContain("ExternalRequestConfirmation") + expect(source, file).toContain("externalRequestConfirmed") + expect(source, file).toContain("confirm_required") + } + }) +}) diff --git a/tests/guards/preview-sanitization-guard.test.ts b/tests/guards/preview-sanitization-guard.test.ts new file mode 100644 index 00000000..911e5370 --- /dev/null +++ b/tests/guards/preview-sanitization-guard.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs" +import { describe, expect, it } from "vitest" + +function read(path: string) { + return readFileSync(path, "utf8") +} + +describe("preview sanitization guard", () => { + it("keeps Markdown, HTML, SVG, and Open Graph preview paths covered by regression tests", () => { + const test = read("tests/unit/preview-sanitization-regression.test.tsx") + const sanitizer = read("src/core/security/sanitize.ts") + const htmlToMarkdown = read("src/features/tools/html-to-markdown/utils.ts") + const svgOptimizer = read("src/features/tools/svg-optimizer/logic.ts") + const ogUtils = read("src/features/tools/open-graph-meta-generator/utils.ts") + + expect(test).toContain("MarkdownPreviewRenderer") + expect(test).toContain("convertHtmlToMarkdown") + expect(test).toContain("sanitizeSvgForPreview") + expect(test).toContain("buildOpenGraphMetaTags") + expect(sanitizer).toContain("FORBID_TAGS") + expect(htmlToMarkdown).toContain("sanitizeHtml(normalized)") + expect(svgOptimizer).toContain("sanitizeSvg") + expect(ogUtils).toContain("escapeHtml") + }) +}) diff --git a/tests/guards/regex-worker-guard.test.ts b/tests/guards/regex-worker-guard.test.ts new file mode 100644 index 00000000..ce448050 --- /dev/null +++ b/tests/guards/regex-worker-guard.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs" +import { describe, expect, it } from "vitest" + +function read(path: string) { + return readFileSync(path, "utf8") +} + +describe("regex worker guard", () => { + it("keeps regex evaluation routed through a worker task with timeout and cancellation", () => { + const page = read("src/features/tools/regex-tester/page.tsx") + const task = read("src/features/tools/regex-tester/regex-test-task.ts") + const worker = read("src/features/tools/regex-tester/regex-test-worker.ts") + + expect(page).toContain("evaluationAbortControllerRef") + expect(page).toContain("runRegexTestTask") + expect(page).not.toContain("testRegexPattern(") + expect(task).toContain("new Worker(new URL(\"./regex-test-worker.ts\", import.meta.url)") + expect(task).toContain("timeoutMs ?? 1_000") + expect(task).toContain("WORKER_TIMEOUT") + expect(worker).toContain("testRegexPattern(pattern, flags, testString, maxMatches)") + }) +}) diff --git a/tests/guards/security-reporting-path.test.ts b/tests/guards/security-reporting-path.test.ts new file mode 100644 index 00000000..4bd34dc4 --- /dev/null +++ b/tests/guards/security-reporting-path.test.ts @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs" +import { describe, expect, it } from "vitest" + +const SECURITY_ADVISORY_URL = "https://github.com/baixiangcpp/byteflow.tools/security/advisories/new" +const SECURITY_POLICY_URL = "https://github.com/baixiangcpp/byteflow.tools/security/policy" + +function read(path: string) { + return readFileSync(path, "utf8") +} + +describe("BF-045 security reporting path", () => { + it("publishes a valid well-known security.txt with the private advisory contact", () => { + const securityTxt = read("public/.well-known/security.txt") + + expect(securityTxt).toContain(`Contact: ${SECURITY_ADVISORY_URL}`) + expect(securityTxt).toContain(`Policy: ${SECURITY_POLICY_URL}`) + expect(securityTxt).toContain("Canonical: https://byteflow.tools/.well-known/security.txt") + expect(securityTxt).toContain("Preferred-Languages: en") + expect(securityTxt).toMatch(/Expires: 2027-06-24T00:00:00Z/) + expect(securityTxt).not.toMatch(/mailto:|api[_-]?key|token|secret/i) + }) + + it("redirects the root security.txt path to the well-known file", () => { + const redirects = read("public/_redirects") + + expect(redirects).toContain("/security.txt /.well-known/security.txt 301") + }) + + it("keeps repository security policy and public pages aligned", () => { + const policy = read(".github/SECURITY.md") + const contactPage = read("src/app/[lang]/contact/page.tsx") + const trustCenterPage = read("src/app/[lang]/trust-center/page.tsx") + const enCopy = read("src/core/i18n/translations/en.json") + + expect(policy).toContain(SECURITY_ADVISORY_URL) + expect(policy).toContain("Do not open a public issue") + expect(policy).toContain("Do not include production secrets") + expect(contactPage).toContain(SECURITY_ADVISORY_URL) + expect(contactPage).toContain("contact_security_title") + expect(trustCenterPage).toContain(SECURITY_ADVISORY_URL) + expect(trustCenterPage).toContain("/.well-known/security.txt") + expect(enCopy).toContain("Use public issues only for non-security bugs or feature requests") + }) +}) diff --git a/tests/guards/tool-action-consistency-guard.test.ts b/tests/guards/tool-action-consistency-guard.test.ts index e3bd4daf..1f6469a5 100644 --- a/tests/guards/tool-action-consistency-guard.test.ts +++ b/tests/guards/tool-action-consistency-guard.test.ts @@ -66,4 +66,14 @@ describe("tool action consistency guard", () => { expect(page).not.toMatch(/\bfetch\s*\(|\bXMLHttpRequest\b/) expect(template).toContain("Does this builder send the HTTP request?") }) + + it("keeps Log Scrubber manual review and category summary visible", () => { + const page = read("src/features/tools/log-scrubber/page.tsx") + const translations = read("src/core/i18n/translations/en.json") + + expect(page).toContain("manual_review_note") + expect(page).toContain("summary_title") + expect(page).toContain("Object.entries(summary)") + expect(translations).toContain("Automated redaction is a safety layer, not a guarantee") + }) }) diff --git a/tests/unit/har-viewer-sanitizer-utils.test.ts b/tests/unit/har-viewer-sanitizer-utils.test.ts index b0e47601..f7e5a2d5 100644 --- a/tests/unit/har-viewer-sanitizer-utils.test.ts +++ b/tests/unit/har-viewer-sanitizer-utils.test.ts @@ -81,9 +81,22 @@ describe("HAR viewer and sanitizer utilities", () => { it("redacts headers, cookies, query strings, bodies, and content", () => { const result = sanitizeHar(SAMPLE_HAR) + const sanitized = JSON.parse(result.output) expect(result.error).toBeUndefined() expect(result.redactionCount).toBeGreaterThanOrEqual(6) + expect(result.summary).toMatchObject({ + header: 2, + cookie: 2, + query: 3, + postData: 1, + content: 1, + }) + expect(sanitized.log._byteflowSanitizerSummary).toMatchObject({ + generatedBy: "byteflow.tools HAR Viewer / Sanitizer", + redactionCount: result.redactionCount, + reviewRequired: true, + }) expect(result.output).toContain("[REDACTED]") expect(result.output).toContain("token=[REDACTED]") expect(result.output).not.toContain("Bearer secret") @@ -102,7 +115,22 @@ describe("HAR viewer and sanitizer utilities", () => { }) expect(result.redactionCount).toBe(0) + expect(result.summary).toEqual({}) expect(result.output).toContain("Bearer secret") + expect(result.output).toContain("_byteflowSanitizerSummary") + }) + + it("documents aggressive defaults in sanitized exports", () => { + const result = sanitizeHar(SAMPLE_HAR) + const sanitized = JSON.parse(result.output) + + expect(sanitized.log._byteflowSanitizerSummary.defaults).toEqual({ + headers: true, + cookies: true, + queryStrings: true, + postData: true, + responseContent: true, + }) }) it("returns parse errors without throwing", () => { diff --git a/tests/unit/log-scrubber-utils.test.ts b/tests/unit/log-scrubber-utils.test.ts index d5a87e3f..8007a55f 100644 --- a/tests/unit/log-scrubber-utils.test.ts +++ b/tests/unit/log-scrubber-utils.test.ts @@ -1,16 +1,20 @@ import { describe, expect, it } from "vitest" import { DEFAULT_SCRUB_OPTIONS, scrubLogs, summarizeFindings } from "../../src/lib/log-scrubber-utils" +function joinTokenParts(parts: string[], separator = "") { + return parts.join(separator) +} + describe("scrubLogs", () => { it("redacts emails, IPs, bearer tokens, and key/value secrets", () => { - const input = "user=alice@example.com ip=192.168.1.10 Authorization: Bearer abcdefghijklmnop password=secret" + const input = "user=alice@example.com ip=192.168.1.10 ipv6=2001:0db8:0000:0000:0000:ff00:0042:8329 Authorization: Bearer abcdefghijklmnop password=secret" const result = scrubLogs(input) expect(result.output).toContain("[EMAIL_REDACTED]") expect(result.output).toContain("[IP_REDACTED]") expect(result.output).toContain("Bearer [TOKEN_REDACTED]") expect(result.output).toContain("password= [SECRET_REDACTED]") - expect(result.redactionCount).toBe(4) + expect(result.redactionCount).toBe(5) }) it("reports positions from the original input when earlier replacements change length", () => { @@ -53,13 +57,14 @@ describe("scrubLogs", () => { }) it("does not retain full raw secrets in findings", () => { - const input = "email=alice@example.com Authorization: Bearer abcdefghijklmnop api_key=sk_live_secretvalue" + const fakeApiKey = joinTokenParts(["sk", "live", "secretvalue"], "_") + const input = `email=alice@example.com Authorization: Bearer abcdefghijklmnop api_key=${fakeApiKey}` const result = scrubLogs(input) const serializedFindings = JSON.stringify(result.findings) expect(serializedFindings).not.toContain("alice@example.com") expect(serializedFindings).not.toContain("abcdefghijklmnop") - expect(serializedFindings).not.toContain("sk_live_secretvalue") + expect(serializedFindings).not.toContain(fakeApiKey) expect(result.findings.every((finding) => finding.maskedPreview.length > 0)).toBe(true) expect(result.output).toContain("[EMAIL_REDACTED]") expect(result.output).toContain("Bearer [TOKEN_REDACTED]") @@ -67,11 +72,48 @@ describe("scrubLogs", () => { }) it("redacts JWT and AWS access keys", () => { - const input = "jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature key=AKIAIOSFODNN7EXAMPLE" + const input = "jwt=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature key=AKIAIOSFODNN7EXAMPLE AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" const result = scrubLogs(input) expect(result.output).toContain("[JWT_REDACTED]") expect(result.output).toContain("[AWS_ACCESS_KEY_REDACTED]") + expect(result.output).toContain("AWS_SECRET_ACCESS_KEY= [SECRET_REDACTED]") + }) + + it("redacts common cloud tokens, cookies, session IDs, and certificate blocks", () => { + const githubToken = joinTokenParts(["gh", "p_abcdefghijklmnopqrstuvwxyz1234567890"]) + const stripeToken = joinTokenParts(["sk", "live", "1234567890abcdef1234567890abcdef"], "_") + const sendgridToken = joinTokenParts(["S", "G.abcdefghijklmnop.qrstuvwxyz123456"]) + const googleToken = joinTokenParts(["AI", "zaSyDUMMYDUMMYDUMMYDUMMYDUMMYDUMMY123"]) + const slackToken = joinTokenParts(["xo", "xb-123456789012-abcdefghijklmnopqrstuv"]) + const input = [ + `github=${githubToken}`, + `stripe=${stripeToken}`, + `sendgrid=${sendgridToken}`, + `google=${googleToken}`, + `slack=${slackToken}`, + "Cookie: sid=abc123; theme=dark", + "session_id=sess_1234567890abcdef request_id=req_1234567890", + "-----BEGIN CERTIFICATE-----\nMIIDdummy\n-----END CERTIFICATE-----", + ].join("\n") + + const result = scrubLogs(input) + + expect(result.output).not.toContain(githubToken) + expect(result.output).not.toContain(stripeToken) + expect(result.output).not.toContain("sid=abc123") + expect(result.output).not.toContain("sess_1234567890abcdef") + expect(result.output).not.toContain("MIIDdummy") + expect(result.output).toContain("[TOKEN_REDACTED]") + expect(result.output).toContain("Cookie: [COOKIE_REDACTED]") + expect(result.output).toContain("session_id= [SESSION_REDACTED]") + expect(result.output).toContain("[CERTIFICATE_REDACTED]") + expect(summarizeFindings(result.findings)).toMatchObject({ + "cloud-token": 5, + cookie: 1, + "session-id": 2, + certificate: 1, + }) }) it("preserves URL host while redacting URL credentials", () => { diff --git a/tests/unit/preview-sanitization-regression.test.tsx b/tests/unit/preview-sanitization-regression.test.tsx new file mode 100644 index 00000000..9cca643b --- /dev/null +++ b/tests/unit/preview-sanitization-regression.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { sanitizeHtml, sanitizeMarkdownHtml, sanitizeSvgForPreview } from "@/core/security/sanitize" +import { MarkdownPreviewRenderer } from "@/features/tool-templates/markdown-preview-renderer" +import { convertHtmlToMarkdown } from "@/features/tools/html-to-markdown/utils" +import { optimizeAndSanitizeSvg } from "@/features/tools/svg-optimizer/logic" +import { buildOpenGraphMetaTags, buildOpenGraphSnippetDocument } from "@/features/tools/open-graph-meta-generator/utils" + +const UNSAFE_MARKUP = ` +

Safe title

+ + + +bad + +` + +function expectNoExecutableMarkup(output: string) { + expect(output).not.toMatch(/` + const preview = sanitizeSvgForPreview(svg) + const optimized = optimizeAndSanitizeSvg(svg) + + expect(preview).toContain(" { + const tags = buildOpenGraphMetaTags({ + title: `">`, + description: ``, + url: "javascript:alert(1)", + image: "https://evil.example/og.png", + type: "website", + siteName: "byteflow.tools", + twitterCard: "summary_large_image", + twitterSite: "@byteflow", + }) + const document = buildOpenGraphSnippetDocument(tags) + + expect(document).toContain("<script>alert(1)</script>") + expect(document).toContain("<img src=x onerror=alert(1)>") + expect(document).not.toContain("javascript:alert(1)") + expect(document).not.toContain("