streamlit 1.53.1__py3-none-any.whl → 1.54.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- streamlit/__init__.py +1 -31
- streamlit/auth_util.py +91 -2
- streamlit/cli_util.py +3 -2
- streamlit/commands/echo.py +2 -2
- streamlit/commands/execution_control.py +1 -1
- streamlit/commands/logo.py +76 -24
- streamlit/commands/navigation.py +1 -1
- streamlit/components/types/base_custom_component.py +0 -2
- streamlit/components/v1/custom_component.py +0 -2
- streamlit/components/v2/bidi_component/main.py +2 -2
- streamlit/components/v2/component_path_utils.py +17 -29
- streamlit/components/v2/manifest_scanner.py +8 -3
- streamlit/components/v2/presentation.py +1 -1
- streamlit/config.py +57 -13
- streamlit/config_util.py +5 -5
- streamlit/connections/snowflake_connection.py +5 -3
- streamlit/dataframe_util.py +10 -10
- streamlit/deprecation_util.py +19 -1
- streamlit/elements/arrow.py +18 -8
- streamlit/elements/deck_gl_json_chart.py +6 -2
- streamlit/elements/exception.py +4 -2
- streamlit/elements/form.py +1 -1
- streamlit/elements/layouts.py +1 -1
- streamlit/elements/lib/built_in_chart_utils.py +36 -13
- streamlit/elements/lib/color_util.py +21 -2
- streamlit/elements/lib/column_config_utils.py +9 -7
- streamlit/elements/lib/dialog.py +1 -1
- streamlit/elements/lib/image_utils.py +5 -5
- streamlit/elements/lib/layout_utils.py +1 -1
- streamlit/elements/lib/options_selector_utils.py +72 -22
- streamlit/elements/lib/policies.py +1 -1
- streamlit/elements/lib/streamlit_plotly_theme.py +9 -11
- streamlit/elements/lib/utils.py +1 -1
- streamlit/elements/map.py +6 -6
- streamlit/elements/plotly_chart.py +2 -2
- streamlit/elements/toast.py +1 -1
- streamlit/elements/vega_charts.py +30 -7
- streamlit/elements/widgets/button.py +3 -3
- streamlit/elements/widgets/button_group.py +3 -3
- streamlit/elements/widgets/chat.py +1 -1
- streamlit/elements/widgets/data_editor.py +6 -6
- streamlit/elements/widgets/multiselect.py +1 -1
- streamlit/elements/widgets/number_input.py +1 -1
- streamlit/elements/widgets/radio.py +91 -31
- streamlit/elements/widgets/select_slider.py +123 -37
- streamlit/elements/widgets/slider.py +5 -5
- streamlit/elements/widgets/time_widgets.py +150 -18
- streamlit/elements/write.py +2 -3
- streamlit/env_util.py +1 -1
- streamlit/errors.py +2 -14
- streamlit/external/langchain/streamlit_callback_handler.py +1 -1
- streamlit/hello/dataframe_demo.py +1 -1
- streamlit/hello/plotting_demo.py +19 -12
- streamlit/path_security.py +98 -0
- streamlit/proto/Alert_pb2.py +2 -3
- streamlit/proto/AppPage_pb2.py +2 -3
- streamlit/proto/ArrowData_pb2.py +2 -3
- streamlit/proto/ArrowNamedDataSet_pb2.py +2 -3
- streamlit/proto/ArrowVegaLiteChart_pb2.py +2 -3
- streamlit/proto/Arrow_pb2.py +2 -3
- streamlit/proto/AudioInput_pb2.py +2 -3
- streamlit/proto/Audio_pb2.py +2 -3
- streamlit/proto/AuthRedirect_pb2.py +2 -3
- streamlit/proto/AutoRerun_pb2.py +2 -3
- streamlit/proto/BackMsg_pb2.py +2 -3
- streamlit/proto/Balloons_pb2.py +2 -3
- streamlit/proto/BidiComponent_pb2.py +2 -3
- streamlit/proto/Block_pb2.py +2 -3
- streamlit/proto/BokehChart_pb2.py +2 -3
- streamlit/proto/ButtonGroup_pb2.py +2 -3
- streamlit/proto/ButtonLikeIconPosition_pb2.py +2 -3
- streamlit/proto/Button_pb2.py +2 -3
- streamlit/proto/CameraInput_pb2.py +2 -3
- streamlit/proto/ChatInput_pb2.py +2 -3
- streamlit/proto/Checkbox_pb2.py +2 -3
- streamlit/proto/ClientState_pb2.py +2 -3
- streamlit/proto/Code_pb2.py +2 -3
- streamlit/proto/ColorPicker_pb2.py +2 -3
- streamlit/proto/Common_pb2.py +2 -3
- streamlit/proto/Components_pb2.py +2 -3
- streamlit/proto/DataFrame_pb2.py +2 -3
- streamlit/proto/DateInput_pb2.py +2 -3
- streamlit/proto/DateTimeInput_pb2.py +2 -3
- streamlit/proto/DeckGlJsonChart_pb2.py +2 -3
- streamlit/proto/Delta_pb2.py +2 -3
- streamlit/proto/DocString_pb2.py +2 -3
- streamlit/proto/DownloadButton_pb2.py +2 -3
- streamlit/proto/Element_pb2.py +2 -3
- streamlit/proto/Empty_pb2.py +2 -3
- streamlit/proto/Exception_pb2.py +2 -3
- streamlit/proto/Favicon_pb2.py +2 -3
- streamlit/proto/FileUploader_pb2.py +2 -3
- streamlit/proto/ForwardMsg_pb2.py +2 -3
- streamlit/proto/GapSize_pb2.py +2 -3
- streamlit/proto/GitInfo_pb2.py +2 -3
- streamlit/proto/GraphVizChart_pb2.py +2 -3
- streamlit/proto/Heading_pb2.py +2 -3
- streamlit/proto/HeightConfig_pb2.py +2 -3
- streamlit/proto/Html_pb2.py +2 -3
- streamlit/proto/IFrame_pb2.py +2 -3
- streamlit/proto/Image_pb2.py +2 -3
- streamlit/proto/Json_pb2.py +2 -3
- streamlit/proto/LabelVisibilityMessage_pb2.py +2 -3
- streamlit/proto/LinkButton_pb2.py +2 -3
- streamlit/proto/Logo_pb2.py +6 -5
- streamlit/proto/Logo_pb2.pyi +25 -1
- streamlit/proto/Markdown_pb2.py +2 -3
- streamlit/proto/Metric_pb2.py +2 -3
- streamlit/proto/MetricsEvent_pb2.py +2 -3
- streamlit/proto/MultiSelect_pb2.py +2 -3
- streamlit/proto/NamedDataSet_pb2.py +2 -3
- streamlit/proto/Navigation_pb2.py +2 -3
- streamlit/proto/NewSession_pb2.py +25 -24
- streamlit/proto/NewSession_pb2.pyi +28 -2
- streamlit/proto/NumberInput_pb2.py +2 -3
- streamlit/proto/PageConfig_pb2.py +2 -3
- streamlit/proto/PageInfo_pb2.py +2 -3
- streamlit/proto/PageLink_pb2.py +2 -3
- streamlit/proto/PageNotFound_pb2.py +2 -3
- streamlit/proto/PageProfile_pb2.py +2 -3
- streamlit/proto/PagesChanged_pb2.py +2 -3
- streamlit/proto/ParentMessage_pb2.py +2 -3
- streamlit/proto/PlotlyChart_pb2.py +2 -3
- streamlit/proto/Progress_pb2.py +2 -3
- streamlit/proto/Radio_pb2.py +5 -4
- streamlit/proto/Radio_pb2.pyi +20 -3
- streamlit/proto/RootContainer_pb2.py +2 -3
- streamlit/proto/Selectbox_pb2.py +2 -3
- streamlit/proto/SessionEvent_pb2.py +2 -3
- streamlit/proto/SessionStatus_pb2.py +2 -3
- streamlit/proto/Skeleton_pb2.py +2 -3
- streamlit/proto/Slider_pb2.py +7 -8
- streamlit/proto/Slider_pb2.pyi +9 -1
- streamlit/proto/Snow_pb2.py +2 -3
- streamlit/proto/Space_pb2.py +2 -3
- streamlit/proto/Spinner_pb2.py +2 -3
- streamlit/proto/TextAlignmentConfig_pb2.py +2 -3
- streamlit/proto/TextArea_pb2.py +2 -3
- streamlit/proto/TextInput_pb2.py +2 -3
- streamlit/proto/Text_pb2.py +2 -3
- streamlit/proto/TimeInput_pb2.py +2 -3
- streamlit/proto/Toast_pb2.py +2 -3
- streamlit/proto/Transient_pb2.py +2 -3
- streamlit/proto/VegaLiteChart_pb2.py +2 -3
- streamlit/proto/Video_pb2.py +2 -3
- streamlit/proto/WidgetStates_pb2.py +2 -3
- streamlit/proto/WidthConfig_pb2.py +2 -3
- streamlit/proto/openmetrics_data_model_pb2.py +2 -3
- streamlit/runtime/app_session.py +106 -60
- streamlit/runtime/caching/cache_data_api.py +3 -3
- streamlit/runtime/caching/cache_errors.py +0 -2
- streamlit/runtime/caching/cache_resource_api.py +1 -1
- streamlit/runtime/caching/cache_utils.py +2 -2
- streamlit/runtime/caching/hashing.py +1 -3
- streamlit/runtime/caching/storage/cache_storage_protocol.py +0 -3
- streamlit/runtime/connection_factory.py +1 -1
- streamlit/runtime/credentials.py +2 -2
- streamlit/runtime/metrics_util.py +3 -3
- streamlit/runtime/runtime.py +6 -6
- streamlit/runtime/scriptrunner/script_runner.py +17 -0
- streamlit/runtime/scriptrunner_utils/exceptions.py +0 -4
- streamlit/runtime/scriptrunner_utils/script_run_context.py +13 -31
- streamlit/runtime/secrets.py +3 -4
- streamlit/runtime/state/__init__.py +7 -1
- streamlit/runtime/state/common.py +13 -0
- streamlit/runtime/state/query_params.py +493 -24
- streamlit/runtime/state/session_state.py +179 -4
- streamlit/runtime/state/widgets.py +26 -1
- streamlit/runtime/stats.py +1 -10
- streamlit/static/index.html +1 -1
- streamlit/static/manifest.json +304 -304
- streamlit/static/static/js/{ErrorOutline.esm.CScZvf44.js → ErrorOutline.esm.BWk6F-Tz.js} +1 -1
- streamlit/static/static/js/{FileDownload.esm.COCxTZxP.js → FileDownload.esm.AllYUuOW.js} +1 -1
- streamlit/static/static/js/{FileHelper.Bhs-iVRI.js → FileHelper.BvVTNdmy.js} +1 -1
- streamlit/static/static/js/{FormClearHelper.CA_5b-Ut.js → FormClearHelper.C__r5Llk.js} +1 -1
- streamlit/static/static/js/{InputInstructions.Bzb0MCfv.js → InputInstructions.DOtkdOMV.js} +1 -1
- streamlit/static/static/js/Particles.DCsqQZlE.js +1 -0
- streamlit/static/static/js/{ProgressBar.DyQNhVsJ.js → ProgressBar.DLCRvt4m.js} +2 -2
- streamlit/static/static/js/{StreamlitSyntaxHighlighter.BOkJThtV.js → StreamlitSyntaxHighlighter.CYFWoZHb.js} +1 -1
- streamlit/static/static/js/{TableChart.esm.a60nntBC.js → TableChart.esm.D6ydHcIm.js} +1 -1
- streamlit/static/static/js/Toolbar.BHDNzWBx.js +1 -0
- streamlit/static/static/js/{WidgetLabelHelpIconInline.BjIku2ic.js → WidgetLabelHelpIconInline.DEXBrVlc.js} +1 -1
- streamlit/static/static/js/{base-input.avGkArOc.js → base-input.TSQjctlq.js} +4 -4
- streamlit/static/static/js/{checkbox.Q8mCuqps.js → checkbox.BKgfzJZV.js} +1 -1
- streamlit/static/static/js/{createDownloadLinkElement.CfqHRpxo.js → createDownloadLinkElement.CG7nr2a4.js} +1 -1
- streamlit/static/static/js/{data-grid-overlay-editor.PuoMl3yV.js → data-grid-overlay-editor.ChXO__lP.js} +1 -1
- streamlit/static/static/js/{downloader.CjG2csSm.js → downloader.DJ3R_zWA.js} +1 -1
- streamlit/static/static/js/embed.u3PPfLkw.js +193 -0
- streamlit/static/static/js/{es6.CQD6uUK7.js → es6.C5Mfy8nd.js} +2 -2
- streamlit/static/static/js/{formatNumber.CtjUO-if.js → formatNumber.CMRgW9EJ.js} +1 -1
- streamlit/static/static/js/{iconPosition.7Qt6oUiI.js → iconPosition.B4EEXI3E.js} +1 -1
- streamlit/static/static/js/{iframeResizer.contentWindow._oj2Xh0v.js → iframeResizer.contentWindow.WSvOiTW0.js} +1 -1
- streamlit/static/static/js/index.-FOBV3nz.js +1 -0
- streamlit/static/static/js/{index.BuBkymZd.js → index.-NF8OSF5.js} +1 -1
- streamlit/static/static/js/{index.B-XrnnK6.js → index.4cBg8kn5.js} +1 -1
- streamlit/static/static/js/{index.B_ylV_tl.js → index.B0pzzCsH.js} +1 -1
- streamlit/static/static/js/{index.BhJwyXH6.js → index.BID6ND5j.js} +2 -2
- streamlit/static/static/js/index.BMp5bGjh.js +1 -0
- streamlit/static/static/js/{index.Cptu1tS-.js → index.BQcmlvas.js} +1 -1
- streamlit/static/static/js/{index.DXQ_Fvpt.js → index.BRcmclgI.js} +1 -1
- streamlit/static/static/js/index.BaUZR4IG.js +1 -0
- streamlit/static/static/js/{index.CMBgAPh6.js → index.BbMJj4PN.js} +1 -1
- streamlit/static/static/js/{index.CVRgrLT-.js → index.BdCTJtq3.js} +2 -2
- streamlit/static/static/js/index.BdETLMuI.js +1 -0
- streamlit/static/static/js/index.BnKMWhs1.js +1 -0
- streamlit/static/static/js/index.Br1kXwQW.js +2 -0
- streamlit/static/static/js/{index.XGft6-dq.js → index.Bt2olRE4.js} +1 -1
- streamlit/static/static/js/{index.B2fAYU1N.js → index.Bxwsv5T8.js} +1 -1
- streamlit/static/static/js/index.C4KskYz6.js +1 -0
- streamlit/static/static/js/{index.DZE_91Ym.js → index.C6bmbXk0.js} +1 -1
- streamlit/static/static/js/{index.Egabyb7u.js → index.CEfKfbta.js} +1 -1
- streamlit/static/static/js/index.CIuaA8q0.js +2 -0
- streamlit/static/static/js/{index.DVtfSohT.js → index.CV1sObFX.js} +1 -1
- streamlit/static/static/js/{index.BlJhnb4M.js → index.CbR6dgaV.js} +1 -1
- streamlit/static/static/js/index.Cq6szKqJ.js +1 -0
- streamlit/static/static/js/index.CyouXqCz.js +1 -0
- streamlit/static/static/js/{index.B5wmZkRW.js → index.D1NUgMFI.js} +1 -1
- streamlit/static/static/js/{index.euRMkmNi.js → index.D7SWG4Om.js} +1 -1
- streamlit/static/static/js/{index.Bg-9YNUa.js → index.DAYPEwLI.js} +1 -1
- streamlit/static/static/js/index.DKS75Vfg.js +11 -0
- streamlit/static/static/js/{index.CIizdLeb.js → index.DOXrMIxB.js} +1 -1
- streamlit/static/static/js/{index.BRegnbUa.js → index.DOzYX8yS.js} +3 -3
- streamlit/static/static/js/{index.BksGMsW0.js → index.DRFMYcC4.js} +4 -4
- streamlit/static/static/js/{index.B8PovXCX.js → index.Divl5FCY.js} +1 -1
- streamlit/static/static/js/{index.DxQuXlXH.js → index.DjAJ_CUa.js} +1 -1
- streamlit/static/static/js/{index.BrRuSP42.js → index.Dncue2pm.js} +33 -33
- streamlit/static/static/js/{index.DSTThs-t.js → index.Drusyo5m.js} +47 -47
- streamlit/static/static/js/{index.BOafPwIE.js → index.DuUyDGnP.js} +1 -1
- streamlit/static/static/js/{index.D1bkwsLT.js → index.DvgT2rB2.js} +223 -223
- streamlit/static/static/js/{index.BmDXWfgx.js → index.DzutABu5.js} +2 -2
- streamlit/static/static/js/index.Dzw2iPzi.js +3 -0
- streamlit/static/static/js/{index.DJsqD2Sc.js → index.FsTmxLbT.js} +1 -1
- streamlit/static/static/js/{index.BOTEMJfV.js → index.OIwPqGYN.js} +1 -1
- streamlit/static/static/js/{index.CBqST2Yj.js → index.RXLN7YFT.js} +2 -2
- streamlit/static/static/js/{index.Ft2Zxbhr.js → index.YYb2u0jk.js} +2 -2
- streamlit/static/static/js/{index.BWCFtBS4.js → index.h8ejt-W3.js} +1 -1
- streamlit/static/static/js/{index.KuLql7H0.js → index.lFMCi9am.js} +1 -1
- streamlit/static/static/js/{index.D8t7R4QQ.js → index.pOgf4cEj.js} +1 -1
- streamlit/static/static/js/{index.CsoN0h7K.js → index.s_E0s7LB.js} +51 -51
- streamlit/static/static/js/{index.BVX_bqnf.js → index.xLCbzoqj.js} +1 -1
- streamlit/static/static/js/{input.Cf97CQME.js → input.BLG7kWaj.js} +2 -2
- streamlit/static/static/js/{main.Ccuk53yQ.js → main.D_CmqChN.js} +1 -1
- streamlit/static/static/js/{memory.Bng6Ij0g.js → memory.T8u9KqIQ.js} +1 -1
- streamlit/static/static/js/{number-overlay-editor.CFLv-CWC.js → number-overlay-editor.BKBSXkAM.js} +2 -2
- streamlit/static/static/js/{pandasStylerUtils.C2hcAKiv.js → pandasStylerUtils.B4tLYMwS.js} +1 -1
- streamlit/static/static/js/{sandbox.BXdeD-wA.js → sandbox.jRlkcPem.js} +1 -1
- streamlit/static/static/js/{styled-components.Br04Ogac.js → styled-components.D2QhNwzd.js} +1 -1
- streamlit/static/static/js/{throttle.mI9ItGre.js → throttle.Cyw_V0Dq.js} +1 -1
- streamlit/static/static/js/{timepicker.poFdB0sd.js → timepicker.PzyuDDWl.js} +1 -1
- streamlit/static/static/js/{toConsumableArray.92-fANS-.js → toConsumableArray.gE9fMkLj.js} +1 -1
- streamlit/static/static/js/uniqueId.B1GeHnT1.js +1 -0
- streamlit/static/static/js/{useBasicWidgetState.DzKGLAv_.js → useBasicWidgetState.DFklfao0.js} +1 -1
- streamlit/static/static/js/{useIntlLocale.BMma2iiY.js → useIntlLocale.C3tUGWTU.js} +8 -8
- streamlit/static/static/js/{useTextInputAutoExpand.DQbIhdma.js → useTextInputAutoExpand.D9nU_y-e.js} +1 -1
- streamlit/static/static/js/useUpdateUiValue.ClTdrkJN.js +1 -0
- streamlit/static/static/js/{useWaveformController.AH0ggRyc.js → useWaveformController.lzTbjMW2.js} +1 -1
- streamlit/static/static/js/{withCalculatedWidth.G5xJ-MbS.js → withCalculatedWidth.Dxs9I5Oe.js} +1 -1
- streamlit/static/static/js/{withFullScreenWrapper.rdRu6zZ4.js → withFullScreenWrapper.DfpAcJxf.js} +1 -1
- streamlit/string_util.py +2 -2
- streamlit/testing/v1/app_test.py +1 -1
- streamlit/testing/v1/element_tree.py +33 -20
- streamlit/type_util.py +2 -2
- streamlit/url_util.py +2 -2
- streamlit/user_info.py +2 -41
- streamlit/util.py +1 -1
- streamlit/watcher/event_based_path_watcher.py +37 -7
- streamlit/watcher/path_watcher.py +61 -2
- streamlit/watcher/util.py +26 -10
- streamlit/web/bootstrap.py +16 -4
- streamlit/web/cli.py +1 -4
- streamlit/web/server/app_discovery.py +2 -1
- streamlit/web/server/app_static_file_handler.py +9 -0
- streamlit/web/server/bidi_component_request_handler.py +4 -4
- streamlit/web/server/component_file_utils.py +14 -6
- streamlit/web/server/component_request_handler.py +2 -2
- streamlit/web/server/oauth_authlib_routes.py +14 -42
- streamlit/web/server/server.py +1 -1
- streamlit/web/server/server_util.py +23 -1
- streamlit/web/server/starlette/starlette_app.py +7 -1
- streamlit/web/server/starlette/starlette_auth_routes.py +94 -16
- streamlit/web/server/starlette/starlette_path_security_middleware.py +97 -0
- streamlit/web/server/starlette/starlette_routes.py +16 -9
- streamlit/web/server/starlette/starlette_server.py +2 -2
- streamlit/web/server/starlette/starlette_static_routes.py +14 -4
- streamlit/web/server/stats_request_handler.py +1 -3
- {streamlit-1.53.1.dist-info → streamlit-1.54.0.dist-info}/METADATA +10 -25
- {streamlit-1.53.1.dist-info → streamlit-1.54.0.dist-info}/RECORD +290 -290
- {streamlit-1.53.1.dist-info → streamlit-1.54.0.dist-info}/WHEEL +1 -1
- streamlit/commands/experimental_query_params.py +0 -169
- streamlit/static/static/js/Particles.ix5_l22I.js +0 -1
- streamlit/static/static/js/Toolbar.CxkcuBQ8.js +0 -1
- streamlit/static/static/js/embed.DZ-CLCPz.js +0 -195
- streamlit/static/static/js/index.B6ZAXv47.js +0 -1
- streamlit/static/static/js/index.BDm-Ia27.js +0 -1
- streamlit/static/static/js/index.BeCZLkzg.js +0 -1
- streamlit/static/static/js/index.BuEBeckn.js +0 -11
- streamlit/static/static/js/index.CL2eCR01.js +0 -1
- streamlit/static/static/js/index.CdLlbsiN.js +0 -1
- streamlit/static/static/js/index.CwIIk90V.js +0 -1
- streamlit/static/static/js/index.DDk0U8rh.js +0 -2
- streamlit/static/static/js/index.DNB79dOd.js +0 -3
- streamlit/static/static/js/index.DNj5S4tY.js +0 -1
- streamlit/static/static/js/index.DOY0ZriT.js +0 -2
- streamlit/static/static/js/index.r0gCrMFP.js +0 -1
- streamlit/static/static/js/uniqueId.BUj-C6GA.js +0 -1
- streamlit/static/static/js/useUpdateUiValue.Bk5OIXup.js +0 -1
- streamlit-1.53.1.data/scripts/streamlit.cmd +0 -16
- {streamlit-1.53.1.dist-info → streamlit-1.54.0.dist-info}/entry_points.txt +0 -0
- {streamlit-1.53.1.dist-info → streamlit-1.54.0.dist-info}/top_level.txt +0 -0
|
@@ -21,6 +21,7 @@ from typing import Final
|
|
|
21
21
|
import tornado.web
|
|
22
22
|
|
|
23
23
|
from streamlit.logger import get_logger
|
|
24
|
+
from streamlit.path_security import is_unsafe_path_pattern
|
|
24
25
|
|
|
25
26
|
_LOGGER: Final = get_logger(__name__)
|
|
26
27
|
|
|
@@ -53,6 +54,14 @@ class AppStaticFileHandler(tornado.web.StaticFileHandler):
|
|
|
53
54
|
def initialize(self, path: str, default_filename: str | None = None) -> None:
|
|
54
55
|
super().initialize(path, default_filename)
|
|
55
56
|
|
|
57
|
+
@classmethod
|
|
58
|
+
def get_absolute_path(cls, root: str, path: str) -> str:
|
|
59
|
+
# SECURITY: Validate path pattern BEFORE any filesystem operations.
|
|
60
|
+
# See is_unsafe_path_pattern() docstring for details.
|
|
61
|
+
if is_unsafe_path_pattern(path):
|
|
62
|
+
raise tornado.web.HTTPError(400, "Bad Request")
|
|
63
|
+
return super().get_absolute_path(root, path)
|
|
64
|
+
|
|
56
65
|
def validate_absolute_path(self, root: str, absolute_path: str) -> str | None:
|
|
57
66
|
full_path = os.path.abspath(absolute_path)
|
|
58
67
|
|
|
@@ -77,8 +77,8 @@ class BidiComponentRequestHandler(tornado.web.RequestHandler):
|
|
|
77
77
|
Notes
|
|
78
78
|
-----
|
|
79
79
|
This method writes directly to the response and sets appropriate HTTP
|
|
80
|
-
status codes on error (``404`` for missing components/files, ``
|
|
81
|
-
|
|
80
|
+
status codes on error (``404`` for missing components/files, ``400`` for
|
|
81
|
+
unsafe paths).
|
|
82
82
|
"""
|
|
83
83
|
parts = path.split("/")
|
|
84
84
|
component_name = parts[0]
|
|
@@ -105,8 +105,8 @@ class BidiComponentRequestHandler(tornado.web.RequestHandler):
|
|
|
105
105
|
return
|
|
106
106
|
abspath = build_safe_abspath(component_path, filename)
|
|
107
107
|
if abspath is None:
|
|
108
|
-
self.write("
|
|
109
|
-
self.set_status(
|
|
108
|
+
self.write("Bad Request")
|
|
109
|
+
self.set_status(400)
|
|
110
110
|
return
|
|
111
111
|
|
|
112
112
|
# If the resolved path is a directory, return 404 not found.
|
|
@@ -27,15 +27,18 @@ import mimetypes
|
|
|
27
27
|
import os
|
|
28
28
|
from typing import Final
|
|
29
29
|
|
|
30
|
+
from streamlit.path_security import is_unsafe_path_pattern
|
|
31
|
+
|
|
30
32
|
_OCTET_STREAM: Final[str] = "application/octet-stream"
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def build_safe_abspath(component_root: str, relative_url_path: str) -> str | None:
|
|
34
|
-
"""Build an absolute path inside ``component_root`` if safe.
|
|
36
|
+
r"""Build an absolute path inside ``component_root`` if safe.
|
|
35
37
|
|
|
36
|
-
The function
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
The function first validates that ``relative_url_path`` does not contain
|
|
39
|
+
dangerous patterns using :func:`~streamlit.path_security.is_unsafe_path_pattern`,
|
|
40
|
+
then joins it with ``component_root`` and resolves symlinks.
|
|
41
|
+
Returns ``None`` if the path is rejected by security checks or escapes the root.
|
|
39
42
|
|
|
40
43
|
Parameters
|
|
41
44
|
----------
|
|
@@ -43,13 +46,18 @@ def build_safe_abspath(component_root: str, relative_url_path: str) -> str | Non
|
|
|
43
46
|
Absolute path to the component's root directory.
|
|
44
47
|
relative_url_path : str
|
|
45
48
|
Relative URL path from the component root to the requested file.
|
|
49
|
+
Must be a simple relative path without dangerous patterns.
|
|
46
50
|
|
|
47
51
|
Returns
|
|
48
52
|
-------
|
|
49
53
|
str or None
|
|
50
|
-
The resolved absolute path if it
|
|
51
|
-
otherwise ``None
|
|
54
|
+
The resolved absolute path if it passes all validation and stays
|
|
55
|
+
within ``component_root``; otherwise ``None``.
|
|
52
56
|
"""
|
|
57
|
+
# See is_unsafe_path_pattern() for security details.
|
|
58
|
+
if is_unsafe_path_pattern(relative_url_path):
|
|
59
|
+
return None
|
|
60
|
+
|
|
53
61
|
root_real = os.path.realpath(component_root)
|
|
54
62
|
candidate = os.path.normpath(os.path.join(root_real, relative_url_path))
|
|
55
63
|
candidate_real = os.path.realpath(candidate)
|
|
@@ -48,8 +48,8 @@ class ComponentRequestHandler(tornado.web.RequestHandler):
|
|
|
48
48
|
filename = "/".join(parts[1:])
|
|
49
49
|
abspath = build_safe_abspath(component_root, filename)
|
|
50
50
|
if abspath is None:
|
|
51
|
-
self.write("
|
|
52
|
-
self.set_status(
|
|
51
|
+
self.write("Bad Request")
|
|
52
|
+
self.set_status(400)
|
|
53
53
|
return
|
|
54
54
|
try:
|
|
55
55
|
with open(abspath, "rb") as file:
|
|
@@ -15,18 +15,20 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
17
|
from typing import Any, Final, cast
|
|
18
|
-
from urllib.parse import urlencode, urlparse
|
|
19
18
|
|
|
20
19
|
import tornado.web
|
|
21
20
|
|
|
22
21
|
from streamlit.auth_util import (
|
|
23
22
|
AuthCache,
|
|
23
|
+
build_logout_url,
|
|
24
24
|
clear_cookie_and_chunks,
|
|
25
25
|
decode_provider_token,
|
|
26
26
|
generate_default_provider_section,
|
|
27
27
|
get_cookie_with_chunks,
|
|
28
|
+
get_origin_from_redirect_uri,
|
|
28
29
|
get_redirect_uri,
|
|
29
30
|
get_secrets_auth_section,
|
|
31
|
+
get_validated_redirect_uri,
|
|
30
32
|
set_cookie_with_chunks,
|
|
31
33
|
)
|
|
32
34
|
from streamlit.errors import StreamlitAuthError
|
|
@@ -179,21 +181,6 @@ class AuthLogoutHandler(AuthHandlerMixin, tornado.web.RequestHandler):
|
|
|
179
181
|
else:
|
|
180
182
|
self.redirect_to_base()
|
|
181
183
|
|
|
182
|
-
def _get_redirect_uri(self) -> str | None:
|
|
183
|
-
auth_section = get_secrets_auth_section()
|
|
184
|
-
if not auth_section:
|
|
185
|
-
return None
|
|
186
|
-
|
|
187
|
-
redirect_uri = get_redirect_uri(auth_section)
|
|
188
|
-
if not redirect_uri:
|
|
189
|
-
return None
|
|
190
|
-
|
|
191
|
-
if not redirect_uri.endswith("/oauth2callback"):
|
|
192
|
-
_LOGGER.warning("Redirect URI does not end with /oauth2callback")
|
|
193
|
-
return None
|
|
194
|
-
|
|
195
|
-
return redirect_uri
|
|
196
|
-
|
|
197
184
|
def _get_provider_logout_url(self) -> str | None:
|
|
198
185
|
"""Get the OAuth provider's logout URL from OIDC metadata."""
|
|
199
186
|
cookie_value = get_cookie_with_chunks(self._get_signed_cookie, AUTH_COOKIE_NAME)
|
|
@@ -219,17 +206,13 @@ class AuthLogoutHandler(AuthHandlerMixin, tornado.web.RequestHandler):
|
|
|
219
206
|
# Use redirect_uri (i.e. /oauth2callback) for post_logout_redirect_uri
|
|
220
207
|
# This is safer than redirecting to root as some providers seem to
|
|
221
208
|
# require url to be in a whitelist /oauth2callback should be whitelisted
|
|
222
|
-
redirect_uri =
|
|
209
|
+
redirect_uri = get_validated_redirect_uri()
|
|
223
210
|
if redirect_uri is None:
|
|
224
211
|
_LOGGER.info("Redirect url could not be determined")
|
|
225
212
|
return None
|
|
226
213
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
"post_logout_redirect_uri": redirect_uri,
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
# Add id_token_hint to logout params if it is available
|
|
214
|
+
# Get id_token_hint from tokens cookie if available
|
|
215
|
+
id_token: str | None = None
|
|
233
216
|
tokens_cookie_value = get_cookie_with_chunks(
|
|
234
217
|
self._get_signed_cookie, TOKENS_COOKIE_NAME
|
|
235
218
|
)
|
|
@@ -237,15 +220,16 @@ class AuthLogoutHandler(AuthHandlerMixin, tornado.web.RequestHandler):
|
|
|
237
220
|
try:
|
|
238
221
|
tokens = json.loads(tokens_cookie_value)
|
|
239
222
|
id_token = tokens.get("id_token")
|
|
240
|
-
if id_token:
|
|
241
|
-
logout_params["id_token_hint"] = id_token
|
|
242
223
|
except (json.JSONDecodeError, TypeError):
|
|
243
|
-
_LOGGER.exception(
|
|
244
|
-
"Error, invalid tokens cookie value.",
|
|
245
|
-
)
|
|
224
|
+
_LOGGER.exception("Error, invalid tokens cookie value.")
|
|
246
225
|
return None
|
|
247
226
|
|
|
248
|
-
return
|
|
227
|
+
return build_logout_url(
|
|
228
|
+
end_session_endpoint=end_session_endpoint,
|
|
229
|
+
client_id=client.client_id,
|
|
230
|
+
post_logout_redirect_uri=redirect_uri,
|
|
231
|
+
id_token=id_token,
|
|
232
|
+
)
|
|
249
233
|
|
|
250
234
|
except Exception as e:
|
|
251
235
|
_LOGGER.warning("Failed to get provider logout URL: %s", e)
|
|
@@ -327,16 +311,4 @@ class AuthCallbackHandler(AuthHandlerMixin, tornado.web.RequestHandler):
|
|
|
327
311
|
return provider
|
|
328
312
|
|
|
329
313
|
def _get_origin_from_secrets(self) -> str | None:
|
|
330
|
-
|
|
331
|
-
auth_section = get_secrets_auth_section()
|
|
332
|
-
if auth_section:
|
|
333
|
-
redirect_uri = get_redirect_uri(auth_section)
|
|
334
|
-
|
|
335
|
-
if not redirect_uri:
|
|
336
|
-
return None
|
|
337
|
-
|
|
338
|
-
redirect_uri_parsed = urlparse(redirect_uri)
|
|
339
|
-
origin_from_redirect_uri: str = (
|
|
340
|
-
redirect_uri_parsed.scheme + "://" + redirect_uri_parsed.netloc
|
|
341
|
-
)
|
|
342
|
-
return origin_from_redirect_uri
|
|
314
|
+
return get_origin_from_redirect_uri()
|
streamlit/web/server/server.py
CHANGED
|
@@ -263,7 +263,7 @@ def start_listening_tcp_socket(http_server: HTTPServer) -> None:
|
|
|
263
263
|
except OSError as e:
|
|
264
264
|
# EADDRINUSE: port in use by another process
|
|
265
265
|
# EACCES: port reserved by system (common on Windows, see #13521)
|
|
266
|
-
if e.errno in
|
|
266
|
+
if e.errno in {errno.EADDRINUSE, errno.EACCES}:
|
|
267
267
|
if server_port_is_manually_set():
|
|
268
268
|
_LOGGER.error("Port %s is not available", port) # noqa: TRY400
|
|
269
269
|
sys.exit(1)
|
|
@@ -82,7 +82,7 @@ def is_url_from_allowed_origins(url: str) -> bool:
|
|
|
82
82
|
url_util.get_hostname(origin) for origin in allowlisted_origins()
|
|
83
83
|
]
|
|
84
84
|
|
|
85
|
-
allowed_domains: list[str |
|
|
85
|
+
allowed_domains: list[str | Callable[[], str | None] | None] = [
|
|
86
86
|
# Check localhost first.
|
|
87
87
|
"localhost",
|
|
88
88
|
"0.0.0.0", # noqa: S104
|
|
@@ -137,6 +137,28 @@ def _get_server_address_if_manually_set() -> str | None:
|
|
|
137
137
|
return None
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def get_display_address(address: str) -> str:
|
|
141
|
+
"""Get a display-friendly address for URLs shown to users.
|
|
142
|
+
|
|
143
|
+
Wildcard addresses like "0.0.0.0" (all IPv4) or "::" (all interfaces)
|
|
144
|
+
are not valid browser addresses on all platforms. This translates
|
|
145
|
+
them to "localhost" for display purposes.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
address
|
|
150
|
+
The server address (IP or hostname).
|
|
151
|
+
|
|
152
|
+
Returns
|
|
153
|
+
-------
|
|
154
|
+
str
|
|
155
|
+
Address suitable for display. Wildcards become "localhost".
|
|
156
|
+
"""
|
|
157
|
+
if address in {"0.0.0.0", "::"}: # noqa: S104
|
|
158
|
+
return "localhost"
|
|
159
|
+
return address
|
|
160
|
+
|
|
161
|
+
|
|
140
162
|
def make_url_path_regex(
|
|
141
163
|
*path: str,
|
|
142
164
|
trailing_slash: Literal["optional", "required", "prohibited"] = "optional",
|
|
@@ -140,7 +140,7 @@ def create_streamlit_middleware() -> list[Middleware]:
|
|
|
140
140
|
"""Create the Streamlit-internal middleware stack.
|
|
141
141
|
|
|
142
142
|
This function creates the middleware required for Streamlit's core functionality
|
|
143
|
-
including session management and GZip compression.
|
|
143
|
+
including path security, session management, and GZip compression.
|
|
144
144
|
|
|
145
145
|
Returns
|
|
146
146
|
-------
|
|
@@ -153,9 +153,15 @@ def create_streamlit_middleware() -> list[Middleware]:
|
|
|
153
153
|
from streamlit.web.server.starlette.starlette_gzip_middleware import (
|
|
154
154
|
MediaAwareGZipMiddleware,
|
|
155
155
|
)
|
|
156
|
+
from streamlit.web.server.starlette.starlette_path_security_middleware import (
|
|
157
|
+
PathSecurityMiddleware,
|
|
158
|
+
)
|
|
156
159
|
|
|
157
160
|
middleware: list[Middleware] = []
|
|
158
161
|
|
|
162
|
+
# FIRST: Path security middleware to block dangerous paths before any other processing.
|
|
163
|
+
middleware.append(Middleware(PathSecurityMiddleware))
|
|
164
|
+
|
|
159
165
|
# Add session middleware
|
|
160
166
|
middleware.append(
|
|
161
167
|
Middleware(
|
|
@@ -12,19 +12,26 @@
|
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
|
+
# ruff: noqa: RUF029 # Async route handlers are idiomatic even without await
|
|
16
|
+
|
|
15
17
|
"""Starlette app authentication routes."""
|
|
16
18
|
|
|
17
19
|
from __future__ import annotations
|
|
18
20
|
|
|
21
|
+
import json
|
|
19
22
|
import time
|
|
20
23
|
from typing import TYPE_CHECKING, Any, Final, cast
|
|
21
|
-
from urllib.parse import urlparse
|
|
22
24
|
|
|
23
25
|
from streamlit.auth_util import (
|
|
26
|
+
build_logout_url,
|
|
24
27
|
clear_cookie_and_chunks,
|
|
25
28
|
decode_provider_token,
|
|
26
29
|
generate_default_provider_section,
|
|
30
|
+
get_cookie_with_chunks,
|
|
31
|
+
get_origin_from_redirect_uri,
|
|
32
|
+
get_redirect_uri,
|
|
27
33
|
get_secrets_auth_section,
|
|
34
|
+
get_validated_redirect_uri,
|
|
28
35
|
set_cookie_with_chunks,
|
|
29
36
|
)
|
|
30
37
|
from streamlit.errors import StreamlitAuthError
|
|
@@ -139,7 +146,7 @@ def _looks_like_provider_section(value: dict[str, Any]) -> bool:
|
|
|
139
146
|
return any(key in value for key in provider_keys)
|
|
140
147
|
|
|
141
148
|
|
|
142
|
-
class _AuthlibConfig(dict[str, Any]):
|
|
149
|
+
class _AuthlibConfig(dict[str, Any]): # noqa: FURB189
|
|
143
150
|
"""Config adapter that exposes provider data via Authlib's flat lookup.
|
|
144
151
|
|
|
145
152
|
Authlib expects a flat configuration dictionary (e.g. "GOOGLE_CLIENT_ID").
|
|
@@ -307,7 +314,7 @@ def _create_oauth_client(provider: str) -> tuple[Any, str]:
|
|
|
307
314
|
|
|
308
315
|
auth_section = get_secrets_auth_section()
|
|
309
316
|
if auth_section:
|
|
310
|
-
redirect_uri = auth_section
|
|
317
|
+
redirect_uri = get_redirect_uri(auth_section) or "/"
|
|
311
318
|
config = auth_section.to_dict()
|
|
312
319
|
else:
|
|
313
320
|
config = {}
|
|
@@ -385,20 +392,80 @@ def _get_provider_by_state(state_code_from_url: str | None) -> str | None:
|
|
|
385
392
|
|
|
386
393
|
def _get_origin_from_secrets() -> str | None:
|
|
387
394
|
"""Extract the origin from the redirect URI in the secrets."""
|
|
395
|
+
return get_origin_from_redirect_uri()
|
|
388
396
|
|
|
389
|
-
redirect_uri = None
|
|
390
|
-
auth_section = get_secrets_auth_section()
|
|
391
|
-
if auth_section:
|
|
392
|
-
redirect_uri = auth_section.get("redirect_uri", None)
|
|
393
397
|
|
|
394
|
-
|
|
398
|
+
def _get_cookie_value_from_request(request: Request, cookie_name: str) -> bytes | None:
|
|
399
|
+
"""Get a signed cookie value from the request, handling chunked cookies."""
|
|
400
|
+
|
|
401
|
+
def get_single_cookie(name: str) -> bytes | None:
|
|
402
|
+
return _get_signed_cookie_from_request(request, name)
|
|
403
|
+
|
|
404
|
+
return get_cookie_with_chunks(get_single_cookie, cookie_name)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _get_provider_logout_url(request: Request) -> str | None:
|
|
408
|
+
"""Get the OAuth provider's logout URL from OIDC metadata.
|
|
409
|
+
|
|
410
|
+
Returns the end_session_endpoint URL with proper parameters for OIDC logout,
|
|
411
|
+
or None if the provider doesn't support it or required data is unavailable.
|
|
412
|
+
|
|
413
|
+
This function returns None (rather than raising exceptions) to allow graceful
|
|
414
|
+
fallback to a simple base URL redirect when OIDC logout isn't possible.
|
|
415
|
+
"""
|
|
416
|
+
cookie_value = _get_cookie_value_from_request(request, USER_COOKIE_NAME)
|
|
417
|
+
|
|
418
|
+
if not cookie_value:
|
|
395
419
|
return None
|
|
396
420
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
421
|
+
try:
|
|
422
|
+
user_info = json.loads(cookie_value)
|
|
423
|
+
provider = user_info.get("provider")
|
|
424
|
+
if not provider:
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
client, _ = _create_oauth_client(provider)
|
|
428
|
+
|
|
429
|
+
# Load OIDC metadata - Authlib's Starlette client uses async methods
|
|
430
|
+
# but load_server_metadata is synchronous in both implementations
|
|
431
|
+
metadata = client.load_server_metadata()
|
|
432
|
+
end_session_endpoint = metadata.get("end_session_endpoint")
|
|
433
|
+
|
|
434
|
+
if not end_session_endpoint:
|
|
435
|
+
_LOGGER.info("No end_session_endpoint found for provider %s", provider)
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
# Use redirect_uri (i.e. /oauth2callback) for post_logout_redirect_uri
|
|
439
|
+
# This is safer than redirecting to root as some providers seem to
|
|
440
|
+
# require URL to be in a whitelist - /oauth2callback should be whitelisted
|
|
441
|
+
redirect_uri = get_validated_redirect_uri()
|
|
442
|
+
if redirect_uri is None:
|
|
443
|
+
_LOGGER.info("Redirect url could not be determined")
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
# Get id_token_hint from tokens cookie if available
|
|
447
|
+
id_token: str | None = None
|
|
448
|
+
tokens_cookie_value = _get_cookie_value_from_request(
|
|
449
|
+
request, TOKENS_COOKIE_NAME
|
|
450
|
+
)
|
|
451
|
+
if tokens_cookie_value:
|
|
452
|
+
try:
|
|
453
|
+
tokens = json.loads(tokens_cookie_value)
|
|
454
|
+
id_token = tokens.get("id_token")
|
|
455
|
+
except (json.JSONDecodeError, TypeError):
|
|
456
|
+
_LOGGER.exception("Error, invalid tokens cookie value.")
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
return build_logout_url(
|
|
460
|
+
end_session_endpoint=end_session_endpoint,
|
|
461
|
+
client_id=client.client_id,
|
|
462
|
+
post_logout_redirect_uri=redirect_uri,
|
|
463
|
+
id_token=id_token,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
except Exception as e:
|
|
467
|
+
_LOGGER.warning("Failed to get provider logout URL: %s", e)
|
|
468
|
+
return None
|
|
402
469
|
|
|
403
470
|
|
|
404
471
|
async def _auth_login(request: Request, base_url: str) -> Response:
|
|
@@ -421,9 +488,20 @@ async def _auth_login(request: Request, base_url: str) -> Response:
|
|
|
421
488
|
|
|
422
489
|
|
|
423
490
|
async def _auth_logout(request: Request, base_url: str) -> Response:
|
|
424
|
-
"""Logout the user by clearing the auth cookie and redirecting
|
|
491
|
+
"""Logout the user by clearing the auth cookie and redirecting.
|
|
492
|
+
|
|
493
|
+
If the OAuth provider supports end_session_endpoint, redirects there for
|
|
494
|
+
proper OIDC logout. Otherwise, redirects to the base URL.
|
|
495
|
+
"""
|
|
496
|
+
from starlette.responses import RedirectResponse
|
|
497
|
+
|
|
498
|
+
provider_logout_url = _get_provider_logout_url(request)
|
|
499
|
+
|
|
500
|
+
if provider_logout_url:
|
|
501
|
+
response = RedirectResponse(provider_logout_url, status_code=302)
|
|
502
|
+
else:
|
|
503
|
+
response = await _redirect_to_base(base_url)
|
|
425
504
|
|
|
426
|
-
response = await _redirect_to_base(base_url)
|
|
427
505
|
_clear_auth_cookie(response, request)
|
|
428
506
|
return response
|
|
429
507
|
|
|
@@ -471,7 +549,7 @@ async def _auth_callback(request: Request, base_url: str) -> Response:
|
|
|
471
549
|
|
|
472
550
|
response = await _redirect_to_base(base_url)
|
|
473
551
|
|
|
474
|
-
cookie_value = dict(user, origin=origin, is_logged_in=True)
|
|
552
|
+
cookie_value = dict(user, origin=origin, is_logged_in=True, provider=provider)
|
|
475
553
|
tokens = {k: token[k] for k in ["id_token", "access_token"] if k in token}
|
|
476
554
|
if user:
|
|
477
555
|
await _set_auth_cookie(response, cookie_value, tokens)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026)
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Path security middleware for blocking unsafe path patterns.
|
|
16
|
+
|
|
17
|
+
This middleware implements the "Swiss Cheese" defense model - it provides
|
|
18
|
+
an additional layer of protection that catches dangerous path patterns even
|
|
19
|
+
if individual route handlers forget to validate paths. This is especially
|
|
20
|
+
important for preventing SSRF attacks via Windows UNC paths.
|
|
21
|
+
|
|
22
|
+
Defense Layers
|
|
23
|
+
--------------
|
|
24
|
+
Layer 1 (this middleware): Catch-all for any route, including future routes
|
|
25
|
+
Layer 2 (route handlers): Defense-in-depth via build_safe_abspath() and
|
|
26
|
+
explicit is_unsafe_path_pattern() checks
|
|
27
|
+
|
|
28
|
+
Each layer has potential "holes" (ways it could fail):
|
|
29
|
+
- Middleware: Could be accidentally removed, misconfigured, or bypassed
|
|
30
|
+
- Route handlers: Developer could forget to add checks to new routes
|
|
31
|
+
|
|
32
|
+
By keeping both layers, an attack only succeeds if BOTH fail simultaneously.
|
|
33
|
+
|
|
34
|
+
See Also
|
|
35
|
+
--------
|
|
36
|
+
streamlit.path_security : Core path validation functions used by this middleware
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from typing import TYPE_CHECKING
|
|
42
|
+
|
|
43
|
+
from starlette.responses import Response
|
|
44
|
+
|
|
45
|
+
from streamlit.path_security import is_unsafe_path_pattern
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PathSecurityMiddleware:
|
|
52
|
+
"""ASGI middleware that blocks requests with unsafe path patterns.
|
|
53
|
+
|
|
54
|
+
Implements Swiss Cheese defense - catches dangerous patterns even if
|
|
55
|
+
route handlers forget to validate paths. This prevents SSRF attacks
|
|
56
|
+
via Windows UNC paths and other path traversal vulnerabilities.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
app
|
|
61
|
+
The ASGI application to wrap.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
65
|
+
self.app = app
|
|
66
|
+
|
|
67
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
68
|
+
"""Process incoming requests and block unsafe paths.
|
|
69
|
+
|
|
70
|
+
Only validates HTTP requests; WebSocket and lifespan scopes are
|
|
71
|
+
passed through without validation since they don't serve file content.
|
|
72
|
+
"""
|
|
73
|
+
# Only validate HTTP requests (skip WebSocket, lifespan)
|
|
74
|
+
if scope["type"] != "http":
|
|
75
|
+
await self.app(scope, receive, send)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
path = scope.get("path", "")
|
|
79
|
+
|
|
80
|
+
# SECURITY: Check for double-slash patterns BEFORE stripping slashes.
|
|
81
|
+
# UNC paths like "//server/share" would be normalized to "server/share"
|
|
82
|
+
# by lstrip("/"), making them look safe. We must reject these early.
|
|
83
|
+
if path.startswith(("//", "\\\\")):
|
|
84
|
+
response = Response(content="Bad Request", status_code=400)
|
|
85
|
+
await response(scope, receive, send)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
# Strip leading slash to get the relative path for validation
|
|
89
|
+
relative_path = path.lstrip("/")
|
|
90
|
+
|
|
91
|
+
# Check if the path contains unsafe patterns
|
|
92
|
+
if relative_path and is_unsafe_path_pattern(relative_path):
|
|
93
|
+
response = Response(content="Bad Request", status_code=400)
|
|
94
|
+
await response(scope, receive, send)
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
await self.app(scope, receive, send)
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
|
+
# ruff: noqa: RUF029 # Async route handlers are idiomatic even without await
|
|
16
|
+
|
|
15
17
|
"""Route handlers for the Starlette server."""
|
|
16
18
|
|
|
17
19
|
from __future__ import annotations
|
|
@@ -341,9 +343,7 @@ def create_metrics_routes(runtime: Runtime, base_url: str | None) -> list[BaseRo
|
|
|
341
343
|
|
|
342
344
|
async def _metrics_endpoint(request: Request) -> Response:
|
|
343
345
|
requested_families = request.query_params.getlist("families")
|
|
344
|
-
stats = runtime.stats_mgr.get_stats(
|
|
345
|
-
family_names=requested_families if requested_families else None
|
|
346
|
-
)
|
|
346
|
+
stats = runtime.stats_mgr.get_stats(family_names=requested_families or None)
|
|
347
347
|
accept = request.headers.get("Accept", "")
|
|
348
348
|
if "application/x-protobuf" in accept:
|
|
349
349
|
payload = StatsRequestHandler._stats_to_proto(stats).SerializeToString()
|
|
@@ -701,7 +701,8 @@ def create_component_routes(
|
|
|
701
701
|
# Use build_safe_abspath to properly resolve symlinks and prevent traversal
|
|
702
702
|
abspath = build_safe_abspath(component_root, filename)
|
|
703
703
|
if abspath is None:
|
|
704
|
-
|
|
704
|
+
# Return 400 for malicious paths (consistent with middleware behavior)
|
|
705
|
+
raise HTTPException(status_code=400, detail="Bad Request")
|
|
705
706
|
|
|
706
707
|
try:
|
|
707
708
|
async with await anyio.open_file(abspath, "rb") as file:
|
|
@@ -743,6 +744,7 @@ def create_bidi_component_routes(
|
|
|
743
744
|
) -> list[BaseRoute]:
|
|
744
745
|
"""Create bidirectional component route handlers."""
|
|
745
746
|
import anyio
|
|
747
|
+
from anyio import Path as AsyncPath
|
|
746
748
|
from starlette.responses import PlainTextResponse, Response
|
|
747
749
|
from starlette.routing import Route
|
|
748
750
|
|
|
@@ -771,9 +773,10 @@ def create_bidi_component_routes(
|
|
|
771
773
|
|
|
772
774
|
abspath = build_safe_abspath(component_root, filename)
|
|
773
775
|
if abspath is None:
|
|
774
|
-
|
|
776
|
+
# Return 400 for unsafe paths (matches Tornado behavior for opacity)
|
|
777
|
+
return await _text_response("Bad Request", 400)
|
|
775
778
|
|
|
776
|
-
if
|
|
779
|
+
if await AsyncPath(abspath).is_dir():
|
|
777
780
|
return await _text_response("not found", 404)
|
|
778
781
|
|
|
779
782
|
try:
|
|
@@ -819,6 +822,7 @@ def create_app_static_serving_routes(
|
|
|
819
822
|
main_script_path: str | None, base_url: str | None
|
|
820
823
|
) -> list[BaseRoute]:
|
|
821
824
|
"""Create app static serving file route handlers."""
|
|
825
|
+
from anyio import Path as AsyncPath
|
|
822
826
|
from starlette.exceptions import HTTPException
|
|
823
827
|
from starlette.responses import FileResponse, Response
|
|
824
828
|
from starlette.routing import Route
|
|
@@ -836,12 +840,15 @@ def create_app_static_serving_routes(
|
|
|
836
840
|
relative_path = request.path_params.get("path", "")
|
|
837
841
|
safe_path = build_safe_abspath(app_static_root, relative_path)
|
|
838
842
|
if safe_path is None:
|
|
839
|
-
|
|
843
|
+
# Return 400 for malicious paths (consistent with middleware behavior)
|
|
844
|
+
raise HTTPException(status_code=400, detail="Bad Request")
|
|
840
845
|
|
|
841
|
-
|
|
846
|
+
async_path = AsyncPath(safe_path)
|
|
847
|
+
if not await async_path.exists() or await async_path.is_dir():
|
|
842
848
|
raise HTTPException(status_code=404, detail="File not found")
|
|
843
849
|
|
|
844
|
-
|
|
850
|
+
file_stat = await async_path.stat()
|
|
851
|
+
if file_stat.st_size > MAX_APP_STATIC_FILE_SIZE:
|
|
845
852
|
raise HTTPException(
|
|
846
853
|
status_code=404,
|
|
847
854
|
detail="File is too large",
|
|
@@ -288,7 +288,7 @@ class UvicornServer:
|
|
|
288
288
|
last_exception = exc
|
|
289
289
|
# EADDRINUSE: port in use by another process
|
|
290
290
|
# EACCES: port reserved by system (common on Windows, see #13521)
|
|
291
|
-
if exc.errno in
|
|
291
|
+
if exc.errno in {errno.EADDRINUSE, errno.EACCES}:
|
|
292
292
|
if _is_port_manually_set():
|
|
293
293
|
_LOGGER.error("Port %s is not available", port) # noqa: TRY400
|
|
294
294
|
sys.exit(1)
|
|
@@ -476,7 +476,7 @@ class UvicornRunner:
|
|
|
476
476
|
except OSError as exc:
|
|
477
477
|
# EADDRINUSE: port in use by another process
|
|
478
478
|
# EACCES: port reserved by system (common on Windows)
|
|
479
|
-
if exc.errno in
|
|
479
|
+
if exc.errno in {errno.EADDRINUSE, errno.EACCES}:
|
|
480
480
|
if _is_port_manually_set():
|
|
481
481
|
_LOGGER.error("Port %s is not available", port) # noqa: TRY400
|
|
482
482
|
sys.exit(1)
|