Escape Project Metadata in the Project Page and Restrict Accessory Opening

The Project page rendered the model and profile name, author, description
and accessory file names from the 3MF as live HTML. Names, authors and file
names are now set as text, and the file list is built from DOM nodes with
bound click handlers instead of concatenated markup. Descriptions can
legitimately carry rich-text HTML, so they are rebuilt from an inert
DOMParser document, keeping only plain formatting tags, http(s) links and
http(s) images, with every other attribute dropped.

Opening an accessory from the page now only launches regular files that
lie inside the project's extracted auxiliary directory. The containment
check is a new libslic3r helper, is_absolute_path_within_root, built on
is_path_within_root so symlinks leading out of the root are rejected too.
This commit is contained in:
Hanif Koh
2026-09-28 05:56:10 +08:00
parent 4964f49765
commit e5af09d900
5 changed files with 137 additions and 25 deletions
+78 -24
View File
@@ -214,9 +214,9 @@ function ShowModelInfo( pModel )
SendWXDebugInfo("Model Name: "+sModelName);
$('#ModelName').html(sModelName);
$('#ModelName').text(sModelName);
$('#ModelName').attr('title',sModelName);
$('#ModelAuthorName').html(sModelAuthor);
$('#ModelAuthorName').text(sModelAuthor);
switch(UploadType)
{
@@ -268,7 +268,7 @@ function ShowModelInfo( pModel )
break;
}
$('#Model_Desc').html( html_decode(sModelDesc) );
$('#Model_Desc').empty().append( SanitizeDescHtml( html_decode(sModelDesc) ) );
let ModelPreviewList=pModel.preview_img;
let TotalPreview=ModelPreviewList.length;
@@ -410,7 +410,8 @@ function ConstructFileHtml( ID, pItem )
{
let fTotal=pItem.length;
let strHtml='';
let pBoard=$('#'+ID+' .FileListBoard');
pBoard.empty();
for( let f=0;f<fTotal;f++ )
{
let pOne=pItem[f];
@@ -443,39 +444,92 @@ function ConstructFileHtml( ID, pItem )
ImgPath='img/default.png';
}
//Add html
//Add html. File names come from the 3MF, so build the nodes rather than concatenating markup.
let pIconImg=$('<img />').attr('src',ImgPath);
let pMenu=$('<div class="FileMenu"><img src="img/s.svg" /></div>');
if( strClass!='ImageIcon' )
{
strHtml+='<div class="FileItem">'+
' <div class="'+strClass+'"><img src="'+ImgPath+'" /></div>'+
' <div class="FileText">'+
' <div class="FileName">'+tName+'</div>'+
' </div>'+
' <div class="FileMenu" onClick="OnClickOpenFile(\''+tPath+'\')"><img src="img/s.svg" /></div>'+
'</div>';
pMenu.on('click', function(){ OnClickOpenFile(tPath); });
}
else
{
ImgID++;
let TmpImgID="AF"+ImgID;
strHtml+='<div class="FileItem">'+
' <div class="'+strClass+'"><img id="'+TmpImgID+'" src="'+ImgPath+'" /></div>'+
' <div class="FileText">'+
' <div class="FileName">'+tName+'</div>'+
' </div>'+
' <div class="FileMenu" onClick="OnClickOpenImage(\''+TmpImgID+'\')"><img src="img/s.svg" /></div>'+
'</div>';
pIconImg.attr('id',TmpImgID);
pMenu.on('click', function(){ OnClickOpenImage(TmpImgID); });
}
let pFileItem=$('<div class="FileItem"></div>');
pFileItem.append( $('<div></div>').addClass(strClass).append(pIconImg) );
pFileItem.append( $('<div class="FileText"></div>').append( $('<div class="FileName"></div>').text(tName) ) );
pFileItem.append( pMenu );
pBoard.append( pFileItem );
}
$('#'+ID+' .FileListBoard').html(strHtml);
if( fTotal>0 )
$('#'+ID).show();
}
// Descriptions are untrusted 3MF metadata that may carry rich-text HTML (e.g. from MakerWorld).
// Rebuild them from an inert parse, keeping only plain formatting tags and http(s) links and images.
var DescAllowedTags=['P','BR','B','STRONG','I','EM','U','S','STRIKE','SUB','SUP','SMALL','MARK',
'H1','H2','H3','H4','H5','H6','UL','OL','LI','BLOCKQUOTE','PRE','CODE','HR','SPAN','DIV',
'TABLE','THEAD','TBODY','TFOOT','TR','TH','TD','A','IMG'];
// Dropped together with their content; any other unknown tag is unwrapped to its children.
var DescDroppedTags=['SCRIPT','STYLE','TEMPLATE','NOSCRIPT','TEXTAREA','TITLE','IFRAME','FRAME','OBJECT','EMBED','SVG','MATH'];
function IsHttpUrl( strUrl )
{
return /^\s*https?:\/\//i.test(strUrl||'');
}
function CopyDescNodes( pSrc, pDst )
{
for( let pNode=pSrc.firstChild;pNode!=null;pNode=pNode.nextSibling )
{
if( pNode.nodeType==Node.TEXT_NODE )
{
pDst.appendChild( document.createTextNode(pNode.nodeValue) );
continue;
}
if( pNode.nodeType!=Node.ELEMENT_NODE )
continue;
let sTag=pNode.nodeName.toUpperCase();
if( $.inArray(sTag,DescDroppedTags)>=0 )
continue;
if( $.inArray(sTag,DescAllowedTags)<0 )
{
CopyDescNodes(pNode,pDst);
continue;
}
let pElem=document.createElement(sTag);
if( sTag=='A' && IsHttpUrl(pNode.getAttribute('href')) )
pElem.setAttribute('href',pNode.getAttribute('href'));
else if( sTag=='IMG' )
{
if( !IsHttpUrl(pNode.getAttribute('src')) )
continue;
pElem.setAttribute('src',pNode.getAttribute('src'));
}
CopyDescNodes(pNode,pElem);
pDst.appendChild(pElem);
}
}
function SanitizeDescHtml( strHtml )
{
let pFragment=document.createDocumentFragment();
// A DOMParser document is inert: it runs no scripts and loads no resources.
let pDoc=new DOMParser().parseFromString(strHtml,'text/html');
if( pDoc && pDoc.body )
CopyDescNodes(pDoc.body,pFragment);
return pFragment;
}
function ShowProfilelInfo( pProfile )
{
//==========Profile Info==========
@@ -483,10 +537,10 @@ function ShowProfilelInfo( pProfile )
let sProfileAuthor=decodeURIComponent(pProfile.author);
let sProfileDesc=decodeURIComponent(pProfile.description);
$('#ProfileName').html(sProfileName);
$('#ProfileAuthor').html(sProfileAuthor);
$('#ProfileName').text(sProfileName);
$('#ProfileAuthor').text(sProfileAuthor);
$('#Profile_Desc').html( html_decode(sProfileDesc) );
$('#Profile_Desc').empty().append( SanitizeDescHtml( html_decode(sProfileDesc) ) );
let ProfilePreviewList=pProfile.preview_img;
let TotalPreview=ProfilePreviewList.length;
+3
View File
@@ -260,6 +260,9 @@ extern bool is_json_file(const std::string& path);
// Both '/' and '\\' are treated as separators on every platform, so an archive rejected on one OS
// is rejected on all of them.
extern bool is_path_within_root(const std::string &rel_path, const boost::filesystem::path &root);
// True if path names an entry strictly inside root: it must be spelled with root as its prefix,
// and must still resolve inside root once symlinks are followed.
extern bool is_absolute_path_within_root(const boost::filesystem::path &path, const boost::filesystem::path &root);
// Orca: custom protocal support utils
inline bool is_orca_open(const std::string& url) { return boost::starts_with(url, "orcaslicer://open"); }
+6
View File
@@ -1112,6 +1112,12 @@ bool is_path_within_root(const std::string &rel_path, const boost::filesystem::p
}
}
bool is_absolute_path_within_root(const boost::filesystem::path &path, const boost::filesystem::path &root)
{
const boost::filesystem::path rel = path.lexically_relative(root);
return !rel.empty() && rel != "." && is_path_within_root(rel.string(), root);
}
bool is_img_file(const std::string &path)
{
return boost::iends_with(path, ".png") || boost::iends_with(path, ".svg");
+5 -1
View File
@@ -293,9 +293,13 @@ void ProjectPanel::OnScriptMessage(wxWebViewEvent& evt)
if (!accessory_path.empty()) {
std::string decode_path = wxGetApp().url_decode(accessory_path.ToStdString());
fs::path path(decode_path);
// Only open the project's own extracted auxiliary files, with the root built as in Reload().
fs::path aux_root(encode_path(wxGetApp().plater()->model().get_auxiliary_file_temp_path().c_str()));
if (fs::exists(path)) {
if (is_absolute_path_within_root(path, aux_root) && fs::is_regular_file(path)) {
wxLaunchDefaultApplication(path.wstring(), 0);
} else {
BOOST_LOG_TRIVIAL(warning) << "open_3mf_accessory: ignoring path outside the project auxiliary directory: " << decode_path;
}
}
}
+45
View File
@@ -152,3 +152,48 @@ TEST_CASE("resolve_cli_input_path leaves inputs that must not be completed uncha
REQUIRE(resolve_cli_input_path("").empty());
}
}
TEST_CASE("is_absolute_path_within_root accepts only entries inside the root", "[utils]") {
namespace fs = boost::filesystem;
ScopedTemporaryDir outer;
const fs::path root = outer.path() / "Auxiliaries";
fs::create_directories(root / "Others");
const fs::path inside = root / "Others" / "note.txt";
const fs::path outside = outer.path() / "secret.txt";
std::ofstream(inside.string()) << "inside";
std::ofstream(outside.string()) << "outside";
SECTION("a file inside the root") {
REQUIRE(is_absolute_path_within_root(inside, root));
}
SECTION("a path inside the root whose file does not exist yet") {
REQUIRE(is_absolute_path_within_root(root / "Others" / "missing.txt", root));
}
SECTION("the root itself") {
REQUIRE_FALSE(is_absolute_path_within_root(root, root));
}
SECTION("a parent-directory escape spelled under the root") {
REQUIRE_FALSE(is_absolute_path_within_root(root / "Others" / ".." / ".." / "secret.txt", root));
}
SECTION("an absolute path elsewhere") {
REQUIRE_FALSE(is_absolute_path_within_root(outside, root));
}
SECTION("a sibling directory sharing the root's name as a prefix") {
const fs::path sibling = outer.path() / "Auxiliaries2" / "note.txt";
REQUIRE_FALSE(is_absolute_path_within_root(sibling, root));
}
SECTION("a relative path") {
REQUIRE_FALSE(is_absolute_path_within_root(fs::path("Others") / "note.txt", root));
}
SECTION("an empty path") {
REQUIRE_FALSE(is_absolute_path_within_root(fs::path(), root));
}
#ifndef _WIN32
// Creating symlinks on Windows needs elevated rights or developer mode.
SECTION("a symlink inside the root that points outside") {
const fs::path link = root / "Others" / "link.txt";
fs::create_symlink(outside, link);
REQUIRE_FALSE(is_absolute_path_within_root(link, root));
}
#endif
}