From 59e19150c9fd779af42ff744c6a225d6d54c6cd3 Mon Sep 17 00:00:00 2001 From: nojaf Date: Mon, 18 May 2026 15:04:25 +0200 Subject: [PATCH 1/8] feat(sharing): enforce sharing config restrictions server-side in edit mode Gate the read_code and export_as_html endpoints behind the existing sharing.wasm and sharing.html config flags respectively. Previously these flags only hid UI buttons, leaving the underlying APIs callable directly. Now the server returns 403 (surfaced as 401 by the error handler) when the relevant flag is false. Also gates the "Create molab notebook" menu item behind sharingWasmEnabled, which was the only sharing action with no visibility control. --- .../editor/actions/useNotebookActions.tsx | 1 + marimo/_server/api/endpoints/export.py | 11 ++++++++ marimo/_server/api/endpoints/files.py | 11 +++++++- tests/_server/api/endpoints/test_files.py | 25 +++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/editor/actions/useNotebookActions.tsx b/frontend/src/components/editor/actions/useNotebookActions.tsx index 0e9db7ce7a4..16b84ee058c 100644 --- a/frontend/src/components/editor/actions/useNotebookActions.tsx +++ b/frontend/src/components/editor/actions/useNotebookActions.tsx @@ -387,6 +387,7 @@ export function useNotebookActions() { { icon: , label: "Create molab notebook", + hidden: !sharingWasmEnabled, handle: async () => { const code = await readCode(); const url = createShareableLink({ diff --git a/marimo/_server/api/endpoints/export.py b/marimo/_server/api/endpoints/export.py index 1500f79f70c..82da1f1aa09 100644 --- a/marimo/_server/api/endpoints/export.py +++ b/marimo/_server/api/endpoints/export.py @@ -78,11 +78,22 @@ async def export_as_html( type: string 400: description: File must be saved before downloading + 403: + description: HTML sharing is disabled by configuration """ app_state = AppState(request) body = await parse_request(request, cls=ExportAsHTMLRequest) session = app_state.require_current_session() + if ( + session.config_manager.get_config().get("sharing", {}).get("html") + is False + ): + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="HTML sharing is disabled by configuration.", + ) + # Check if the file is named if not session.app_file_manager.is_notebook_named: raise HTTPException( diff --git a/marimo/_server/api/endpoints/files.py b/marimo/_server/api/endpoints/files.py index 45550b15967..c0f9684fc37 100644 --- a/marimo/_server/api/endpoints/files.py +++ b/marimo/_server/api/endpoints/files.py @@ -59,7 +59,7 @@ async def read_code( 400: description: File must be saved before downloading 403: - description: Code is not available in run mode + description: Code is not available in run mode, or sharing is disabled by configuration """ app_state = AppState(request) @@ -72,6 +72,15 @@ async def read_code( session = app_state.require_current_session() + if ( + session.config_manager.get_config().get("sharing", {}).get("wasm") + is False + ): + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Code sharing is disabled by configuration.", + ) + if not session.app_file_manager.path: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, diff --git a/tests/_server/api/endpoints/test_files.py b/tests/_server/api/endpoints/test_files.py index ade1c754dac..47cbd65eb74 100644 --- a/tests/_server/api/endpoints/test_files.py +++ b/tests/_server/api/endpoints/test_files.py @@ -555,6 +555,31 @@ def test_read_code_without_saved_file(client: TestClient) -> None: ) +@with_session(SESSION_ID) +def test_read_code_with_wasm_sharing_disabled(client: TestClient) -> None: + """Test read_code is blocked when sharing.wasm = false in config.""" + with patch("marimo._server.api.endpoints.files.AppState") as mock_state: + mock_session = Mock() + mock_session.config_manager.get_config.return_value = { + "sharing": {"wasm": False} + } + mock_session.app_file_manager.path = "notebook.py" + mock_state.return_value.session_manager.should_send_code_to_frontend.return_value = True + mock_state.return_value.require_current_session.return_value = ( + mock_session + ) + + response = client.post( + "/api/kernel/read_code", + headers=HEADERS, + json={}, + ) + + # handle_error converts all 403s → 401 to prompt browser re-auth + assert response.status_code == 401 + assert "Authorization header required" in response.json()["detail"] + + @with_session(SESSION_ID) def test_save_with_unicode_content(client: TestClient) -> None: """Test save endpoint with unicode and special characters.""" From 5f2a5ab5107d2ac2b05608fef852b9254276cde9 Mon Sep 17 00:00:00 2001 From: nojaf Date: Mon, 18 May 2026 15:14:40 +0200 Subject: [PATCH 2/8] feat(sharing): enforce sharing restrictions in CLI export commands Block `marimo export html-wasm` entirely when sharing.wasm = false. Default `--no-include-code` for `marimo export html` when either sharing.wasm or sharing.html is false, so code is absent from the output without requiring per-invocation flags. --- marimo/_cli/export/commands.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/marimo/_cli/export/commands.py b/marimo/_cli/export/commands.py index 598bc6ecc65..af1b0a5594f 100644 --- a/marimo/_cli/export/commands.py +++ b/marimo/_cli/export/commands.py @@ -22,6 +22,7 @@ ) from marimo._cli.sandbox import maybe_prompt_run_in_sandbox, run_in_sandbox from marimo._cli.utils import prompt_to_overwrite +from marimo._config.manager import get_default_config_manager from marimo._dependencies.dependencies import DependencyManager from marimo._dependencies.errors import ManyModulesNotFoundError from marimo._pyodide.pyodide_constraints import PYODIDE_PYTHON_VERSION @@ -242,6 +243,14 @@ def html( run_in_sandbox(sys.argv[1:], name=name) return + sharing = ( + get_default_config_manager(current_path=name) + .get_config() + .get("sharing", {}) + ) + if sharing.get("wasm") is False or sharing.get("html") is False: + include_code = False + cli_args = parse_args(args) def export_callback(file_path: MarimoPath) -> ExportResult: @@ -877,6 +886,17 @@ def html_wasm( args: tuple[str, ...], ) -> None: """Export a notebook as a WASM-powered standalone HTML file.""" + sharing = ( + get_default_config_manager(current_path=name) + .get_config() + .get("sharing", {}) + ) + if sharing.get("wasm") is False: + raise click.UsageError( + "WebAssembly export is disabled by your marimo configuration " + "(sharing.wasm = false)." + ) + if execute and watch: raise click.UsageError( "--execute and --watch cannot be used together." From 6b91e887273a39b668b63af84320a4554c6940ab Mon Sep 17 00:00:00 2001 From: nojaf Date: Mon, 18 May 2026 16:49:23 +0200 Subject: [PATCH 3/8] feat(sharing): add MARIMO_RESTRICT_SHARING env var for machine-wide enforcement Injects sharing.wasm = false and sharing.html = false into the config resolution chain via EnvConfigManager when set, taking precedence over all per-project and per-user config. Intended for devpod/container environments where infra sets the variable and all notebooks inherit it without requiring per-project configuration. --- marimo/_config/manager.py | 3 +++ marimo/_config/settings.py | 7 +++++++ tests/_server/api/endpoints/test_files.py | 24 +++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/marimo/_config/manager.py b/marimo/_config/manager.py index ba57b4da012..94c906e3e37 100644 --- a/marimo/_config/manager.py +++ b/marimo/_config/manager.py @@ -36,6 +36,7 @@ mask_secrets_partial, remove_secret_placeholders, ) +from marimo._config.settings import GLOBAL_SETTINGS from marimo._config.utils import ( get_or_create_user_config_path, ) @@ -331,6 +332,8 @@ def get_config(self, *, hide_secrets: bool = True) -> PartialMarimoConfig: ["runtime", "auto_instantiate"], project_config, ) + if GLOBAL_SETTINGS.RESTRICT_SHARING: + project_config["sharing"] = {"wasm": False, "html": False} if hide_secrets: return mask_secrets_partial(project_config) return project_config diff --git a/marimo/_config/settings.py b/marimo/_config/settings.py index 7ab62613da0..54736313205 100644 --- a/marimo/_config/settings.py +++ b/marimo/_config/settings.py @@ -27,6 +27,13 @@ class GlobalSettings: DISABLE_AUTH_ON_VIRTUAL_FILES: bool = os.getenv( "_MARIMO_DISABLE_AUTH_ON_VIRTUAL_FILES", "false" ) in ("true", "1") + # Prevent all external code-sharing features (shareable WASM links, molab, + # HTML export with code). Intended for machine-wide enforcement via devpod + # or container environment — takes precedence over per-project config. + RESTRICT_SHARING: bool = os.getenv("MARIMO_RESTRICT_SHARING", "false") in ( + "true", + "1", + ) GLOBAL_SETTINGS = GlobalSettings() diff --git a/tests/_server/api/endpoints/test_files.py b/tests/_server/api/endpoints/test_files.py index 47cbd65eb74..832cce1a7cf 100644 --- a/tests/_server/api/endpoints/test_files.py +++ b/tests/_server/api/endpoints/test_files.py @@ -580,6 +580,30 @@ def test_read_code_with_wasm_sharing_disabled(client: TestClient) -> None: assert "Authorization header required" in response.json()["detail"] +@pytest.mark.flaky(reruns=5) +@with_session(SESSION_ID) +def test_read_code_blocked_by_restrict_sharing_env( + client: TestClient, +) -> None: + """Test read_code is blocked when MARIMO_RESTRICT_SHARING=1. + + The env var flows through EnvConfigManager into the config chain, so + we test via the real session rather than mocking AppState. + """ + with patch("marimo._config.manager.GLOBAL_SETTINGS") as mock_gs: + mock_gs.RESTRICT_SHARING = True + + response = client.post( + "/api/kernel/read_code", + headers=HEADERS, + json={}, + ) + + # handle_error converts all 403s → 401 to prompt browser re-auth + assert response.status_code == 401 + assert "Authorization header required" in response.json()["detail"] + + @with_session(SESSION_ID) def test_save_with_unicode_content(client: TestClient) -> None: """Test save endpoint with unicode and special characters.""" From 602276ebc099722ca10336c0fb5906d5b207ca93 Mon Sep 17 00:00:00 2001 From: nojaf Date: Tue, 19 May 2026 08:30:29 +0200 Subject: [PATCH 4/8] feat(sharing): bake sharing config into exported HTML to hide molab button Rather than blocking CLI export commands (which are local operations), thread the resolved sharing config through all export paths so it is embedded in the generated HTML. The static banner reads sharing.wasm from the embedded config and conditionally hides the "Open in molab" button. This prevents a hosted WASM or static HTML file from offering an easy one-click path to send source code to an external service. Covers all export paths: CLI (export_as_wasm, run_app_then_export_as_wasm, run_app_then_export_as_html), server endpoint, and picks up MARIMO_RESTRICT_SHARING automatically via EnvConfigManager. --- .../components/static-html/static-banner.tsx | 50 +++++++++++-------- marimo/_cli/export/commands.py | 20 -------- marimo/_server/api/endpoints/export.py | 4 +- marimo/_server/export/__init__.py | 12 +++-- marimo/_server/export/exporter.py | 23 +++++---- 5 files changed, 52 insertions(+), 57 deletions(-) diff --git a/frontend/src/components/static-html/static-banner.tsx b/frontend/src/components/static-html/static-banner.tsx index e8757b2ca78..ac108c2683d 100644 --- a/frontend/src/components/static-html/static-banner.tsx +++ b/frontend/src/components/static-html/static-banner.tsx @@ -6,6 +6,7 @@ import { useAtomValue } from "jotai"; import { CopyIcon, DownloadIcon } from "lucide-react"; import type React from "react"; import { Constants } from "@/core/constants"; +import { useResolvedMarimoConfig } from "@/core/config/config"; import { codeAtom } from "@/core/saving/file-state"; import { useFilename } from "@/core/saving/filename"; import { isStaticNotebook } from "@/core/static/static-state"; @@ -66,6 +67,9 @@ const StaticBannerDialog = ({ code }: { code: string }) => { filename = filename.slice(lastSlash + 1); } + const [resolvedConfig] = useResolvedMarimoConfig(); + const molabEnabled = resolvedConfig.sharing?.wasm !== false; + const href = window.location.href; const molabLink = createShareableLink({ code, @@ -118,28 +122,30 @@ const StaticBannerDialog = ({ code }: { code: string }) => { )} -
- -

- Run this notebook in{" "} - molab, marimo's - cloud-hosted notebook platform. -

-
+ {molabEnabled && ( +
+ +

+ Run this notebook in{" "} + molab, marimo's + cloud-hosted notebook platform. +

+
+ )}
diff --git a/marimo/_cli/export/commands.py b/marimo/_cli/export/commands.py index af1b0a5594f..598bc6ecc65 100644 --- a/marimo/_cli/export/commands.py +++ b/marimo/_cli/export/commands.py @@ -22,7 +22,6 @@ ) from marimo._cli.sandbox import maybe_prompt_run_in_sandbox, run_in_sandbox from marimo._cli.utils import prompt_to_overwrite -from marimo._config.manager import get_default_config_manager from marimo._dependencies.dependencies import DependencyManager from marimo._dependencies.errors import ManyModulesNotFoundError from marimo._pyodide.pyodide_constraints import PYODIDE_PYTHON_VERSION @@ -243,14 +242,6 @@ def html( run_in_sandbox(sys.argv[1:], name=name) return - sharing = ( - get_default_config_manager(current_path=name) - .get_config() - .get("sharing", {}) - ) - if sharing.get("wasm") is False or sharing.get("html") is False: - include_code = False - cli_args = parse_args(args) def export_callback(file_path: MarimoPath) -> ExportResult: @@ -886,17 +877,6 @@ def html_wasm( args: tuple[str, ...], ) -> None: """Export a notebook as a WASM-powered standalone HTML file.""" - sharing = ( - get_default_config_manager(current_path=name) - .get_config() - .get("sharing", {}) - ) - if sharing.get("wasm") is False: - raise click.UsageError( - "WebAssembly export is disabled by your marimo configuration " - "(sharing.wasm = false)." - ) - if execute and watch: raise click.UsageError( "--execute and --watch cannot be used together." diff --git a/marimo/_server/api/endpoints/export.py b/marimo/_server/api/endpoints/export.py index 82da1f1aa09..4bed65df262 100644 --- a/marimo/_server/api/endpoints/export.py +++ b/marimo/_server/api/endpoints/export.py @@ -105,11 +105,13 @@ async def export_as_html( if not app_state.session_manager.should_send_code_to_frontend(): body.include_code = False + resolved_config = session.config_manager.get_config() html, filename = Exporter().export_as_html( app=session.app_file_manager.app, filename=session.app_file_manager.filename, session_view=session.session_view, - display_config=session.config_manager.get_config()["display"], + display_config=resolved_config["display"], + sharing_config=resolved_config.get("sharing"), request=body, ) diff --git a/marimo/_server/export/__init__.py b/marimo/_server/export/__init__.py index 18473723924..9164765ad39 100644 --- a/marimo/_server/export/__init__.py +++ b/marimo/_server/export/__init__.py @@ -167,15 +167,17 @@ def export_as_wasm( # Inline the layout file, if it exists app.inline_layout_file() config = get_default_config_manager(current_path=path.absolute_name) + resolved = config.get_config() result = Exporter().export_as_wasm( filename=path.short_name, app=app, - display_config=config.get_config()["display"], + display_config=resolved["display"], mode=mode, code=app.to_py(), asset_url=asset_url, show_code=show_code, + sharing_config=resolved.get("sharing"), ) return ExportResult( contents=result[0], @@ -332,7 +334,8 @@ async def run_app_then_export_as_html( file_manager.app.inline_layout_file() config = get_default_config_manager(current_path=file_manager.path) - display_config = cast(DisplayConfig, config.get_config()["display"]) + resolved = config.get_config() + display_config = cast(DisplayConfig, resolved["display"]) session_view, did_error = await run_app_until_completion( file_manager, cli_args, @@ -344,6 +347,7 @@ async def run_app_then_export_as_html( app=file_manager.app, session_view=session_view, display_config=display_config, + sharing_config=resolved.get("sharing"), request=ExportAsHTMLRequest( include_code=include_code, download=False, @@ -377,7 +381,8 @@ async def run_app_then_export_as_wasm( file_manager.app.inline_layout_file() config = get_default_config_manager(current_path=file_manager.path) - display_config = cast(DisplayConfig, config.get_config()["display"]) + resolved = config.get_config() + display_config = cast(DisplayConfig, resolved["display"]) session_view, did_error = await run_app_until_completion( file_manager, @@ -406,6 +411,7 @@ async def run_app_then_export_as_wasm( asset_url=asset_url, session_snapshot=session_snapshot, notebook_snapshot=notebook_snapshot, + sharing_config=resolved.get("sharing"), ) return ExportResult( contents=html, diff --git a/marimo/_server/export/exporter.py b/marimo/_server/export/exporter.py index cee285b552e..5b4a3de96a9 100644 --- a/marimo/_server/export/exporter.py +++ b/marimo/_server/export/exporter.py @@ -15,6 +15,7 @@ DEFAULT_CONFIG, DisplayConfig, MarimoConfig, + SharingConfig, ) from marimo._config.settings import GLOBAL_SETTINGS from marimo._config.utils import deep_copy @@ -101,12 +102,13 @@ def export_as_html( session_view: SessionView, display_config: DisplayConfig, request: ExportAsHTMLRequest, + sharing_config: SharingConfig | None = None, ) -> tuple[str, str]: index_html = get_html_contents() filename = get_filename(filename) - # Configure notebook with display settings - config = self._prepare_display_config(display_config) + # Configure notebook with display and sharing settings + config = self._prepare_display_config(display_config, sharing_config) # Serialize notebook state session_snapshot = serialize_session_view( @@ -157,15 +159,15 @@ def export_as_html( return html, download_filename def _prepare_display_config( - self, display_config: DisplayConfig + self, + display_config: DisplayConfig, + sharing_config: SharingConfig | None = None, ) -> MarimoConfig: - """Prepare config with display settings for static notebook.""" - # We only want pass the display config in the static notebook, - # since we use: - # - display.theme - # - display.cell_output + """Prepare config with display and sharing settings for static notebook.""" config = deep_copy(DEFAULT_CONFIG) config["display"] = display_config + if sharing_config: + config["sharing"] = sharing_config # type: ignore[typeddict-item] return cast(MarimoConfig, config) def _inline_virtual_files( @@ -349,14 +351,13 @@ def export_as_wasm( asset_url: str | None = None, session_snapshot: NotebookSessionV1 | None = None, notebook_snapshot: NotebookV1 | None = None, + sharing_config: SharingConfig | None = None, ) -> tuple[str, str]: """Export notebook as a WASM-powered standalone HTML file.""" index_html = get_html_contents() filename = get_filename(filename) - # We only want to pass the display config in the static notebook - config: MarimoConfig = deep_copy(DEFAULT_CONFIG) - config["display"] = display_config + config = self._prepare_display_config(display_config, sharing_config) # Remove autosave config["save"]["autosave"] = "off" From 9685c58889ccbf8122a9f52e1a2458ae89880a91 Mon Sep 17 00:00:00 2001 From: nojaf Date: Tue, 19 May 2026 08:56:02 +0200 Subject: [PATCH 5/8] Update openapi --- packages/openapi/api.yaml | 5 ++++- packages/openapi/src/api.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/openapi/api.yaml b/packages/openapi/api.yaml index 556cc6366d3..863bcf74e7c 100644 --- a/packages/openapi/api.yaml +++ b/packages/openapi/api.yaml @@ -5999,6 +5999,8 @@ paths: description: Export the notebook as HTML 400: description: File must be saved before downloading + 403: + description: HTML sharing is disabled by configuration /api/export/ipynb: post: parameters: @@ -6506,7 +6508,8 @@ paths: 400: description: File must be saved before downloading 403: - description: Code is not available in run mode + description: Code is not available in run mode, or sharing is disabled by + configuration /api/kernel/rename: post: parameters: diff --git a/packages/openapi/src/api.ts b/packages/openapi/src/api.ts index 8085de21a08..1417236cd68 100644 --- a/packages/openapi/src/api.ts +++ b/packages/openapi/src/api.ts @@ -833,6 +833,13 @@ export interface paths { }; content?: never; }; + /** @description HTML sharing is disabled by configuration */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; delete?: never; @@ -2066,7 +2073,7 @@ export interface paths { }; content?: never; }; - /** @description Code is not available in run mode */ + /** @description Code is not available in run mode, or sharing is disabled by configuration */ 403: { headers: { [name: string]: unknown; From 51a81dc7c37a64b1228eafc83d7f9db90285fb3d Mon Sep 17 00:00:00 2001 From: nojaf Date: Wed, 20 May 2026 09:14:32 +0200 Subject: [PATCH 6/8] remove MARIMO_RESTRICT_SHARING env var for separate PR --- marimo/_config/manager.py | 3 --- marimo/_config/settings.py | 7 ------- tests/_server/api/endpoints/test_files.py | 24 ----------------------- 3 files changed, 34 deletions(-) diff --git a/marimo/_config/manager.py b/marimo/_config/manager.py index 94c906e3e37..ba57b4da012 100644 --- a/marimo/_config/manager.py +++ b/marimo/_config/manager.py @@ -36,7 +36,6 @@ mask_secrets_partial, remove_secret_placeholders, ) -from marimo._config.settings import GLOBAL_SETTINGS from marimo._config.utils import ( get_or_create_user_config_path, ) @@ -332,8 +331,6 @@ def get_config(self, *, hide_secrets: bool = True) -> PartialMarimoConfig: ["runtime", "auto_instantiate"], project_config, ) - if GLOBAL_SETTINGS.RESTRICT_SHARING: - project_config["sharing"] = {"wasm": False, "html": False} if hide_secrets: return mask_secrets_partial(project_config) return project_config diff --git a/marimo/_config/settings.py b/marimo/_config/settings.py index 54736313205..7ab62613da0 100644 --- a/marimo/_config/settings.py +++ b/marimo/_config/settings.py @@ -27,13 +27,6 @@ class GlobalSettings: DISABLE_AUTH_ON_VIRTUAL_FILES: bool = os.getenv( "_MARIMO_DISABLE_AUTH_ON_VIRTUAL_FILES", "false" ) in ("true", "1") - # Prevent all external code-sharing features (shareable WASM links, molab, - # HTML export with code). Intended for machine-wide enforcement via devpod - # or container environment — takes precedence over per-project config. - RESTRICT_SHARING: bool = os.getenv("MARIMO_RESTRICT_SHARING", "false") in ( - "true", - "1", - ) GLOBAL_SETTINGS = GlobalSettings() diff --git a/tests/_server/api/endpoints/test_files.py b/tests/_server/api/endpoints/test_files.py index 832cce1a7cf..47cbd65eb74 100644 --- a/tests/_server/api/endpoints/test_files.py +++ b/tests/_server/api/endpoints/test_files.py @@ -580,30 +580,6 @@ def test_read_code_with_wasm_sharing_disabled(client: TestClient) -> None: assert "Authorization header required" in response.json()["detail"] -@pytest.mark.flaky(reruns=5) -@with_session(SESSION_ID) -def test_read_code_blocked_by_restrict_sharing_env( - client: TestClient, -) -> None: - """Test read_code is blocked when MARIMO_RESTRICT_SHARING=1. - - The env var flows through EnvConfigManager into the config chain, so - we test via the real session rather than mocking AppState. - """ - with patch("marimo._config.manager.GLOBAL_SETTINGS") as mock_gs: - mock_gs.RESTRICT_SHARING = True - - response = client.post( - "/api/kernel/read_code", - headers=HEADERS, - json={}, - ) - - # handle_error converts all 403s → 401 to prompt browser re-auth - assert response.status_code == 401 - assert "Authorization header required" in response.json()["detail"] - - @with_session(SESSION_ID) def test_save_with_unicode_content(client: TestClient) -> None: """Test save endpoint with unicode and special characters.""" From c4186b603fc9e21697ae9597acdd3ce1c2205e68 Mon Sep 17 00:00:00 2001 From: nojaf Date: Thu, 21 May 2026 09:32:03 +0200 Subject: [PATCH 7/8] fix(sharing): don't gate read_code on sharing.wasm config The read_code endpoint is used by several local operations (Download Python code, Copy code to clipboard) that have nothing to do with external sharing. Blocking it via sharing.wasm broke those flows with an unexpected server error while the buttons remained visible in the UI. Only the HTML export endpoint warrants a server-side sharing gate, since it is a server-driven operation. The wasm/molab sharing flow constructs URLs entirely client-side and is already gated at the UI layer. --- marimo/_server/api/endpoints/files.py | 11 +--------- tests/_server/api/endpoints/test_files.py | 25 ----------------------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/marimo/_server/api/endpoints/files.py b/marimo/_server/api/endpoints/files.py index c0f9684fc37..45550b15967 100644 --- a/marimo/_server/api/endpoints/files.py +++ b/marimo/_server/api/endpoints/files.py @@ -59,7 +59,7 @@ async def read_code( 400: description: File must be saved before downloading 403: - description: Code is not available in run mode, or sharing is disabled by configuration + description: Code is not available in run mode """ app_state = AppState(request) @@ -72,15 +72,6 @@ async def read_code( session = app_state.require_current_session() - if ( - session.config_manager.get_config().get("sharing", {}).get("wasm") - is False - ): - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Code sharing is disabled by configuration.", - ) - if not session.app_file_manager.path: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, diff --git a/tests/_server/api/endpoints/test_files.py b/tests/_server/api/endpoints/test_files.py index 47cbd65eb74..ade1c754dac 100644 --- a/tests/_server/api/endpoints/test_files.py +++ b/tests/_server/api/endpoints/test_files.py @@ -555,31 +555,6 @@ def test_read_code_without_saved_file(client: TestClient) -> None: ) -@with_session(SESSION_ID) -def test_read_code_with_wasm_sharing_disabled(client: TestClient) -> None: - """Test read_code is blocked when sharing.wasm = false in config.""" - with patch("marimo._server.api.endpoints.files.AppState") as mock_state: - mock_session = Mock() - mock_session.config_manager.get_config.return_value = { - "sharing": {"wasm": False} - } - mock_session.app_file_manager.path = "notebook.py" - mock_state.return_value.session_manager.should_send_code_to_frontend.return_value = True - mock_state.return_value.require_current_session.return_value = ( - mock_session - ) - - response = client.post( - "/api/kernel/read_code", - headers=HEADERS, - json={}, - ) - - # handle_error converts all 403s → 401 to prompt browser re-auth - assert response.status_code == 401 - assert "Authorization header required" in response.json()["detail"] - - @with_session(SESSION_ID) def test_save_with_unicode_content(client: TestClient) -> None: """Test save endpoint with unicode and special characters.""" From b97c236de8a7e88eb40fa8e9702837047431a8e7 Mon Sep 17 00:00:00 2001 From: nojaf Date: Thu, 21 May 2026 09:38:53 +0200 Subject: [PATCH 8/8] fix(sharing): remove server-side 403 gate from HTML export endpoint Exporting != sharing. Blocking POST /api/export/html when sharing.html is false prevented legitimate local downloads (Download as HTML) with no security benefit, since the user already has the source in the editor. The sharing config is still baked into exported HTML so the static banner's molab button respects the flag. Enforcement stays at the UI layer, which is what the maintainer intended. --- marimo/_server/api/endpoints/export.py | 11 ----------- packages/openapi/api.yaml | 5 +---- packages/openapi/src/api.ts | 9 +-------- 3 files changed, 2 insertions(+), 23 deletions(-) diff --git a/marimo/_server/api/endpoints/export.py b/marimo/_server/api/endpoints/export.py index 4bed65df262..93ea9c350c8 100644 --- a/marimo/_server/api/endpoints/export.py +++ b/marimo/_server/api/endpoints/export.py @@ -78,22 +78,11 @@ async def export_as_html( type: string 400: description: File must be saved before downloading - 403: - description: HTML sharing is disabled by configuration """ app_state = AppState(request) body = await parse_request(request, cls=ExportAsHTMLRequest) session = app_state.require_current_session() - if ( - session.config_manager.get_config().get("sharing", {}).get("html") - is False - ): - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="HTML sharing is disabled by configuration.", - ) - # Check if the file is named if not session.app_file_manager.is_notebook_named: raise HTTPException( diff --git a/packages/openapi/api.yaml b/packages/openapi/api.yaml index 863bcf74e7c..556cc6366d3 100644 --- a/packages/openapi/api.yaml +++ b/packages/openapi/api.yaml @@ -5999,8 +5999,6 @@ paths: description: Export the notebook as HTML 400: description: File must be saved before downloading - 403: - description: HTML sharing is disabled by configuration /api/export/ipynb: post: parameters: @@ -6508,8 +6506,7 @@ paths: 400: description: File must be saved before downloading 403: - description: Code is not available in run mode, or sharing is disabled by - configuration + description: Code is not available in run mode /api/kernel/rename: post: parameters: diff --git a/packages/openapi/src/api.ts b/packages/openapi/src/api.ts index 1417236cd68..8085de21a08 100644 --- a/packages/openapi/src/api.ts +++ b/packages/openapi/src/api.ts @@ -833,13 +833,6 @@ export interface paths { }; content?: never; }; - /** @description HTML sharing is disabled by configuration */ - 403: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; }; }; delete?: never; @@ -2073,7 +2066,7 @@ export interface paths { }; content?: never; }; - /** @description Code is not available in run mode, or sharing is disabled by configuration */ + /** @description Code is not available in run mode */ 403: { headers: { [name: string]: unknown;