From a9b13f25b1fc7b242840c450a025be8c2b01757d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Tue, 14 Jul 2026 11:06:53 +0200 Subject: [PATCH 1/3] security: move OG image execution behind flag --- docs/guides/publishing/opengraph.md | 8 +++++++- marimo/_cli/cli.py | 9 +++++++++ marimo/_metadata/opengraph.py | 5 +++-- marimo/_server/api/endpoints/assets.py | 6 ++++++ marimo/_server/api/endpoints/home.py | 3 +++ marimo/_server/asgi.py | 4 ++++ marimo/_server/session_manager.py | 2 ++ marimo/_server/start.py | 2 ++ marimo/_server/templates/templates.py | 4 ++++ tests/_metadata/test_opengraph.py | 6 +++--- tests/_server/api/endpoints/test_home.py | 23 ++++++++++++++++++++++- 11 files changed, 65 insertions(+), 7 deletions(-) diff --git a/docs/guides/publishing/opengraph.md b/docs/guides/publishing/opengraph.md index b3f34ac1f75..809927c2f35 100644 --- a/docs/guides/publishing/opengraph.md +++ b/docs/guides/publishing/opengraph.md @@ -54,7 +54,13 @@ For execution and sandbox options (and for Playwright installation instructions) ## Dynamic metadata -For dynamic previews, you can provide a generator function in script metadata. Generators must be [top-level functions](../reusing_functions.md) (decorated with `@app.function`) and cannot depend on values defined in regular cells, so that they can be safely executed in the marimo CLI without running the whole notebook. +For dynamic previews, you can provide a generator function in script metadata. Dynamic metadata executes Python while the server resolves previews, so the host must enable it explicitly for notebooks from directories you trust: + +```bash +marimo run --execute-opengraph-generators folder/ +``` + +For programmatic deployments, pass `execute_opengraph_generators=True` to `marimo.create_asgi_app()`. Generators must be [top-level functions](../reusing_functions.md) decorated with `@app.function`. They run outside normal cell execution, so values defined in regular cells are unavailable at metadata resolution time. In script metadata, point `generator` at the name of a function defined in the notebook: diff --git a/marimo/_cli/cli.py b/marimo/_cli/cli.py index 048d44aa4a4..604a7b3f689 100644 --- a/marimo/_cli/cli.py +++ b/marimo/_cli/cli.py @@ -1102,6 +1102,13 @@ def _create_run_workspace( hidden=True, help="Custom asset URL for loading static resources. Can include {version} placeholder.", ) +@click.option( + "--execute-opengraph-generators", + is_flag=True, + default=False, + type=bool, + help="Execute notebook-defined OpenGraph generators while resolving metadata.", +) @click.option( "--show-tracebacks/--no-show-tracebacks", is_flag=True, @@ -1137,6 +1144,7 @@ def run( trusted: bool | None, server_startup_command: str | None, asset_url: str | None, + execute_opengraph_generators: bool, show_tracebacks: bool | None, name: str, args: tuple[str, ...], @@ -1281,6 +1289,7 @@ def run( sandbox_mode=sandbox_mode, startup_tip=choose_startup_tip(click.get_current_context()), show_tracebacks=show_tracebacks, + execute_opengraph_generators=execute_opengraph_generators, ) diff --git a/marimo/_metadata/opengraph.py b/marimo/_metadata/opengraph.py index 2746a327994..b7e401493ac 100644 --- a/marimo/_metadata/opengraph.py +++ b/marimo/_metadata/opengraph.py @@ -489,8 +489,9 @@ def resolve_opengraph_metadata( *, app_title: str | None = None, context: OpenGraphContext | None = None, + execute_generator: bool = False, ) -> OpenGraphMetadata: - """Resolve OpenGraph metadata from config, defaults, and a generator hook.""" + """Resolve OpenGraph metadata and optionally execute a generator hook.""" declared = read_opengraph_from_file(filepath) or OpenGraphConfig() title = declared.title or app_title or derive_title_from_path(filepath) @@ -505,7 +506,7 @@ def resolve_opengraph_metadata( image=image, ) - if declared.generator: + if declared.generator and execute_generator: ctx = context or OpenGraphContext(filepath=filepath) dynamic = _run_opengraph_generator( declared.generator, context=ctx, parent=resolved diff --git a/marimo/_server/api/endpoints/assets.py b/marimo/_server/api/endpoints/assets.py index f52e29d3332..4286cf447f2 100644 --- a/marimo/_server/api/endpoints/assets.py +++ b/marimo/_server/api/endpoints/assets.py @@ -228,6 +228,9 @@ def og_thumbnail(*, request: Request) -> Response: base_url=app_state.base_url, mode=app_state.mode.value, ), + execute_generator=( + app_state.session_manager.execute_opengraph_generators + ), ) title = opengraph.title or "marimo" image = opengraph.image @@ -407,6 +410,9 @@ async def index(request: Request) -> Response: else None, asset_url=app_state.asset_url, html_head=app_state.html_head, + execute_opengraph_generators=( + app_state.session_manager.execute_opengraph_generators + ), ) # Inject service worker registration with the notebook ID diff --git a/marimo/_server/api/endpoints/home.py b/marimo/_server/api/endpoints/home.py index a16b6603135..1acee1bf93f 100644 --- a/marimo/_server/api/endpoints/home.py +++ b/marimo/_server/api/endpoints/home.py @@ -133,6 +133,9 @@ def get_files_with_metadata() -> list[FileInfo]: base_url=base_url, mode=mode, ), + execute_generator=( + session_manager.execute_opengraph_generators + ), ) result.append( FileInfo( diff --git a/marimo/_server/asgi.py b/marimo/_server/asgi.py index 3a2d57a1784..6c0ddaf39c7 100644 --- a/marimo/_server/asgi.py +++ b/marimo/_server/asgi.py @@ -362,6 +362,7 @@ def create_asgi_app( redirect_console_to_browser: bool = False, show_tracebacks: bool = False, html_head: str | None = None, + execute_opengraph_generators: bool = False, ) -> ASGIAppBuilder: """Public API to create an ASGI app that can serve multiple notebooks. This only works for application that are in Run mode. @@ -384,6 +385,8 @@ def create_asgi_app( This is useful for adding global analytics scripts, custom stylesheets, meta tags, etc. When a notebook also has its own `html_head_file` config, the global `html_head` is injected first, followed by the per-notebook content. + execute_opengraph_generators (bool, optional): Execute notebook-defined OpenGraph generators while resolving metadata. + Enable this for notebooks from directories you trust. Returns: ASGIAppBuilder: A builder object to create multiple ASGI apps @@ -562,6 +565,7 @@ def _create_app_for_file(base_url: str, file_path: str) -> ASGIApp: isolate_apps=config_reader.experimental.get( "isolate_apps", False ), + execute_opengraph_generators=execute_opengraph_generators, ) enable_auth = not AuthToken.is_empty(auth_token) app = create_starlette_app( diff --git a/marimo/_server/session_manager.py b/marimo/_server/session_manager.py index f65cead6e59..c897b17113a 100644 --- a/marimo/_server/session_manager.py +++ b/marimo/_server/session_manager.py @@ -85,6 +85,7 @@ def __init__( watch: bool = False, sandbox_mode: SandboxMode | None = None, isolate_apps: bool = False, + execute_opengraph_generators: bool = False, ) -> None: # Core configuration self.workspace = workspace @@ -98,6 +99,7 @@ def __init__( self.redirect_console_to_browser = redirect_console_to_browser self._config_manager = config_manager self.sandbox_mode = sandbox_mode + self.execute_opengraph_generators = execute_opengraph_generators # When running multiple apps, each app runs in an isolated host # process, to avoid collisions in sys.modules and other Python global diff --git a/marimo/_server/start.py b/marimo/_server/start.py index 711bd183118..5cca29565b7 100644 --- a/marimo/_server/start.py +++ b/marimo/_server/start.py @@ -214,6 +214,7 @@ def start( sandbox_mode: SandboxMode | None = None, startup_tip: CliTip | None = None, show_tracebacks: bool | None = None, + execute_opengraph_generators: bool = False, ) -> None: """ Start the server. @@ -322,6 +323,7 @@ def start( watch=watch, sandbox_mode=sandbox_mode, isolate_apps=isolate_apps, + execute_opengraph_generators=execute_opengraph_generators, ) log_level = "info" if development_mode else "error" diff --git a/marimo/_server/templates/templates.py b/marimo/_server/templates/templates.py index 30473bef217..3389a9f4fbf 100644 --- a/marimo/_server/templates/templates.py +++ b/marimo/_server/templates/templates.py @@ -197,6 +197,7 @@ def opengraph_metadata_template( app_config: _AppConfig, filename: str | None, filepath: str | None, + execute_opengraph_generators: bool = False, ) -> str: """Return OpenGraph `` tags for a notebook, or an empty string.""" if not filepath: @@ -221,6 +222,7 @@ def opengraph_metadata_template( base_url=base_url, mode=mode.value, ), + execute_generator=execute_opengraph_generators, ) except Exception: return "" @@ -277,6 +279,7 @@ def notebook_page_template( runtime_config: list[dict[str, Any]] | None = None, asset_url: str | None = None, html_head: str | None = None, + execute_opengraph_generators: bool = False, ) -> str: html = html.replace("{{ base_url }}", base_url) @@ -337,6 +340,7 @@ def notebook_page_template( app_config=app_config, filename=filename, filepath=filepath, + execute_opengraph_generators=execute_opengraph_generators, ) if opengraph_tags: html = html.replace("", f"{opengraph_tags}\n") diff --git a/tests/_metadata/test_opengraph.py b/tests/_metadata/test_opengraph.py index e8616177453..39713f233d5 100644 --- a/tests/_metadata/test_opengraph.py +++ b/tests/_metadata/test_opengraph.py @@ -200,7 +200,7 @@ def generate_opengraph(ctx, parent): path = tmp_path / "notebook.py" path.write_text(script, encoding="utf-8") - resolved = resolve_opengraph_metadata(str(path)) + resolved = resolve_opengraph_metadata(str(path), execute_generator=True) assert resolved.title == "Static Title" assert resolved.image == "https://example.com/generated.png" @@ -225,7 +225,7 @@ def generate_opengraph(ctx, parent, extra): path = tmp_path / "notebook.py" path.write_text(script, encoding="utf-8") - resolved = resolve_opengraph_metadata(str(path)) + resolved = resolve_opengraph_metadata(str(path), execute_generator=True) assert resolved.title == "Static Title" assert resolved.image is None @@ -254,6 +254,6 @@ def generate_opengraph(ctx, parent): path = tmp_path / "notebook.py" path.write_text(script, encoding="utf-8") - resolved = resolve_opengraph_metadata(str(path)) + resolved = resolve_opengraph_metadata(str(path), execute_generator=True) assert resolved.title == "Static Title" assert resolved.image == "https://example.com/3.png" diff --git a/tests/_server/api/endpoints/test_home.py b/tests/_server/api/endpoints/test_home.py index 9533ebb99f8..e1ee1161fc8 100644 --- a/tests/_server/api/endpoints/test_home.py +++ b/tests/_server/api/endpoints/test_home.py @@ -124,8 +124,27 @@ def test_workspace_files_in_run_mode(client: TestClient) -> None: session_manager.mode = SessionMode.RUN with tempfile.TemporaryDirectory() as temp_dir: + marker = Path(temp_dir) / "marker.txt" marimo_file = Path(temp_dir) / "notebook.py" - marimo_file.write_text("import marimo\napp = marimo.App()") + marimo_file.write_text( + textwrap.dedent( + f""" + # /// script + # [tool.marimo.opengraph] + # title = "Static Title" + # generator = "generate_opengraph" + # /// + import marimo + app = marimo.App() + with app.setup: + open({str(marker)!r}, "w").write("executed") + @app.function + def generate_opengraph(context, parent): + return {{"title": "Dynamic Title"}} + """ + ).lstrip(), + encoding="utf-8", + ) non_marimo_file = Path(temp_dir) / "text.txt" non_marimo_file.write_text("This is not a marimo file") @@ -145,6 +164,8 @@ def test_workspace_files_in_run_mode(client: TestClient) -> None: assert len(files) == 1 assert body["root"] == temp_dir assert files[0]["path"] == marimo_file.name + assert files[0]["opengraph"]["title"] == "Static Title" + assert not marker.exists() @with_session(SESSION_ID) From 2698e8218f20698d0077ef8f42726be30a713da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Tue, 14 Jul 2026 12:12:31 +0200 Subject: [PATCH 2/3] address comments --- docs/guides/publishing/opengraph.md | 8 ++----- marimo/_cli/cli.py | 4 +--- marimo/_metadata/opengraph.py | 2 +- tests/_server/api/endpoints/test_home.py | 28 ++++++++++-------------- 4 files changed, 16 insertions(+), 26 deletions(-) diff --git a/docs/guides/publishing/opengraph.md b/docs/guides/publishing/opengraph.md index 809927c2f35..99939438ba9 100644 --- a/docs/guides/publishing/opengraph.md +++ b/docs/guides/publishing/opengraph.md @@ -54,13 +54,9 @@ For execution and sandbox options (and for Playwright installation instructions) ## Dynamic metadata -For dynamic previews, you can provide a generator function in script metadata. Dynamic metadata executes Python while the server resolves previews, so the host must enable it explicitly for notebooks from directories you trust: +For dynamic previews, you can provide a generator function in script metadata. Dynamic metadata executes Python while the server resolves previews, so enable it only for notebooks you trust. Run `marimo run --execute-opengraph-generators folder/`, or pass `execute_opengraph_generators=True` to `marimo.create_asgi_app()`. -```bash -marimo run --execute-opengraph-generators folder/ -``` - -For programmatic deployments, pass `execute_opengraph_generators=True` to `marimo.create_asgi_app()`. Generators must be [top-level functions](../reusing_functions.md) decorated with `@app.function`. They run outside normal cell execution, so values defined in regular cells are unavailable at metadata resolution time. +Generators must be [top-level functions](../reusing_functions.md). They run outside normal cell execution, so values defined in regular cells are unavailable at metadata resolution time. In script metadata, point `generator` at the name of a function defined in the notebook: diff --git a/marimo/_cli/cli.py b/marimo/_cli/cli.py index 604a7b3f689..0795e27a6c6 100644 --- a/marimo/_cli/cli.py +++ b/marimo/_cli/cli.py @@ -1105,9 +1105,7 @@ def _create_run_workspace( @click.option( "--execute-opengraph-generators", is_flag=True, - default=False, - type=bool, - help="Execute notebook-defined OpenGraph generators while resolving metadata.", + help="Execute OpenGraph generators for trusted notebooks. Not available in Docker.", ) @click.option( "--show-tracebacks/--no-show-tracebacks", diff --git a/marimo/_metadata/opengraph.py b/marimo/_metadata/opengraph.py index b7e401493ac..3ff3434db6f 100644 --- a/marimo/_metadata/opengraph.py +++ b/marimo/_metadata/opengraph.py @@ -491,7 +491,7 @@ def resolve_opengraph_metadata( context: OpenGraphContext | None = None, execute_generator: bool = False, ) -> OpenGraphMetadata: - """Resolve OpenGraph metadata and optionally execute a generator hook.""" + """Resolve OpenGraph metadata from config, defaults, and a generator hook.""" declared = read_opengraph_from_file(filepath) or OpenGraphConfig() title = declared.title or app_title or derive_title_from_path(filepath) diff --git a/tests/_server/api/endpoints/test_home.py b/tests/_server/api/endpoints/test_home.py index e1ee1161fc8..6c229b62fbe 100644 --- a/tests/_server/api/endpoints/test_home.py +++ b/tests/_server/api/endpoints/test_home.py @@ -127,22 +127,18 @@ def test_workspace_files_in_run_mode(client: TestClient) -> None: marker = Path(temp_dir) / "marker.txt" marimo_file = Path(temp_dir) / "notebook.py" marimo_file.write_text( - textwrap.dedent( - f""" - # /// script - # [tool.marimo.opengraph] - # title = "Static Title" - # generator = "generate_opengraph" - # /// - import marimo - app = marimo.App() - with app.setup: - open({str(marker)!r}, "w").write("executed") - @app.function - def generate_opengraph(context, parent): - return {{"title": "Dynamic Title"}} - """ - ).lstrip(), + f"""# /// script +# [tool.marimo.opengraph] +# title = "Static Title" +# generator = "generate_opengraph" +# /// +import marimo +app = marimo.App() +with app.setup: + open({str(marker)!r}, "w").write("executed") +def generate_opengraph(context, parent): + return {{"title": "Dynamic Title"}} +""", encoding="utf-8", ) From 18bf03934815bb6c077e21e186300b2d7981c4c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Tue, 14 Jul 2026 12:15:58 +0200 Subject: [PATCH 3/3] ensure flag is available in docker --- marimo/_cli/cli.py | 3 ++- marimo/_cli/run_docker.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/marimo/_cli/cli.py b/marimo/_cli/cli.py index 0795e27a6c6..b3355bc3dd2 100644 --- a/marimo/_cli/cli.py +++ b/marimo/_cli/cli.py @@ -1105,7 +1105,7 @@ def _create_run_workspace( @click.option( "--execute-opengraph-generators", is_flag=True, - help="Execute OpenGraph generators for trusted notebooks. Not available in Docker.", + help="Execute OpenGraph generators for trusted notebooks.", ) @click.option( "--show-tracebacks/--no-show-tracebacks", @@ -1176,6 +1176,7 @@ def run( "run", port=port, debug=GLOBAL_SETTINGS.DEVELOPMENT_MODE, + execute_opengraph_generators=execute_opengraph_generators, ) return diff --git a/marimo/_cli/run_docker.py b/marimo/_cli/run_docker.py index bb63456dca9..e13eb78c8c6 100644 --- a/marimo/_cli/run_docker.py +++ b/marimo/_cli/run_docker.py @@ -100,6 +100,7 @@ def run_in_docker( *, port: int | None, debug: bool = False, + execute_opengraph_generators: bool = False, ) -> None: echo(f"Starting {green('containerized')} marimo notebook") @@ -144,6 +145,8 @@ def run_in_docker( host, ] ) + if execute_opengraph_generators: + container_command.append("--execute-opengraph-generators") if file_path is not None: container_command.append(file_path)