diff --git a/Packages/macOS/CmuxNext/CEFShim/src/artifact_page.h b/Packages/macOS/CmuxNext/CEFShim/src/artifact_page.h new file mode 100644 index 000000000000..11ebe2c6ecfc --- /dev/null +++ b/Packages/macOS/CmuxNext/CEFShim/src/artifact_page.h @@ -0,0 +1,79 @@ +// Offline artifact documents for the shared CEF agent page. No CEF dependency; +// exercised by test-cmux-page-path-cpp.sh alongside the scheme's path policy. +#ifndef CMUX_SHIM_ARTIFACT_PAGE_H_ +#define CMUX_SHIM_ARTIFACT_PAGE_H_ + +#include + +namespace cmux_shim { +inline constexpr char kArtifactFrameURL[] = "cmux-page://cmux.agent/__artifact"; +inline constexpr char kArtifactCSP[] = + "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + "img-src data: blob:; media-src data: blob:; font-src data:; connect-src 'none'; " + "frame-src 'none'; worker-src 'none'; form-action 'none'; base-uri 'none'; " + "frame-ancestors cmux-page://cmux.agent"; + +inline bool IsArtifactFrameURL(const std::string& url) { + return url.substr(0, url.find_first_of("?#")) == kArtifactFrameURL; +} + +inline bool IsAgentPageURL(const std::string& url) { + const std::string origin = "cmux-page://cmux.agent"; + return url.compare(0, origin.size(), origin) == 0 && + (url.size() == origin.size() || url[origin.size()] == '/' || url[origin.size()] == '?' || + url[origin.size()] == '#'); +} + +inline bool ArtifactContextRestricted(const std::string& frame, const std::string& parent) { + return IsArtifactFrameURL(frame) || IsArtifactFrameURL(parent) || + (frame == "about:srcdoc" && IsAgentPageURL(parent)); +} + +// Only an empty, direct child of the agent page may load the bootstrap once. +// After commit, self navigation (including refresh), children and srcdoc +// replacement are denied, regardless of the destination's scheme. +inline bool ArtifactNavigationRefused(bool main, const std::string& frame, const std::string& parent, + const std::string& target) { + if (ArtifactContextRestricted(frame, parent)) return true; + if (IsArtifactFrameURL(target)) { + return main || target != kArtifactFrameURL || !IsAgentPageURL(parent) || + (!frame.empty() && frame != "about:blank"); + } + return false; +} + +inline bool ArtifactResourceAllowed(const std::string& url, bool image_media_or_font) { + return image_media_or_font && (url.rfind("data:", 0) == 0 || url.rfind("blob:", 0) == 0); +} + +// Same one-shot protocol as AgentPaneRenderFrame. The response's sandbox +// makes the origin opaque even if an embedder omits its iframe sandbox. A +// passive preview remains srcdoc with sandbox=""; only Run grants scripts. +inline char kArtifactDocument[] = R"HTML( +)HTML"; +} // namespace cmux_shim +#endif // CMUX_SHIM_ARTIFACT_PAGE_H_ diff --git a/Packages/macOS/CmuxNext/CEFShim/src/cmux_page_path.h b/Packages/macOS/CmuxNext/CEFShim/src/cmux_page_path.h index c7647123d201..6fbd5d143831 100644 --- a/Packages/macOS/CmuxNext/CEFShim/src/cmux_page_path.h +++ b/Packages/macOS/CmuxNext/CEFShim/src/cmux_page_path.h @@ -13,6 +13,8 @@ #include #include +#include "artifact_page.h" + namespace cmux_shim { inline constexpr char kCmuxPageScheme[] = "cmux-page"; @@ -201,6 +203,7 @@ inline bool GrantsCmuxPageLocalNetwork(const std::string& requesting_origin, uin inline bool PageRequestAllowed(bool main_frame, const std::string& frame_url, const std::string& parent_url, const std::string& domain) { if (main_frame) return true; + if (ArtifactContextRestricted(frame_url, parent_url)) return false; const std::string origin = std::string(kCmuxPageScheme) + "://" + domain; auto same = [&](const std::string& url) { return url.size() >= origin.size() && url.compare(0, origin.size(), origin) == 0 && diff --git a/Packages/macOS/CmuxNext/CEFShim/src/shim_client.mm b/Packages/macOS/CmuxNext/CEFShim/src/shim_client.mm index 35f3beb21875..095ac9940087 100644 --- a/Packages/macOS/CmuxNext/CEFShim/src/shim_client.mm +++ b/Packages/macOS/CmuxNext/CEFShim/src/shim_client.mm @@ -9,6 +9,7 @@ #include "include/cef_parser.h" #include "include/cef_resource_request_handler.h" #include "command_line_switches.h" +#include "artifact_page.h" #include "external_url.h" #include "local_file_handoff.h" #include "local_file_scope.h" @@ -165,6 +166,17 @@ cef_return_value_t OnBeforeResourceLoad(CefRefPtr, CefRefPtr frame) { + while (frame) { + CefRefPtr parent = frame->GetParent(); + if (ArtifactContextRestricted(frame->GetURL().ToString(), parent ? parent->GetURL().ToString() : "")) return true; + frame = parent; + } + return false; +} + class Client : public CefClient, public CefDisplayHandler, public CefLoadHandler, @@ -282,6 +294,10 @@ bool OnChromeCommand(CefRefPtr browser, int command_id, cef_window_o // tab in the other store with the same URL. bool OnBeforeBrowse(CefRefPtr browser, CefRefPtr frame, CefRefPtr request, bool user_gesture, bool is_redirect) override { + CefRefPtr parent = frame->GetParent(); + if (RestrictedArtifactFrame(frame) || + ArtifactNavigationRefused(frame->IsMain(), frame->GetURL().ToString(), + parent ? parent->GetURL().ToString() : "", request->GetURL().ToString())) return true; if (!frame->IsMain()) return false; int id = browser->GetIdentifier(); std::string url = request->GetURL().ToString(); @@ -311,6 +327,12 @@ bool OnBeforeBrowse(CefRefPtr browser, CefRefPtr frame, Ce CefRefPtr request, bool, bool, const CefString&, bool&) override { const std::string url = request->GetURL().ToString(); + const auto type = request->GetResourceType(); + if (RestrictedArtifactFrame(frame) && + !ArtifactResourceAllowed(url, type == RT_IMAGE || type == RT_MEDIA || type == RT_FONT_RESOURCE)) { + static CefRefPtr refusal = new LocalFileRefusal(); + return refusal; + } // A request with no frame (a worker, a CefURLRequest) has no folder to // judge it by: its file: loads are refused. if (LocalFileSchemeURL(url) && request->GetResourceType() != RT_MAIN_FRAME && diff --git a/Packages/macOS/CmuxNext/CEFShim/src/shim_page_scheme.mm b/Packages/macOS/CmuxNext/CEFShim/src/shim_page_scheme.mm index 2061ce638b41..73e2449baa7e 100644 --- a/Packages/macOS/CmuxNext/CEFShim/src/shim_page_scheme.mm +++ b/Packages/macOS/CmuxNext/CEFShim/src/shim_page_scheme.mm @@ -167,6 +167,21 @@ int NextToken() { frame_url = frame->GetURL().ToString(); if (CefRefPtr parent = frame->GetParent()) parent_url = parent->GetURL().ToString(); } + const std::string url = request->GetURL().ToString(); + if (IsArtifactFrameURL(url)) { + if (domain_ != "cmux.agent" || request->GetMethod().ToString() != "GET" || + request->GetResourceType() != RT_SUB_FRAME || + ArtifactNavigationRefused(main_frame, frame_url, parent_url, url)) { + static char kBody[] = "Forbidden"; + return Respond(403, "Forbidden", "text/plain", Headers(), kBody, sizeof(kBody) - 1); + } + CefResponse::HeaderMap headers; + headers.emplace("Content-Security-Policy", kArtifactCSP); + headers.emplace("Cache-Control", "no-store"); + headers.emplace("X-Content-Type-Options", "nosniff"); + headers.emplace("Referrer-Policy", "no-referrer"); + return Respond(200, "OK", "text/html", headers, kArtifactDocument, sizeof(kArtifactDocument) - 1); + } if (!PageRequestAllowed(main_frame, frame_url, parent_url, domain_)) { static char kBody[] = "Forbidden"; return Respond(403, "Forbidden", "text/plain", Headers(), kBody, sizeof(kBody) - 1); diff --git a/Packages/macOS/CmuxNext/CEFShim/tests/cmux_page_path_test.cpp b/Packages/macOS/CmuxNext/CEFShim/tests/cmux_page_path_test.cpp index 6acfcc3a3cd9..311e49171412 100644 --- a/Packages/macOS/CmuxNext/CEFShim/tests/cmux_page_path_test.cpp +++ b/Packages/macOS/CmuxNext/CEFShim/tests/cmux_page_path_test.cpp @@ -84,6 +84,51 @@ int main() { // A child frame being navigated: its parent decides. Allowed(false, "about:blank", "cmux-page://cmux.agent/", "cmux.agent", true); Allowed(false, "about:blank", "https://evil.test/", "cmux.agent", false); + // Artifact documents must never inherit the page host's file authority. + Allowed(false, "cmux-page://cmux.agent/__artifact", "cmux-page://cmux.agent/", "cmux.agent", false); + Allowed(false, "about:blank", "cmux-page://cmux.agent/__artifact", "cmux.agent", false); + Allowed(false, "about:srcdoc", "cmux-page://cmux.agent/", "cmux.agent", false); + { + const std::string page = "cmux-page://cmux.agent/"; + const std::string artifact = cmux_shim::kArtifactFrameURL; + auto navigation = [&](bool main, const std::string& current, const std::string& parent, + const std::string& target, bool refused) { + Expect(cmux_shim::ArtifactNavigationRefused(main, current, parent, target) == refused, + "artifact navigation " + current + " -> " + target + " parent " + parent); + }; + navigation(false, "about:blank", page, artifact, false); + navigation(false, "", page, artifact, false); + navigation(true, "about:blank", "", artifact, true); + for (const auto& parent : {"https://evil.test/", "cmux-page://cmux.agent.evil/", + "cmux-page://cmux.history/", "cmux-page://evil@cmux.agent/"}) { + navigation(false, "about:blank", parent, artifact, true); + } + for (const auto& target : {artifact, artifact + "?copy=1", page, std::string("about:srcdoc"), + std::string("https://example.test/"), std::string("file:///tmp/a.html"), + std::string("data:text/html,replace"), std::string("javascript:alert(1)")}) { + navigation(false, artifact, page, target, true); + navigation(false, "about:srcdoc", page, target, true); + navigation(false, "about:blank", artifact, target, true); + } + navigation(false, "about:blank", page, artifact + "?copy=1", true); + navigation(false, page, "", "https://example.test/", false); + navigation(false, "about:blank", page, "http://localhost:3000/", false); + for (const auto& url : {"https://example.test/a.png", "http://127.0.0.1:4000/api", "file:///tmp/a.png", + "cmux-page://cmux.agent/index.html", "cmux-agent://pane/index.html"}) { + Expect(!cmux_shim::ArtifactResourceAllowed(url, true), "offline artifact refuses " + std::string(url)); + } + Expect(cmux_shim::ArtifactResourceAllowed("data:image/png;base64,AA", true), "inline image allowed"); + Expect(cmux_shim::ArtifactResourceAllowed("blob:null/test", true), "inline media allowed"); + Expect(!cmux_shim::ArtifactResourceAllowed("data:text/html,nested", false), "inline document refused"); + Expect(!cmux_shim::ArtifactResourceAllowed("blob:null/script", false), "blob script refused"); + const std::string policy = cmux_shim::kArtifactCSP; + Expect(policy.find("sandbox allow-scripts;") == 0 && policy.find("allow-same-origin") == std::string::npos, + "artifact response enforces opaque origin"); + Expect(policy.find("connect-src 'none'") != std::string::npos && + policy.find("frame-src 'none'") != std::string::npos && + policy.find("form-action 'none'") != std::string::npos, + "artifact response refuses connections, children and forms"); + } { auto headers = cmux_shim::PageResponseHeaders("default-src 'self'"); bool xfo = false, corp = false, nosniff = false, csp = false;