From a1bb72bbba47a78c4f3fde45270c8231fe1715eb Mon Sep 17 00:00:00 2001 From: Will Chen Date: Fri, 2 Aug 2024 22:49:01 -0700 Subject: [PATCH 1/2] Load JS modules as needed --- .../code_mirror_editor_app.py | 6 ++++- .../copy_to_clipboard_app.py | 5 ++++ .../custom_font_csp_repro/custom_font_app.py | 5 ++++ .../firebase_auth/firebase_auth_app.py | 6 ++++- .../web_component/plotly/plotly_app.py | 8 ++++++- .../quickstart/counter_component_app.py | 5 ++++ .../shared_js_module/shared_js_module_app.py | 5 ++++ mesop/examples/web_component/slot/slot_app.py | 5 ++++ mesop/labs/web_component.py | 6 +++-- mesop/protos/ui.proto | 2 ++ mesop/runtime/context.py | 10 ++++++++ mesop/runtime/runtime.py | 4 +--- mesop/server/constants.py | 2 ++ mesop/server/server.py | 10 ++++++++ mesop/server/static_file_serving.py | 14 +---------- .../e2e/web_components/csp_violations_test.ts | 24 +++++++++++++++++++ .../web_components/shared_js_module_test.ts | 4 ++-- mesop/web/src/services/channel.ts | 7 +++++- mesop/web/src/shell/shell.ts | 21 +++++++++++++++- 19 files changed, 124 insertions(+), 25 deletions(-) create mode 100644 mesop/tests/e2e/web_components/csp_violations_test.ts diff --git a/mesop/examples/web_component/code_mirror_editor/code_mirror_editor_app.py b/mesop/examples/web_component/code_mirror_editor/code_mirror_editor_app.py index 6e09bc9d9..da67fabb1 100644 --- a/mesop/examples/web_component/code_mirror_editor/code_mirror_editor_app.py +++ b/mesop/examples/web_component/code_mirror_editor/code_mirror_editor_app.py @@ -20,7 +20,11 @@ class State: "https://cdnjs.cloudflare.com", "*.fonts.gstatic.com", ], - allowed_script_srcs=["https://cdnjs.cloudflare.com", "*.fonts.gstatic.com"], + allowed_script_srcs=[ + "https://cdnjs.cloudflare.com", + "*.fonts.gstatic.com", + "https://cdn.jsdelivr.net", + ], ), ) def page(): diff --git a/mesop/examples/web_component/copy_to_clipboard/copy_to_clipboard_app.py b/mesop/examples/web_component/copy_to_clipboard/copy_to_clipboard_app.py index 63f67e5e2..a4449fdd5 100644 --- a/mesop/examples/web_component/copy_to_clipboard/copy_to_clipboard_app.py +++ b/mesop/examples/web_component/copy_to_clipboard/copy_to_clipboard_app.py @@ -15,6 +15,11 @@ @me.page( path="/web_component/copy_to_clipboard/copy_to_clipboard_app", + security_policy=me.SecurityPolicy( + allowed_script_srcs=[ + "https://cdn.jsdelivr.net", + ] + ), ) def page(): with me.box( diff --git a/mesop/examples/web_component/custom_font_csp_repro/custom_font_app.py b/mesop/examples/web_component/custom_font_csp_repro/custom_font_app.py index 2f66e42fa..620d69d8c 100644 --- a/mesop/examples/web_component/custom_font_csp_repro/custom_font_app.py +++ b/mesop/examples/web_component/custom_font_csp_repro/custom_font_app.py @@ -17,6 +17,11 @@ stylesheets=[ "https://fonts.googleapis.com/css2?family=Inter+Tight:ital,wght@0,100..900;1,100..900&family=Inter:wght@100..900&display=swap", ], + security_policy=me.SecurityPolicy( + allowed_script_srcs=[ + "https://cdn.jsdelivr.net", + ] + ), ) def page(): me.text("Custom font: Inter Tight", style=me.Style(font_family="Inter Tight")) diff --git a/mesop/examples/web_component/firebase_auth/firebase_auth_app.py b/mesop/examples/web_component/firebase_auth/firebase_auth_app.py index c3509b162..1c5d54b9d 100644 --- a/mesop/examples/web_component/firebase_auth/firebase_auth_app.py +++ b/mesop/examples/web_component/firebase_auth/firebase_auth_app.py @@ -21,7 +21,11 @@ security_policy=me.SecurityPolicy( dangerously_disable_trusted_types=True, allowed_connect_srcs=["*.googleapis.com"], - allowed_script_srcs=["*.google.com"], + allowed_script_srcs=[ + "*.google.com", + "https://www.gstatic.com", + "https://cdn.jsdelivr.net", + ], ), ) def page(): diff --git a/mesop/examples/web_component/plotly/plotly_app.py b/mesop/examples/web_component/plotly/plotly_app.py index e7d07afd7..a86d16b25 100644 --- a/mesop/examples/web_component/plotly/plotly_app.py +++ b/mesop/examples/web_component/plotly/plotly_app.py @@ -11,7 +11,13 @@ # # Disabling trusted types because plotly uses DomParser#parseFromString # which violates TrustedHTML assignment. - security_policy=me.SecurityPolicy(dangerously_disable_trusted_types=True), + security_policy=me.SecurityPolicy( + allowed_script_srcs=[ + "https://cdn.jsdelivr.net", + "https://cdn.plot.ly", + ], + dangerously_disable_trusted_types=True, + ), ) def page(): plotly_component() diff --git a/mesop/examples/web_component/quickstart/counter_component_app.py b/mesop/examples/web_component/quickstart/counter_component_app.py index 9ddbd8bc8..cf4cd3604 100644 --- a/mesop/examples/web_component/quickstart/counter_component_app.py +++ b/mesop/examples/web_component/quickstart/counter_component_app.py @@ -9,6 +9,11 @@ @me.page( path="/web_component/quickstart/counter_component_app", + security_policy=me.SecurityPolicy( + allowed_script_srcs=[ + "https://cdn.jsdelivr.net", + ] + ), ) def page(): counter_component( diff --git a/mesop/examples/web_component/shared_js_module/shared_js_module_app.py b/mesop/examples/web_component/shared_js_module/shared_js_module_app.py index 7969bdc43..2aaa1af8f 100644 --- a/mesop/examples/web_component/shared_js_module/shared_js_module_app.py +++ b/mesop/examples/web_component/shared_js_module/shared_js_module_app.py @@ -6,6 +6,11 @@ @me.page( path="/web_component/shared_js_module/shared_js_module_app", + security_policy=me.SecurityPolicy( + allowed_script_srcs=[ + "https://cdn.jsdelivr.net", + ] + ), ) def page(): me.text("Loaded") diff --git a/mesop/examples/web_component/slot/slot_app.py b/mesop/examples/web_component/slot/slot_app.py index 3b6eb1e6e..75c674928 100644 --- a/mesop/examples/web_component/slot/slot_app.py +++ b/mesop/examples/web_component/slot/slot_app.py @@ -12,6 +12,11 @@ @me.page( path="/web_component/slot/slot_app", + security_policy=me.SecurityPolicy( + allowed_script_srcs=[ + "https://cdn.jsdelivr.net", + ] + ), ) def page(): with outer_component( diff --git a/mesop/labs/web_component.py b/mesop/labs/web_component.py index ee5b491d3..e613e7d31 100644 --- a/mesop/labs/web_component.py +++ b/mesop/labs/web_component.py @@ -20,6 +20,8 @@ def web_component(*, path: str, skip_validation: bool = False): path: The path to the JavaScript file of the web component. skip_validation: If set to True, skips validation. Defaults to False. """ + runtime().check_register_web_component_is_valid() + current_frame = inspect.currentframe() assert current_frame previous_frame = current_frame.f_back @@ -31,14 +33,14 @@ def web_component(*, path: str, skip_validation: bool = False): full_path = os.path.normpath(os.path.join(caller_module_dir, path)) if not full_path.startswith("/"): full_path = "/" + full_path - - runtime().register_js_module(full_path) + js_module_path = full_path def component_wrapper(fn: C) -> C: validated_fn = fn if skip_validation else validate(fn) @wraps(fn) def wrapper(*args: Any, **kw_args: Any): + runtime().context().register_js_module(js_module_path) return validated_fn(*args, **kw_args) return cast(C, wrapper) diff --git a/mesop/protos/ui.proto b/mesop/protos/ui.proto index dcfa4a299..378364cf1 100644 --- a/mesop/protos/ui.proto +++ b/mesop/protos/ui.proto @@ -131,6 +131,8 @@ message RenderEvent { optional string title = 3; repeated Command commands = 4; + repeated string js_modules = 6; + // Only sent in editor mode: repeated ComponentConfig component_configs = 5; } diff --git a/mesop/runtime/context.py b/mesop/runtime/context.py index 92134c766..7a18e1fc1 100644 --- a/mesop/runtime/context.py +++ b/mesop/runtime/context.py @@ -37,6 +37,16 @@ def __init__( self._node_slot_children_count: int | None = None self._viewport_size: pb.ViewportSize | None = None self._theme_settings: pb.ThemeSettings | None = None + self._js_modules: set[str] = set() + + def register_js_module(self, js_module_path: str) -> None: + self._js_modules.add(js_module_path) + + def js_modules(self) -> set[str]: + return self._js_modules + + def clear_js_modules(self): + self._js_modules = set() def commands(self) -> list[pb.Command]: return self._commands diff --git a/mesop/runtime/runtime.py b/mesop/runtime/runtime.py index fe9efc61a..952de19be 100644 --- a/mesop/runtime/runtime.py +++ b/mesop/runtime/runtime.py @@ -48,7 +48,6 @@ def __init__(self): self.hot_reload_counter = 0 self._path_to_page_config: dict[str, PageConfig] = {} self.component_fns: set[Callable[..., Any]] = set() - self.js_modules: set[str] = set() self._handlers: dict[str, Handler] = {} self.event_mappers: dict[Type[Any], Callable[[pb.UserEvent, Key], Any]] = {} self._state_classes: list[type[Any]] = [] @@ -146,12 +145,11 @@ def get_loading_errors(self) -> list[pb.ServerError]: def register_native_component_fn(self, component_fn: Callable[..., Any]): self.component_fns.add(component_fn) - def register_js_module(self, js_module: str) -> None: + def check_register_web_component_is_valid(self) -> None: if self._has_served_traffic: raise MesopDeveloperException( "Cannot register a JS module after traffic has been served. You must define all web components upon server startup before any traffic has been served. This prevents security issues." ) - self.js_modules.add(js_module) def get_component_fns(self) -> set[Callable[..., Any]]: return self.component_fns diff --git a/mesop/server/constants.py b/mesop/server/constants.py index 94b246b52..febe20e43 100644 --- a/mesop/server/constants.py +++ b/mesop/server/constants.py @@ -1,2 +1,4 @@ EDITOR_PACKAGE_PATH = "mesop/mesop/web/src/app/editor/web_package" PROD_PACKAGE_PATH = "mesop/mesop/web/src/app/prod/web_package" + +WEB_COMPONENTS_PATH_SEGMENT = "__web-components-module__" diff --git a/mesop/server/server.py b/mesop/server/server.py index 2bbb84c52..6cc114b80 100644 --- a/mesop/server/server.py +++ b/mesop/server/server.py @@ -13,6 +13,7 @@ from mesop.exceptions import format_traceback from mesop.runtime import runtime from mesop.server.config import app_config +from mesop.server.constants import WEB_COMPONENTS_PATH_SEGMENT from mesop.warn import warn LOCALHOSTS = ( @@ -58,6 +59,11 @@ def render_loop( # (e.g. scroll into view) for the same context (e.g. multiple render loops # when processing a generator handler function) runtime().context().clear_commands() + js_modules = runtime().context().js_modules() + # Similar to above, clear JS modules after sending it once to minimize payload. + # Although it shouldn't cause any issue because client-side, each js module + # should only be imported once. + runtime().context().clear_js_modules() data = pb.UiResponse( render=pb.RenderEvent( root_component=root_component, @@ -67,6 +73,10 @@ def render_loop( if prod_mode or not init_request else get_component_configs(), title=title, + js_modules=[ + f"/{WEB_COMPONENTS_PATH_SEGMENT}{js_module}" + for js_module in js_modules + ], ) ) yield serialize(data) diff --git a/mesop/server/static_file_serving.py b/mesop/server/static_file_serving.py index b521f641a..fc03dfea4 100644 --- a/mesop/server/static_file_serving.py +++ b/mesop/server/static_file_serving.py @@ -13,12 +13,10 @@ from mesop.exceptions import MesopException from mesop.runtime import runtime +from mesop.server.constants import WEB_COMPONENTS_PATH_SEGMENT from mesop.utils.runfiles import get_runfile_location, has_runfiles from mesop.utils.url_utils import sanitize_url_for_csp -WEB_COMPONENTS_PATH_SEGMENT = "__web-components-module__" - - # mimetypes are not always set correctly, thus manually # setting the mimetype here. # See: https://github.com/google/mesop/issues/441 @@ -51,16 +49,6 @@ def retrieve_index_html() -> io.BytesIO | str: for i, line in enumerate(lines): if "$$INSERT_CSP_NONCE$$" in line: lines[i] = lines[i].replace("$$INSERT_CSP_NONCE$$", g.csp_nonce) - if ( - runtime().js_modules - and line.strip() == "" - ): - lines[i] = "\n".join( - [ - f"" - for js_module in runtime().js_modules - ] - ) if ( livereload_script_url and line.strip() == "" diff --git a/mesop/tests/e2e/web_components/csp_violations_test.ts b/mesop/tests/e2e/web_components/csp_violations_test.ts new file mode 100644 index 000000000..e657d16cc --- /dev/null +++ b/mesop/tests/e2e/web_components/csp_violations_test.ts @@ -0,0 +1,24 @@ +import {test, expect} from '@playwright/test'; + +// Prevent regression where JS modules were loaded on every page, +// and not just pages where it was needed. +test('test CSP violations (from web components) are not shown on home page', async ({ + page, +}) => { + const cspViolations: string[] = []; + + // Listen for CSP violations + page.on('console', (msg) => { + if ( + msg.type() === 'error' && + msg.text().includes('Content Security Policy') + ) { + cspViolations.push(msg.text()); + } + }); + + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + expect(cspViolations).toHaveLength(0); +}); diff --git a/mesop/tests/e2e/web_components/shared_js_module_test.ts b/mesop/tests/e2e/web_components/shared_js_module_test.ts index f52c9de60..79f7dc4f5 100644 --- a/mesop/tests/e2e/web_components/shared_js_module_test.ts +++ b/mesop/tests/e2e/web_components/shared_js_module_test.ts @@ -2,8 +2,8 @@ import {test, expect} from '@playwright/test'; test('web components - shared JS module', async ({page}) => { await page.goto('/web_component/shared_js_module/shared_js_module_app'); - expect(page.getByText('Loaded')).toBeVisible(); - expect( + await expect(page.getByText('Loaded')).toBeVisible(); + await expect( page.getByText('value from shared module: shared_module.js'), ).toBeVisible(); }); diff --git a/mesop/web/src/services/channel.ts b/mesop/web/src/services/channel.ts index 4614f2758..61bf0b8eb 100644 --- a/mesop/web/src/services/channel.ts +++ b/mesop/web/src/services/channel.ts @@ -29,6 +29,7 @@ interface InitParams { onRender: ( rootComponent: ComponentProto, componentConfigs: readonly ComponentConfig[], + jsModules: readonly string[], ) => void; onError: (error: ServerError) => void; onCommand: (command: Command) => void; @@ -200,7 +201,11 @@ export class Channel { this.componentConfigs = uiResponse .getRender()! .getComponentConfigsList(); - onRender(this.rootComponent, this.componentConfigs); + onRender( + this.rootComponent, + this.componentConfigs, + uiResponse.getRender()!.getJsModulesList(), + ); this.logger.log({ type: 'RenderLog', states: this.states, diff --git a/mesop/web/src/shell/shell.ts b/mesop/web/src/shell/shell.ts index 0fcb629da..29a303ee6 100644 --- a/mesop/web/src/shell/shell.ts +++ b/mesop/web/src/shell/shell.ts @@ -88,7 +88,26 @@ export class Shell { this.channel.init( { zone: this.zone, - onRender: (rootComponent, componentConfigs) => { + onRender: async (rootComponent, componentConfigs, jsModules) => { + // Import all JS modules concurrently + await Promise.all( + jsModules.map((modulePath) => + import(modulePath) + .then(() => + console.debug( + `Successfully imported JS module: ${modulePath}`, + ), + ) + .catch((error) => + console.error( + `Failed to import JS module: ${modulePath}`, + error, + ), + ), + ), + ).then(() => { + console.debug('All JS modules imported'); + }); this.rootComponent = rootComponent; // Component configs are only sent for the first response. // For subsequent reponses, use the component configs previously From a688220e4b7ad16d34d81e1494c4e2db262ebb3a Mon Sep 17 00:00:00 2001 From: Will Chen Date: Sat, 3 Aug 2024 20:59:29 -0700 Subject: [PATCH 2/2] clean --- mesop/runtime/runtime.py | 2 +- mesop/web/src/app/editor/index.html | 1 - mesop/web/src/app/prod/index.html | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/mesop/runtime/runtime.py b/mesop/runtime/runtime.py index 952de19be..c79ab5319 100644 --- a/mesop/runtime/runtime.py +++ b/mesop/runtime/runtime.py @@ -148,7 +148,7 @@ def register_native_component_fn(self, component_fn: Callable[..., Any]): def check_register_web_component_is_valid(self) -> None: if self._has_served_traffic: raise MesopDeveloperException( - "Cannot register a JS module after traffic has been served. You must define all web components upon server startup before any traffic has been served. This prevents security issues." + "Cannot register a web component after traffic has been served. You must define all web components upon server startup before any traffic has been served. This prevents security issues." ) def get_component_fns(self) -> set[Callable[..., Any]]: diff --git a/mesop/web/src/app/editor/index.html b/mesop/web/src/app/editor/index.html index 5de9c0922..1c743f6c2 100644 --- a/mesop/web/src/app/editor/index.html +++ b/mesop/web/src/app/editor/index.html @@ -22,6 +22,5 @@ - diff --git a/mesop/web/src/app/prod/index.html b/mesop/web/src/app/prod/index.html index d3436b473..f0b0c78a1 100644 --- a/mesop/web/src/app/prod/index.html +++ b/mesop/web/src/app/prod/index.html @@ -20,6 +20,5 @@ -