Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,12 @@ jobs:
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

publish:
build-dist:
needs: [release]
if: needs.release.outputs.released == 'true'
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: write
id-token: write
contents: read
steps:
- uses: actions/checkout@v6
with:
Expand All @@ -102,13 +100,42 @@ jobs:
python -m pip install --upgrade build
python -m build

# Upload dists to the GitHub Release that semantic-release already created
- uses: actions/upload-artifact@v7
with:
name: release-dist
path: dist/

upload-release-assets:
needs: [release, build-dist]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v7
with:
name: release-dist
path: dist/

# Upload dists to the GitHub Release that semantic-release already created.
- name: Upload assets to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ needs.release.outputs.tag }}" dist/* --clobber

# Publish to PyPI
publish:
needs: [release, build-dist]
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v7
with:
name: release-dist
path: dist/

# This job handles the completed artifact only; no build dependencies run
# with the PyPI OIDC capability.
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Lightweight alternatives with no popup. Users navigate date segments with arrow

| Component | Description | Streamlit equivalent |
|-----------|-------------|----------------------|
| `carousel` | Content/image carousel with autoplay, local files & URLs | -- |
| `carousel` | Content/image carousel with autoplay, explicit local paths & URLs | -- |
| `timeline` | Timeline with custom react-icons | -- |
| `pin_input` | PIN/verification code input with masking | `st.text_input` |

Expand Down Expand Up @@ -623,7 +623,7 @@ multi_cascade_tree(

```python
carousel(
items=[...], # [{content?, src?, alt?, background?, color?}] # src: URL or local file path
items=[...], # src: URL/data string or explicit pathlib.Path
autoplay=True,
autoplay_interval=4000, # ms between slides
placement="bottom", # indicator: 'top' | 'bottom' | 'left' | 'right'
Expand All @@ -635,9 +635,11 @@ carousel(
) -> int # active slide index
```

> A non-URL `src` is read from the local filesystem and inlined as a base64
> data URI, so it must be a trusted path chosen by the app, not unsanitized
> user input.
> A string `src` is always a browser reference and is never probed on the
> server filesystem. To inline an app-owned local image, pass a `pathlib.Path`
> (or another `os.PathLike`) chosen by the app. Local assets are limited to
> 10 MiB each and 20 MiB per carousel render; never construct the path object
> from unsanitized user input.

#### `timeline`

Expand Down
12 changes: 6 additions & 6 deletions examples/app_pages/carousel.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@
ci_local = carousel(
items=[
{
"src": str(_ASSETS / "starry_night.jpg"),
"src": _ASSETS / "starry_night.jpg",
"alt": "The Starry Night by Vincent van Gogh, 1889",
},
{
"src": str(_ASSETS / "great_wave.jpg"),
"src": _ASSETS / "great_wave.jpg",
"alt": "The Great Wave off Kanagawa by Katsushika Hokusai, c. 1831",
},
{
"src": str(_ASSETS / "girl_pearl_earring.jpg"),
"src": _ASSETS / "girl_pearl_earring.jpg",
"alt": "Girl with a Pearl Earring by Johannes Vermeer, c. 1665",
},
],
Expand All @@ -52,9 +52,9 @@

active = carousel(
items=[
{"src": str(ASSETS / "starry_night.jpg"), "alt": "Starry Night"},
{"src": str(ASSETS / "great_wave.jpg"), "alt": "Great Wave"},
{"src": str(ASSETS / "pearl_earring.jpg"), "alt": "Pearl Earring"},
{"src": ASSETS / "starry_night.jpg", "alt": "Starry Night"},
{"src": ASSETS / "great_wave.jpg", "alt": "Great Wave"},
{"src": ASSETS / "pearl_earring.jpg", "alt": "Pearl Earring"},
],
autoplay=True,
autoplay_interval=4000,
Expand Down
8 changes: 6 additions & 2 deletions examples/app_pages/pin_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
"A PIN / verification code input with configurable length, "
"masking, and input type filtering."
)
st.caption(
"Demo only: masked PIN and OTP values are not echoed in plaintext. "
"Do not enter real credentials."
)

st.markdown("#### Basic (6-digit)")

Expand All @@ -32,7 +36,7 @@
disabled=disabled,
key="pi_masked",
)
st.code(f"PIN: '{pi2}'")
st.code(f"PIN entered: {len(pi2 or '')}/4 digits")

st.divider()

Expand Down Expand Up @@ -61,7 +65,7 @@
disabled=disabled,
key="pi_otp",
)
st.code(f"OTP: '{pi4}'")
st.code(f"OTP entered: {len(pi4 or '')}/6 digits")

with st.expander("Usage code", icon=":material/code:"):
st.code(
Expand Down
71 changes: 55 additions & 16 deletions st_rsuite/carousel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,50 @@
import mimetypes
import os
from collections.abc import Callable
from os import PathLike

from st_rsuite._component import bind_kind

_component = bind_kind("carousel")

_MAX_LOCAL_IMAGE_BYTES = 10 * 1024 * 1024
_MAX_LOCAL_CAROUSEL_BYTES = 20 * 1024 * 1024

def _resolve_src(src: str) -> str:
"""Convert local file paths to base64 data URIs; leave URLs unchanged."""
if src.startswith(("http://", "https://", "data:")):
return src
path = os.path.expanduser(src)
if os.path.isfile(path):
mime = mimetypes.guess_type(path)[0] or "image/jpeg"
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
return f"data:{mime};base64,{b64}"
return src

def _resolve_src(
src: str | PathLike[str], *, max_bytes: int = _MAX_LOCAL_IMAGE_BYTES
) -> tuple[str, int]:
"""Resolve an explicit local path without probing ordinary URL strings.

Returns the browser source and the number of local bytes consumed. A
``PathLike`` is the caller's explicit authorization to read a local asset;
a ``str`` is always a browser reference, even when it happens to name a
file on the server.
"""
if isinstance(src, str):
return src, 0
if not isinstance(src, PathLike):
raise TypeError("carousel item 'src' must be a string or os.PathLike")

path = os.fspath(src)
if not isinstance(path, str):
raise TypeError("carousel local image paths must resolve to text")
if max_bytes < 0:
raise ValueError("carousel local image byte budget is exhausted")

# Bound the read itself rather than trusting stat().st_size: pseudo-files
# can report a misleading size, and reading one byte past the budget gives
# a deterministic overflow check without allocating the whole file.
with open(path, "rb") as f:
payload = f.read(max_bytes + 1)
if len(payload) > max_bytes:
raise ValueError(
f"carousel local image exceeds the {max_bytes}-byte remaining limit"
)

mime = mimetypes.guess_type(path)[0] or "image/jpeg"
b64 = base64.b64encode(payload).decode()
return f"data:{mime};base64,{b64}", len(payload)


def carousel(
Expand All @@ -43,14 +70,17 @@ def carousel(
items : list of dict
Carousel slides. Each dict can have:
- 'content' (str): text content to display
- 'src' (str): image URL or local file path
- 'src' (str or os.PathLike): browser URL/data reference, or an
explicit local path object
- 'alt' (str): image alt text
- 'background' (str): background color (default '#8b5cf6')
- 'color' (str): text color (default '#fff')

A non-URL 'src' is read from the local filesystem and inlined as a
base64 data URI, so 'src' must be a trusted path chosen by the app, not
unsanitized user input (it would otherwise be an arbitrary-file read).
Plain strings are always passed to the browser and never probed on the
server filesystem. To inline an app-owned local image, pass a
``pathlib.Path`` (or another ``os.PathLike``) chosen by the app. Local
assets are limited to 10 MiB each and 20 MiB per carousel render; do not
construct the path object from unsanitized user input.
autoplay : bool
Auto-transition between slides.
autoplay_interval : int
Expand Down Expand Up @@ -78,9 +108,18 @@ def _noop():
pass

resolved_items = []
local_bytes = 0
for item in items:
if "src" in item:
resolved_items.append({**item, "src": _resolve_src(item["src"])})
remaining = min(
_MAX_LOCAL_IMAGE_BYTES,
_MAX_LOCAL_CAROUSEL_BYTES - local_bytes,
)
resolved_src, bytes_used = _resolve_src(
item["src"], max_bytes=remaining
)
local_bytes += bytes_used
resolved_items.append({**item, "src": resolved_src})
else:
resolved_items.append(item)

Expand Down
98 changes: 98 additions & 0 deletions test/test_carousel_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Security guards for carousel browser and local image sources."""

from __future__ import annotations

import base64
from collections.abc import Callable
from pathlib import Path

import pytest
from registration_stub import stubbed_registration


def _carousel_module(factory: Callable):
with stubbed_registration("carousel", factory) as module:
return module


def _unused_registration(name, **kwargs):
def render(**call_kwargs):
return {"active_index": 0}

return render


def test_plain_string_that_names_a_file_is_never_read(tmp_path: Path):
secret = tmp_path / "secret.txt"
secret.write_text("server-secret")
module = _carousel_module(_unused_registration)

resolved, bytes_used = module._resolve_src(str(secret))

assert resolved == str(secret)
assert bytes_used == 0
assert "server-secret" not in resolved


def test_relative_and_home_path_strings_are_browser_references(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
secret = tmp_path / "secret.png"
secret.write_bytes(b"server-secret")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
module = _carousel_module(_unused_registration)

for source in ("secret.png", "~/secret.png"):
assert module._resolve_src(source) == (source, 0)


@pytest.mark.parametrize(
"source",
[
"https://example.com/image.png",
"data:image/png;base64,AA==",
"./browser-relative/image.png",
],
)
def test_browser_string_sources_are_unchanged(source: str):
module = _carousel_module(_unused_registration)
assert module._resolve_src(source) == (source, 0)


def test_explicit_path_is_inlined_with_a_bounded_read(tmp_path: Path):
image = tmp_path / "pixel.png"
payload = b"not-a-real-png-but-explicit-app-owned-bytes"
image.write_bytes(payload)
module = _carousel_module(_unused_registration)

resolved, bytes_used = module._resolve_src(image, max_bytes=len(payload))

assert bytes_used == len(payload)
assert resolved == (
"data:image/png;base64," + base64.b64encode(payload).decode()
)


def test_explicit_path_over_the_read_limit_is_rejected(tmp_path: Path):
image = tmp_path / "large.png"
image.write_bytes(b"12345")
module = _carousel_module(_unused_registration)

with pytest.raises(ValueError, match="exceeds the 4-byte remaining limit"):
module._resolve_src(image, max_bytes=4)


def test_carousel_enforces_an_aggregate_local_byte_budget(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
first = tmp_path / "first.png"
second = tmp_path / "second.png"
first.write_bytes(b"123")
second.write_bytes(b"456")
module = _carousel_module(_unused_registration)
monkeypatch.setattr(module, "_MAX_LOCAL_IMAGE_BYTES", 5)
monkeypatch.setattr(module, "_MAX_LOCAL_CAROUSEL_BYTES", 5)

with pytest.raises(ValueError, match="exceeds the 2-byte remaining limit"):
module.carousel(items=[{"src": first}, {"src": second}], autoplay=False)
39 changes: 39 additions & 0 deletions test/test_example_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Regression guards for security-sensitive showcase output."""

from __future__ import annotations

import ast
from pathlib import Path

SENSITIVE_VALUES = {"pi2", "pi4"}
VISIBLE_STREAMLIT_SINKS = {"code", "html", "markdown", "text", "write"}


def test_masked_pin_and_otp_are_not_echoed_verbatim():
source_path = (
Path(__file__).parent.parent / "examples" / "app_pages" / "pin_input.py"
)
tree = ast.parse(source_path.read_text())

for call in (node for node in ast.walk(tree) if isinstance(node, ast.Call)):
func = call.func
if not (
isinstance(func, ast.Attribute)
and isinstance(func.value, ast.Name)
and func.value.id == "st"
and func.attr in VISIBLE_STREAMLIT_SINKS
):
continue

for argument in call.args:
assert not (
isinstance(argument, ast.Name) and argument.id in SENSITIVE_VALUES
), f"{argument.id} is rendered directly by st.{func.attr}"
if not isinstance(argument, ast.JoinedStr):
continue
for part in argument.values:
assert not (
isinstance(part, ast.FormattedValue)
and isinstance(part.value, ast.Name)
and part.value.id in SENSITIVE_VALUES
), f"{part.value.id} is interpolated directly by st.{func.attr}"
Loading
Loading