diff --git a/resources/web/model/model.js b/resources/web/model/model.js index b401bea75b..3d7c2fc8ad 100644 --- a/resources/web/model/model.js +++ b/resources/web/model/model.js @@ -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; @@ -281,16 +281,15 @@ function ShowModelInfo( pModel ) if(TotalPreview>0) { - let htmlPreview=''; + $('#ModelPreviewList').empty(); for(let pn=0;pn'; + $('#ModelPreviewList').append( $('
').append( $('').attr('src',FTmpPath) ) ); } - $('#ModelPreviewList').html(htmlPreview); $('#Model_Preview_Image').viewer({ title: false, fullsreen: false, @@ -410,7 +409,8 @@ function ConstructFileHtml( ID, pItem ) { let fTotal=pItem.length; - let strHtml=''; + let pBoard=$('#'+ID+' .FileListBoard'); + pBoard.empty(); for( let f=0;f').attr('src',ImgPath); + let pMenu=$('
'); if( strClass!='ImageIcon' ) { - strHtml+='
'+ - '
'+ - '
'+ - '
'+tName+'
'+ - '
'+ - '
'+ - '
'; + pMenu.on('click', function(){ OnClickOpenFile(tPath); }); } else { ImgID++; let TmpImgID="AF"+ImgID; - strHtml+='
'+ - '
'+ - '
'+ - '
'+tName+'
'+ - '
'+ - '
'+ - '
'; + pIconImg.attr('id',TmpImgID); + pMenu.on('click', function(){ OnClickOpenImage(TmpImgID); }); } + + let pFileItem=$('
'); + pFileItem.append( $('
').addClass(strClass).append(pIconImg) ); + pFileItem.append( $('
').append( $('
').text(tName).attr('title',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','DEL','INS','SUB','SUP','SMALL','MARK', + 'Q','ABBR','KBD','WBR','H1','H2','H3','H4','H5','H6','UL','OL','LI','DL','DT','DD','BLOCKQUOTE','PRE','CODE', + 'HR','SPAN','DIV','FIGURE','FIGCAPTION','TABLE','CAPTION','THEAD','TBODY','TFOOT','TR','TH','TD','A','IMG']; +// Plain attributes kept per tag; the numeric ones must be plain non-negative integers. +var DescAllowedAttrs={'IMG':['alt','title','width','height'],'TD':['colspan','rowspan'],'TH':['colspan','rowspan'],'OL':['start']}; +var DescNumericAttrs=['width','height','colspan','rowspan','start']; +// 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 ) +{ + // The scheme must be written as is, so nothing the URL parser would strip can precede or split it. + if( typeof strUrl!='string' || !/^https?:/i.test(strUrl) ) + return false; + try + { + let sProtocol=new URL(strUrl).protocol; + return sProtocol=='http:' || sProtocol=='https:'; + } + catch(e) + { + return false; + } +} + +// Images load as soon as the page opens, so only https sources are kept: no plain-http requests to the local network. +function IsHttpsUrl( strUrl ) +{ + return IsHttpUrl(strUrl) && new URL(strUrl).protocol=='https:'; +} + +// Embedded YouTube players become a plain link to the video. +function GetYouTubeEmbedUrl( pNode ) +{ + let sSrc=pNode.getAttribute('src'); + if( !IsHttpUrl(sSrc) ) + return null; + let pUrl=new URL(sSrc); + return ( pUrl.origin=='https://www.youtube.com' && pUrl.pathname.indexOf('/embed/')==0 ) ? pUrl.href : null; +} + +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( sTag=='IFRAME' ) + { + let sVideoUrl=GetYouTubeEmbedUrl(pNode); + if( sVideoUrl!=null ) + { + let pLink=document.createElement('A'); + pLink.setAttribute('href',sVideoUrl); + pLink.textContent=sVideoUrl; + pDst.appendChild(pLink); + } + continue; + } + 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( !IsHttpsUrl(pNode.getAttribute('src')) ) + continue; + pElem.setAttribute('src',pNode.getAttribute('src')); + } + $.each( DescAllowedAttrs[sTag]||[], function(i,sAttr){ + let sValue=pNode.getAttribute(sAttr); + if( sValue!=null && ( $.inArray(sAttr,DescNumericAttrs)<0 || /^\d+$/.test(sValue) ) ) + pElem.setAttribute(sAttr,sValue); + }); + 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 +583,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; @@ -499,15 +599,14 @@ function ShowProfilelInfo( pProfile ) if(TotalPreview>0) { - let htmlPreview=''; + $('#ProfilePreviewList').empty(); for(let pn=0;pn'; + $('#ProfilePreviewList').append( $('
').append( $('').attr('src',FTmpPath) ) ); } - $('#ProfilePreviewList').html(htmlPreview); $('#Profile_Preview_Image').viewer({ title: false, fullsreen: false, diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index eff7f51c2a..c85d33eed2 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -263,6 +263,12 @@ extern bool is_path_within_root(const std::string &rel_path, const boost::filesy // True if a symlink stored at link_rel_path (relative to root) with this target stays inside root: the target // must be relative, and joined to the link's directory it must pass is_path_within_root. extern bool is_symlink_target_within_root(const std::string &link_rel_path, const std::string &target, 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); +// True if a file with this name is of a type that the desktop opens as plain content, so it cannot run code. +// Anything unknown is not safe. +extern bool is_safe_to_open_file_name(const std::string &file_name); // Orca: custom protocal support utils inline bool is_orca_open(const std::string& url) { return boost::starts_with(url, "orcaslicer://open"); } diff --git a/src/libslic3r/utils.cpp b/src/libslic3r/utils.cpp index 303476f023..f56ce6a7af 100644 --- a/src/libslic3r/utils.cpp +++ b/src/libslic3r/utils.cpp @@ -70,6 +70,7 @@ #include #include +#include #include #include #include @@ -1127,6 +1128,28 @@ bool is_symlink_target_within_root(const std::string &link_rel_path, const std:: return is_path_within_root((sep == std::string::npos ? std::string() : link_rel_path.substr(0, sep + 1)) + target, root); } +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_safe_to_open_file_name(const std::string &file_name) +{ + // Formats that cannot carry macros or scripts. Legacy and OpenDocument office files, HTML and SVG are left out on purpose. + static const std::vector safe_extensions = { + "jpg", "jpeg", "jfif", "pjpeg", "pjp", "png", "gif", "bmp", "webp", "tif", "tiff", + "pdf", "txt", "md", "csv", "docx", "xlsx", "pptx", + "stl", "obj", "3mf", "amf", "ply", "step", "stp", "iges", "igs", "dxf", + "mp4", "mov", "webm"}; + // The name must end in the extension itself: Windows drops trailing dots and spaces and reads ':' as a stream separator. + const size_t dot = file_name.find_last_of('.'); + if (dot == std::string::npos || file_name.find_first_of("/\\:") != std::string::npos) + return false; + const std::string extension = boost::algorithm::to_lower_copy(file_name.substr(dot + 1)); + return std::find(safe_extensions.begin(), safe_extensions.end(), extension) != safe_extensions.end(); +} + bool is_img_file(const std::string &path) { return boost::iends_with(path, ".png") || boost::iends_with(path, ".svg"); diff --git a/src/slic3r/GUI/Auxiliary.cpp b/src/slic3r/GUI/Auxiliary.cpp index 444daa4f91..67998b59d9 100644 --- a/src/slic3r/GUI/Auxiliary.cpp +++ b/src/slic3r/GUI/Auxiliary.cpp @@ -428,7 +428,7 @@ void AuFile::on_dclick(wxMouseEvent &evt) if (m_type == AddFileButton) return; else - wxLaunchDefaultApplication(m_file_path.wstring(), 0); + desktop_open_project_attachment(this, m_file_path); } void AuFile::on_mouse_left_up(wxMouseEvent &evt) diff --git a/src/slic3r/GUI/GUI.cpp b/src/slic3r/GUI/GUI.cpp index 5301b91d86..d7bd96f178 100644 --- a/src/slic3r/GUI/GUI.cpp +++ b/src/slic3r/GUI/GUI.cpp @@ -30,11 +30,13 @@ #include "AboutDialog.hpp" #include "MsgDialog.hpp" +#include "Plater.hpp" #include "format.hpp" #include "WebUserLoginDialog.hpp" #include "libslic3r/Print.hpp" +#include "libslic3r/Utils.hpp" namespace Slic3r { @@ -643,4 +645,26 @@ void desktop_open_any_folder( const std::string& path ) } +bool desktop_open_project_attachment(wxWindow *parent, const boost::filesystem::path &path) +{ + // The auxiliary path is UTF-8, which is what boost::filesystem reads a narrow string as. + const boost::filesystem::path aux_root(wxGetApp().plater()->model().get_auxiliary_file_temp_path()); + boost::system::error_code ec; + if (!is_absolute_path_within_root(path, aux_root) || !boost::filesystem::is_regular_file(path, ec)) + return false; + + // Attachments come with the project and carry no download mark, so the desktop would open them without a warning. + if (!is_safe_to_open_file_name(path.filename().string())) { + MessageDialog dlg(parent, + wxString::Format(_L("\"%s\" is not a plain document, image or model file. Opening it may run it as a " + "program or script on this computer.\n\n" + "Only open attachments from projects you trust. Open it anyway?"), + from_path(path.filename())), + _L("Open attachment"), wxICON_WARNING | wxYES_NO); + if (dlg.ShowModal() != wxID_YES) + return false; + } + return wxLaunchDefaultApplication(from_path(path), 0); +} + } } diff --git a/src/slic3r/GUI/GUI.hpp b/src/slic3r/GUI/GUI.hpp index 0b3ac6d7b5..a5bbb35f6f 100644 --- a/src/slic3r/GUI/GUI.hpp +++ b/src/slic3r/GUI/GUI.hpp @@ -86,6 +86,9 @@ extern void about(); extern void desktop_open_datadir_folder(); // Ask the destop to open one folder extern void desktop_open_any_folder(const std::string& path); +// Ask the desktop to open a file from the project's auxiliary directory, after a confirmation +// unless its type is known to be plain content. Returns false if the file was not opened. +extern bool desktop_open_project_attachment(wxWindow *parent, const boost::filesystem::path &path); } // namespace GUI } // namespace Slic3r diff --git a/src/slic3r/GUI/Project.cpp b/src/slic3r/GUI/Project.cpp index 7833cc7499..127400c8b1 100644 --- a/src/slic3r/GUI/Project.cpp +++ b/src/slic3r/GUI/Project.cpp @@ -293,10 +293,8 @@ void ProjectPanel::OnScriptMessage(wxWebViewEvent& evt) if (!accessory_path.empty()) { std::string decode_path = wxGetApp().url_decode(accessory_path.ToStdString()); fs::path path(decode_path); - - if (fs::exists(path)) { - wxLaunchDefaultApplication(path.wstring(), 0); - } + if (!desktop_open_project_attachment(this, path)) + BOOST_LOG_TRIVIAL(warning) << "open_3mf_accessory: not opening " << decode_path; } } else if (strCmd == "request_3mf_info") { diff --git a/tests/libslic3r/test_utils.cpp b/tests/libslic3r/test_utils.cpp index e2b220d482..6256e5f044 100644 --- a/tests/libslic3r/test_utils.cpp +++ b/tests/libslic3r/test_utils.cpp @@ -322,3 +322,68 @@ TEST_CASE("is_symlink_target_within_root rejects a target that passes through a CHECK(is_symlink_target_within_root("link", "in/lib.so", root)); } #endif + +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 +} + +TEST_CASE("is_safe_to_open_file_name accepts plain documents, images and models", "[utils]") { + const std::string safe = GENERATE(as{}, + "Manual.pdf", "BOM.xlsx", "BOM.csv", "guide.docx", "notes.txt", "README.md", "photo.JPG", "render.png", + "assembly.step", "part.stl", "project.3mf", "drawing.dxf", "build.mp4", "setup.exe.pdf", ".pdf"); + INFO(safe); + CHECK(is_safe_to_open_file_name(safe)); +} + +TEST_CASE("is_safe_to_open_file_name rejects programs and anything it does not know", "[utils]") { + const std::string unsafe = GENERATE(as{}, + "setup.exe", "SETUP.EXE", "Manual.pdf.exe", "run.bat", "shortcut.lnk", "site.url", "script.ps1", "help.chm", + "tool.jar", "script.py", "Install.command", "install.sh", "launcher.desktop", "Printer.AppImage", + // Documents that can carry macros or scripts. + "BOM.xls", "BOM.xlsm", "guide.doc", "guide.docm", "sheet.ods", "page.html", "logo.svg", "bundle.zip", + // No extension, an unknown one, or a name the desktop would read differently. + "readme", "pdf", "data.xyz", "", "...", "Manual.pdf.", "Manual.pdf ", "setup.exe:note.txt", "dir.pdf/readme"); + INFO(unsafe); + CHECK_FALSE(is_safe_to_open_file_name(unsafe)); +}