Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7d8aa1c
Support linking to UE assets, Blueprints, and C++ classes from Markdown
claude Apr 17, 2026
2d4cc69
Open .cpp instead of .h for native class links
claude Apr 17, 2026
d665117
Resolve native class names with A/U/I prefix stripped
claude Apr 17, 2026
2ce79b7
Preserve path separators in ueasset:// URLs
claude Apr 17, 2026
ed2852a
Add [LinkDebug] logs to diagnose link navigation
claude Apr 17, 2026
7374acd
Resolve Blueprint assets via package-name lookup
claude Apr 17, 2026
70d67e3
Load assets via registry's canonical path and package fallback
claude Apr 17, 2026
1f72a4b
Release v1.2.0 with asset and class linking
claude Apr 18, 2026
72397b1
Add new samples for v1.2.0
EmbarrassingMoment Apr 18, 2026
0065e3f
Merge pull request #62 from EmbarrassingMoment/claude/implement-issue…
EmbarrassingMoment Apr 18, 2026
c10ffa3
Guard UMetaData filter for UE 5.6+ build
claude Apr 19, 2026
b764cb0
Merge pull request #63 from EmbarrassingMoment/claude/fix-umetadata-e…
EmbarrassingMoment Apr 19, 2026
888e517
Block unknown URL schemes in preview navigation
claude Apr 19, 2026
b0a994c
Update Security section in README to document URL scheme allowlist
claude Apr 19, 2026
32efe3c
Add Content-Security-Policy to preview HTML to block external requests
claude Apr 19, 2026
64a3130
Update Security section in README to document CSP injection
claude Apr 19, 2026
255e833
Prompt for confirmation before launching external URLs
claude Apr 19, 2026
20895f7
Document external URL confirmation dialog in README
claude Apr 19, 2026
d828860
Fix localization Gather SearchDirectoryPaths to actual plugin source
claude Apr 19, 2026
66abdf0
Update localization.
EmbarrassingMoment Apr 19, 2026
e0aa375
Fix literal \n\n appearing in localized external URL dialog
claude Apr 19, 2026
5d0ba79
Update localization
EmbarrassingMoment Apr 19, 2026
a477870
Document 1.2.0 security hardening and localization fix in changelog
claude Apr 19, 2026
08eae1b
Merge branch 'claude/browser-plugin-security-review-K6C86' of https:/…
EmbarrassingMoment Apr 19, 2026
4058d22
Merge pull request #65 from EmbarrassingMoment/claude/browser-plugin-…
EmbarrassingMoment Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [1.2.0] - 2026-04-18

### Added

- **Asset & Class Links** — Standard Markdown links whose target begins with a UE package root (`/Game/`, `/Engine/`, `/Plugins/`, `/Script/`) are now opened in the corresponding asset editor from the HTML preview
- **Class Link Scheme** — `[Label](class://ClassName)` resolves the target via UClass lookup; native C++ classes open in the IDE via `FSourceCodeNavigation` (preferring the `.cpp` file, falling back to the header), Blueprint classes open in the Blueprint editor
- **Broken Link Styling for New Schemes** — `ueasset://` and `class://` targets that cannot be resolved are highlighted in red in the preview, matching existing wikilink behavior

### Security

- **URL Scheme Allowlist** — The preview's `OnBeforeNavigation` handler now default-denies any scheme that is not explicitly supported (`data:`, `about:`, `mdasset://`, `ueasset://`, `class://`, `http(s)://`); unknown schemes such as `javascript:` and `file:` are blocked to prevent script execution or local file access from untrusted Markdown content
- **Content Security Policy** — Preview HTML now includes a `Content-Security-Policy` meta tag (`default-src 'none'; style-src 'unsafe-inline'; img-src data:`) that blocks all external network requests (remote images, fetch/XHR, frames, fonts), mitigating IP tracking and SSRF against local services from crafted Markdown assets
- **External URL Confirmation Dialog** — Clicking an `http(s)://` link in the preview now presents a localized confirmation dialog showing the full URL before launching the system browser, mitigating phishing via the address-bar-less preview

### Fixed

- **Localization Gather Path** — Corrected `SearchDirectoryPaths` in `Config/Localization/MarkdownEditor_Gather.ini` which pointed to a non-existent directory (`Plugins/MarkdownEditor/Source`) left over from an earlier plugin rename; now targets `Plugins/MarkdownAsset/Source/MarkdownAssetEditor` so `LOCTEXT` / `NSLOCTEXT` strings are collected correctly by the Localization Dashboard

## [1.1.0] - 2026-04-04

### Added
Expand Down
2 changes: 1 addition & 1 deletion Config/DefaultEditor.ini
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
+EngineTargetsSettings=(Name="Keywords",Guid=AE89AECB47475F420D0D69A5547515DC,TargetDependencies=(33482D004789784C9DA695A682ACCA1B,AC8BFD2A41A2FB2893BB8EA0AF903E6D),AdditionalManifestDependencies=,RequiredModuleNames=,GatherFromTextFiles=(IsEnabled=False,SearchDirectories=,ExcludePathWildcards=,FileExtensions=((Pattern="h"),(Pattern="cpp"),(Pattern="ini")),ShouldGatherFromEditorOnlyData=False),GatherFromPackages=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=,FileExtensions=((Pattern="umap"),(Pattern="uasset")),Collections=,ExcludeClasses=,ShouldExcludeDerivedClasses=False,ShouldGatherFromEditorOnlyData=True,SkipGatherCache=False),GatherFromMetaData=(IsEnabled=True,IncludePathWildcards=((Pattern="Source/Editor/*"),(Pattern="Source/Runtime/*"),(Pattern="Source/Developer/*")),ExcludePathWildcards=((Pattern="Source/Developer/NoRedist/CommunityPortalServices/*")),KeySpecifications=((MetaDataKey=(Name="Keywords"),TextNamespace="UObjectKeywords",TextKeyPattern=(Pattern="{FieldPath}"))),FieldTypesToInclude=,FieldTypesToExclude=,FieldOwnerTypesToInclude=,FieldOwnerTypesToExclude=,ShouldGatherFromEditorOnlyData=True),ExportSettings=(CollapseMode=IdenticalTextIdAndSource,POFormat=Unreal,ShouldPersistCommentsOnExport=False,ShouldAddSourceLocationsAsComments=True),CompileSettings=(SkipSourceCheck=False,ValidateFormatPatterns=True,ValidateSafeWhitespace=False),ImportDialogueSettings=(RawAudioPath=(Path=""),ImportedDialogueFolder="ImportedDialogue",bImportNativeAsSource=False),NativeCultureIndex=0,SupportedCulturesStatistics=((CultureName="en"),(CultureName="es"),(CultureName="ja"),(CultureName="ko"),(CultureName="pt-BR"),(CultureName="zh-CN")))
+EngineTargetsSettings=(Name="Category",Guid=14B8DEE642A6A7AFEB5A28B959EC373A,TargetDependencies=,AdditionalManifestDependencies=,RequiredModuleNames=,GatherFromTextFiles=(IsEnabled=False,SearchDirectories=,ExcludePathWildcards=,FileExtensions=((Pattern="h"),(Pattern="cpp"),(Pattern="ini")),ShouldGatherFromEditorOnlyData=False),GatherFromPackages=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=,FileExtensions=((Pattern="umap"),(Pattern="uasset")),Collections=,ExcludeClasses=,ShouldExcludeDerivedClasses=False,ShouldGatherFromEditorOnlyData=False,SkipGatherCache=False),GatherFromMetaData=(IsEnabled=True,IncludePathWildcards=((Pattern="Source/Editor/*"),(Pattern="Source/Runtime/*"),(Pattern="Source/Developer/*")),ExcludePathWildcards=((Pattern="Source/Developer/NoRedist/CommunityPortalServices/*")),KeySpecifications=((MetaDataKey=(Name="Category"),TextNamespace="UObjectCategory",TextKeyPattern=(Pattern="{FieldPath}"))),FieldTypesToInclude=,FieldTypesToExclude=,FieldOwnerTypesToInclude=,FieldOwnerTypesToExclude=,ShouldGatherFromEditorOnlyData=True),ExportSettings=(CollapseMode=IdenticalTextIdAndSource,POFormat=Unreal,ShouldPersistCommentsOnExport=False,ShouldAddSourceLocationsAsComments=True),CompileSettings=(SkipSourceCheck=False,ValidateFormatPatterns=True,ValidateSafeWhitespace=False),ImportDialogueSettings=(RawAudioPath=(Path=""),ImportedDialogueFolder="ImportedDialogue",bImportNativeAsSource=False),NativeCultureIndex=0,SupportedCulturesStatistics=((CultureName="en"),(CultureName="es"),(CultureName="ja"),(CultureName="ko"),(CultureName="pt-BR"),(CultureName="zh-CN")))
-GameTargetsSettings=(Name="Game",Guid=AE0EA34A45461A25BA65A391026F19F8,TargetDependencies=(33482D004789784C9DA695A682ACCA1B,AC8BFD2A41A2FB2893BB8EA0AF903E6D),AdditionalManifestDependencies=,RequiredModuleNames=,GatherFromTextFiles=(IsEnabled=False,SearchDirectories=,ExcludePathWildcards=,FileExtensions=((Pattern="h"),(Pattern="cpp"),(Pattern="ini"))),GatherFromPackages=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=,FileExtensions=((Pattern="umap"),(Pattern="uasset")),ShouldGatherFromEditorOnlyData=False),GatherFromMetaData=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=,KeySpecifications=,ShouldGatherFromEditorOnlyData=False),NativeCultureIndex=-1,SupportedCulturesStatistics=((CultureName="en")))
+GameTargetsSettings=(Name="MarkdownEditor",Guid=6E9E53F641319F56D59BD1A79AF36E6A,TargetDependencies=(AC8BFD2A41A2FB2893BB8EA0AF903E6D,33482D004789784C9DA695A682ACCA1B),AdditionalManifestDependencies=,RequiredModuleNames=,GatherFromTextFiles=(IsEnabled=True,SearchDirectories=((Path="Plugins/MarkdownEditor/Source")),ExcludePathWildcards=,FileExtensions=((Pattern="h"),(Pattern="cpp"),(Pattern="ini")),ShouldGatherFromEditorOnlyData=False),GatherFromPackages=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=((Pattern="Content/L10N/*")),FileExtensions=((Pattern="umap"),(Pattern="uasset")),Collections=,ExcludeClasses=,ShouldExcludeDerivedClasses=False,ShouldGatherFromEditorOnlyData=False,SkipGatherCache=False),GatherFromMetaData=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=,KeySpecifications=,FieldTypesToInclude=,FieldTypesToExclude=,FieldOwnerTypesToInclude=,FieldOwnerTypesToExclude=,ShouldGatherFromEditorOnlyData=False),ExportSettings=(CollapseMode=IdenticalTextIdAndSource,POFormat=Unreal,ShouldPersistCommentsOnExport=False,ShouldAddSourceLocationsAsComments=True),CompileSettings=(SkipSourceCheck=False,ValidateFormatPatterns=True,ValidateSafeWhitespace=False),ImportDialogueSettings=(RawAudioPath=(Path=""),ImportedDialogueFolder="ImportedDialogue",bImportNativeAsSource=False),NativeCultureIndex=0,SupportedCulturesStatistics=((CultureName="en"),(CultureName="ja")))
+GameTargetsSettings=(Name="MarkdownEditor",Guid=6E9E53F641319F56D59BD1A79AF36E6A,TargetDependencies=(AC8BFD2A41A2FB2893BB8EA0AF903E6D,33482D004789784C9DA695A682ACCA1B),AdditionalManifestDependencies=,RequiredModuleNames=,GatherFromTextFiles=(IsEnabled=True,SearchDirectories=((Path="Plugins/MarkdownAsset/Source/MarkdownAssetEditor")),ExcludePathWildcards=,FileExtensions=((Pattern="h"),(Pattern="cpp"),(Pattern="ini")),ShouldGatherFromEditorOnlyData=False),GatherFromPackages=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=((Pattern="Content/L10N/*")),FileExtensions=((Pattern="umap"),(Pattern="uasset")),Collections=,ExcludeClasses=,ShouldExcludeDerivedClasses=False,ShouldGatherFromEditorOnlyData=False,SkipGatherCache=False),GatherFromMetaData=(IsEnabled=False,IncludePathWildcards=,ExcludePathWildcards=,KeySpecifications=,FieldTypesToInclude=,FieldTypesToExclude=,FieldOwnerTypesToInclude=,FieldOwnerTypesToExclude=,ShouldGatherFromEditorOnlyData=False),ExportSettings=(CollapseMode=IdenticalTextIdAndSource,POFormat=Unreal,ShouldPersistCommentsOnExport=False,ShouldAddSourceLocationsAsComments=True),CompileSettings=(SkipSourceCheck=False,ValidateFormatPatterns=True,ValidateSafeWhitespace=False),ImportDialogueSettings=(RawAudioPath=(Path=""),ImportedDialogueFolder="ImportedDialogue",bImportNativeAsSource=False),NativeCultureIndex=0,SupportedCulturesStatistics=((CultureName="en"),(CultureName="ja")))

[Internationalization]
+LocalizationPaths=%GAMEDIR%Content/Localization/MarkdownEditor
Expand Down
5 changes: 2 additions & 3 deletions Config/Localization/MarkdownEditor_Gather.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
; THESE ARE GENERATED FILES, DO NOT EDIT DIRECTLY!
; USE THE LOCALIZATION DASHBOARD IN THE UNREAL EDITOR TO EDIT THE CONFIGURATION
;METADATA=(Diff=true, UseCommands=true)
[CommonSettings]
ManifestDependencies=../../../../../../Program Files/Epic Games/UE_5.5/Engine/Content/Localization/Editor/Editor.manifest
ManifestDependencies=../../../../../../Program Files/Epic Games/UE_5.5/Engine/Content/Localization/Engine/Engine.manifest
Expand All @@ -13,7 +12,7 @@ CulturesToGenerate=ja

[GatherTextStep0]
CommandletClass=GatherTextFromSource
SearchDirectoryPaths=Plugins/MarkdownEditor/Source
SearchDirectoryPaths=Plugins/MarkdownAsset/Source/MarkdownAssetEditor
ExcludePathFilters=Config/Localization/*
FileNameFilters=*.h
FileNameFilters=*.cpp
Expand Down
Binary file added Content/BP_LinkTest.uasset
Binary file not shown.
4 changes: 4 additions & 0 deletions Content/Localization/MarkdownEditor/MarkdownEditor.csv
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
Date/Time,Word Count,en,ja
2026.03.11-15.05.19,99,99,0
2026.03.11-15.15.14,99,99,99
2026.04.19-21.11.13,0,0,0
2026.04.19-21.14.36,111,111,0
2026.04.19-21.16.48,111,111,99
2026.04.19-21.24.03,111,111,111
Binary file modified Content/Localization/MarkdownEditor/MarkdownEditor.manifest
Binary file not shown.
Binary file modified Content/Localization/MarkdownEditor/en/MarkdownEditor.archive
Binary file not shown.
Binary file modified Content/Localization/MarkdownEditor/en/MarkdownEditor.locres
Binary file not shown.
Binary file modified Content/Localization/MarkdownEditor/ja/MarkdownEditor.archive
Binary file not shown.
Binary file modified Content/Localization/MarkdownEditor/ja/MarkdownEditor.locres
Binary file not shown.
Binary file added Content/MD_LinkTest.uasset
Binary file not shown.
4 changes: 2 additions & 2 deletions Plugins/MarkdownAsset/MarkdownAsset.uplugin
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"FileVersion": 3,
"Version": 2,
"VersionName": "1.1.0",
"Version": 3,
"VersionName": "1.2.0",
"FriendlyName": "MarkdownAsset",
"Description": "A custom Markdown asset type with a split-pane live preview editor. Supports GFM (tables, task lists, strikethrough). Import/export .md files directly from Content Browser.",
"Category": "Editor",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,36 @@ static FString PercentEncode(const FString& Input)
return Encoded;
}

/**
* Percent-encodes a URI path while preserving the path-separator '/' so the result
* forms a well-formed scheme:///path URL (e.g. "ueasset:///Game/Foo/Bar"). Encoding
* every '/' produced an invalid authority-only URL that CEF failed to navigate.
*/
static FString PercentEncodePath(const FString& Input)
{
FTCHARToUTF8 Utf8(*Input);
const char* Data = Utf8.Get();
int32 Len = Utf8.Length();

FString Encoded;
Encoded.Reserve(Len * 3);

for (int32 i = 0; i < Len; ++i)
{
uint8 Ch = static_cast<uint8>(Data[i]);
if ((Ch >= 'A' && Ch <= 'Z') || (Ch >= 'a' && Ch <= 'z') || (Ch >= '0' && Ch <= '9')
|| Ch == '-' || Ch == '_' || Ch == '.' || Ch == '~' || Ch == '/')
{
Encoded.AppendChar(static_cast<TCHAR>(Ch));
}
else
{
Encoded += FString::Printf(TEXT("%%%02X"), Ch);
}
}
return Encoded;
}

/**
* Converts md4c-html wikilink elements to anchor tags with the mdasset:// scheme.
* Input: <x-wikilink data-target="Name">Name</x-wikilink>
Expand Down Expand Up @@ -72,6 +102,74 @@ static FString PostProcessWikilinks(const FString& Html)
return Result;
}

/** Decodes HTML numeric/named entities that md4c emits inside href values (e.g. &amp;, &#x2F;). */
static FString DecodeHtmlEntitiesInHref(const FString& Input)
{
FString Output = Input;
Output.ReplaceInline(TEXT("&amp;"), TEXT("&"));
Output.ReplaceInline(TEXT("&#x2F;"), TEXT("/"));
Output.ReplaceInline(TEXT("&#47;"), TEXT("/"));
Output.ReplaceInline(TEXT("&lt;"), TEXT("<"));
Output.ReplaceInline(TEXT("&gt;"), TEXT(">"));
Output.ReplaceInline(TEXT("&quot;"), TEXT("\""));
return Output;
}

/**
* Rewrites anchor tags whose href targets a UE package path or class reference
* into custom URL schemes intercepted by the editor toolkit:
* [Label](/Game/Foo/Bar) -> <a href="ueasset:///Game/Foo/Bar">
* [Label](class://MyClass) -> <a href="class://MyClass"> (normalized)
* http/https/mdasset/data URLs are left untouched.
*/
static FString PostProcessAssetAndClassLinks(const FString& Html)
{
// Capture every <a href="..."> (non-greedy) so we can classify the href.
const FRegexPattern AnchorPattern(TEXT("<a href=\"([^\"]*)\""));
FRegexMatcher Matcher(AnchorPattern, Html);

FString Processed;
int32 LastPos = 0;

while (Matcher.FindNext())
{
Processed += Html.Mid(LastPos, Matcher.GetMatchBeginning() - LastPos);

FString Href = Matcher.GetCaptureGroup(1);
const FString DecodedHref = DecodeHtmlEntitiesInHref(Href);

const bool bIsPackagePath =
DecodedHref.StartsWith(TEXT("/Game/")) ||
DecodedHref.StartsWith(TEXT("/Engine/")) ||
DecodedHref.StartsWith(TEXT("/Plugins/")) ||
DecodedHref.StartsWith(TEXT("/Script/"));
const bool bIsClassScheme = DecodedHref.StartsWith(TEXT("class://"));

if (bIsPackagePath)
{
// Produce "ueasset:///Game/Foo/Bar" (scheme + empty authority + path);
// path separators must stay literal for CEF to parse the URL.
const FString Encoded = PercentEncodePath(DecodedHref);
Processed += FString::Printf(TEXT("<a href=\"ueasset://%s\""), *Encoded);
}
else if (bIsClassScheme)
{
const FString ClassName = DecodedHref.Mid(FString(TEXT("class://")).Len());
const FString Encoded = PercentEncode(ClassName);
Processed += FString::Printf(TEXT("<a href=\"class://%s\""), *Encoded);
}
else
{
Processed += Html.Mid(Matcher.GetMatchBeginning(), Matcher.GetMatchEnding() - Matcher.GetMatchBeginning());
}

LastPos = Matcher.GetMatchEnding();
}
Processed += Html.Mid(LastPos);

return Processed;
}

/**
* Helper function for md4c-html callback to append output to a string.
*/
Expand Down Expand Up @@ -109,6 +207,7 @@ FString UMarkdownAsset::GetParsedHTML() const
}

OutputHtml = PostProcessWikilinks(OutputHtml);
OutputHtml = PostProcessAssetAndClassLinks(OutputHtml);

return OutputHtml;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ public MarkdownAssetEditor(ReadOnlyTargetRules Target) : base(Target)
"AppFramework",
"WorkspaceMenuStructure",
"WebBrowser",
"AssetRegistry"
"AssetRegistry",
"SourceCodeAccess"
}
);
}
Expand Down
Loading
Loading