Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sanitise avatar URL client-side #1420

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ venv
.DS_Store

.chainlit
!cypress/e2e/**/*/.chainlit/*
!cypress/e2e/**/.chainlit/**
cypress/e2e/**/.chainlit/translations
chainlit.md

cypress/screenshots
Expand Down
5 changes: 2 additions & 3 deletions backend/chainlit/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1045,13 +1045,12 @@ async def get_logo(theme: Optional[Theme] = Query(Theme.light)):
@router.get("/avatars/{avatar_id:str}")
async def get_avatar(avatar_id: str):
"""Get the avatar for the user based on the avatar_id."""
if not re.match(r"^[a-zA-Z0-9_ -]+$", avatar_id):
if not re.match(r"^[a-z0-9_]+$", avatar_id):
raise HTTPException(status_code=400, detail="Invalid avatar_id")

if avatar_id == "default":
avatar_id = config.ui.name

avatar_id = avatar_id.strip().lower().replace(" ", "_")
avatar_id = avatar_id.strip().lower().replace(" ", "_")

base_path = Path(APP_ROOT) / "public" / "avatars"
avatar_pattern = f"{avatar_id}.*"
Expand Down
18 changes: 0 additions & 18 deletions backend/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,24 +184,6 @@ def test_get_avatar_custom(test_client: TestClient, monkeypatch: pytest.MonkeyPa
os.remove(custom_avatar_path)


def test_get_avatar_with_spaces(
test_client: TestClient, monkeypatch: pytest.MonkeyPatch
):
"""Test with custom avatar."""
custom_avatar_path = os.path.join(APP_ROOT, "public", "avatars", "my_assistant.png")
os.makedirs(os.path.dirname(custom_avatar_path), exist_ok=True)
with open(custom_avatar_path, "wb") as f:
f.write(b"fake image data")

response = test_client.get("/avatars/My Assistant")
assert response.status_code == 200
assert response.headers["content-type"].startswith("image/")
assert response.content == b"fake image data"

# Clean up
os.remove(custom_avatar_path)


def test_get_avatar_non_existent_favicon(
test_client: TestClient, monkeypatch: pytest.MonkeyPatch
):
Expand Down
111 changes: 111 additions & 0 deletions cypress/e2e/echo/.chainlit/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
[project]
# Whether to enable telemetry (default: true). No personal data is collected.
enable_telemetry = true


# List of environment variables to be provided by each user to use the app.
user_env = []

# Duration (in seconds) during which the session is saved when the connection is lost
session_timeout = 3600

# Enable third parties caching (e.g LangChain cache)
cache = false

# Authorized origins
allow_origins = ["*"]

# Follow symlink for asset mount (see https://github.com/Chainlit/chainlit/issues/317)
# follow_symlink = false

[features]
# Process and display HTML in messages. This can be a security risk (see https://stackoverflow.com/questions/19603097/why-is-it-dangerous-to-render-user-generated-html-or-javascript)
unsafe_allow_html = false

# Process and display mathematical expressions. This can clash with "$" characters in messages.
latex = false

# Automatically tag threads with the current chat profile (if a chat profile is used)
auto_tag_thread = true

# Allow users to edit their own messages
edit_message = true

# Authorize users to spontaneously upload files with messages
[features.spontaneous_file_upload]
enabled = true
accept = ["*/*"]
max_files = 20
max_size_mb = 500

[features.audio]
# Sample rate of the audio
sample_rate = 24000

[UI]
# Name of the assistant.
name = "My Assistant"

# Description of the assistant. This is used for HTML tags.
# description = ""

# Large size content are by default collapsed for a cleaner ui
default_collapse_content = true

# Chain of Thought (CoT) display mode. Can be "hidden", "tool_call" or "full".
cot = "full"

# Link to your github repo. This will add a github button in the UI's header.
# github = ""

# Specify a CSS file that can be used to customize the user interface.
# The CSS file can be served from the public directory or via an external link.
# custom_css = "/public/test.css"

# Specify a Javascript file that can be used to customize the user interface.
# The Javascript file can be served from the public directory.
# custom_js = "/public/test.js"

# Specify a custom font url.
# custom_font = "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap"

# Specify a custom meta image url.
# custom_meta_image_url = "https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png"

# Specify a custom build directory for the frontend.
# This can be used to customize the frontend code.
# Be careful: If this is a relative path, it should not start with a slash.
# custom_build = "./public/build"

[UI.theme]
default = "dark"
#layout = "wide"
#font_family = "Inter, sans-serif"
# Override default MUI light theme. (Check theme.ts)
[UI.theme.light]
#background = "#FAFAFA"
#paper = "#FFFFFF"

[UI.theme.light.primary]
#main = "#F80061"
#dark = "#980039"
#light = "#FFE7EB"
[UI.theme.light.text]
#primary = "#212121"
#secondary = "#616161"

# Override default MUI dark theme. (Check theme.ts)
[UI.theme.dark]
#background = "#FAFAFA"
#paper = "#FFFFFF"

[UI.theme.dark.primary]
#main = "#F80061"
#dark = "#980039"
#light = "#FFE7EB"
[UI.theme.dark.text]
#primary = "#EEEEEE"
#secondary = "#BDBDBD"

[meta]
generated_by = "1.2.0"
3 changes: 3 additions & 0 deletions cypress/e2e/echo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Echo app

Based on [In Pure Python](https://docs.chainlit.io/get-started/pure-python) documentation section.
12 changes: 12 additions & 0 deletions cypress/e2e/echo/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import chainlit as cl


@cl.on_message
async def main(message: cl.Message):
"""Example from 'In Pure Python' docs section."""
# Your custom logic goes here...

# Send a response back to the user
await cl.Message(
content=f"Received: {message.content}",
).send()
Binary file added cypress/e2e/echo/public/avatars/my_assistant.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
68 changes: 68 additions & 0 deletions cypress/e2e/echo/spec.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { runTestServer, submitMessage } from '../../support/testUtils';

describe('Basic echo example', () => {
before(() => {
runTestServer();
});

describe('Steps', () => {
beforeEach(() => {
cy.visit('/');
submitMessage("I'm functional.");
cy.get('.step').as('steps');
});

it('should show 2 steps', () => {
cy.get('@steps').should('have.length', 2);
});

describe('User message', () => {
beforeEach(() => {
cy.get('@steps').eq(0).as('user_message');
});

it('should contain the submitted message ', () => {
cy.get('@user_message').should('contain', "I'm functional.");
});
});

describe('AI message', () => {
beforeEach(() => {
cy.get('@steps').eq(1).as('ai_message');
});

it('should echo submitted message, prefixed by: "Received: "', () => {
cy.get('@ai_message').should('contain', "Received: I'm functional.");
});

describe('avatar', () => {
beforeEach(() => {
cy.get('@ai_message').find('.message-avatar').as('message_avatar');
cy.get('@message_avatar')
.find('.MuiAvatar-root img')
.as('avatar_img');
});

it('should have "My Assistant" as label', () => {
cy.get('@message_avatar')
.find('.MuiAvatar-root')
.should('have.attr', 'aria-label', 'My Assistant');
});

it('should load /avatars/my_assistant', () => {
cy.get('@avatar_img')
.should('have.length', 1)
.should('have.attr', 'src')
.and('include', '/avatars/my_assistant');

cy.get('@avatar_img')
.should('be.visible')
.and(($img) => {
const img = $img[0] as HTMLImageElement;
expect(img.naturalWidth).to.be.greaterThan(0);
});
});
});
});
});
});
14 changes: 13 additions & 1 deletion frontend/src/components/molecules/messages/components/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ interface Props {
hide?: boolean;
}

const getAuthorAvatarId = (author: string | undefined) => {
if (!author) return 'default';

return author
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores
};

const MessageAvatar = ({ author, hide }: Props) => {
const apiClient = useContext(ChainlitContext);
const { chatProfile } = useChatSession();
Expand All @@ -28,7 +38,9 @@ const MessageAvatar = ({ author, hide }: Props) => {
if (isAssistant && selectedChatProfile?.icon) {
return selectedChatProfile.icon;
}
return apiClient?.buildEndpoint(`/avatars/${author || 'default'}`);

const authorAvatarId = getAuthorAvatarId(author);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think doing this in the client is a good idea since developers also use chainlit with custom frontends

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can give custom frontend implementors (2-line!) instructions to implement this?

This patch would not only improve security, but being consistent in how we deal with filenames will make the developer experience much more robust. This would replace a . (and other characters, think unicode) with _, allowing any characters in avatar names.

We currently do not: a lot of author names will prevent the avatar from working. This patch solves that, fully, without compromising security again.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proposed compromise:

  1. We keep allowing spaces and upper characters (basically not change the backend).
  2. We throw a deprecation error every time an 'invalid' URL comes in.
  3. We update the frontend code already.

This would let existing users know of what's coming, recover pre-existing functionality in cases of special characters, guarantee sufficient security for now and maximal security long-term.

return apiClient?.buildEndpoint(`/avatars/${authorAvatarId}`);
}, [apiClient, selectedChatProfile, config, author]);

return (
Expand Down
Loading