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
14 changes: 14 additions & 0 deletions frontend/nginx.conf
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
map $host $upload_image_origins {
localhost "http://localhost:5000";
127.0.0.1 "http://localhost:5000";
~^app-dev\.(.+)$ "https://api-dev.$1 http://api-dev.$1";
~^app\.(.+)$ "https://api.$1 http://api.$1";
default "https://api.$host http://api.$host";
}

server {
listen 80;

Expand All @@ -11,6 +19,10 @@ server {
root /usr/share/nginx/html;
index index.html;

# The API origin serves signed uploads. NGINX locations with their own
# add_header directives repeat this policy because inheritance stops there.
add_header Content-Security-Policy "img-src 'self' $upload_image_origins data: blob:" always;

# Keep the correct HTTP status while giving people a useful recovery path.
# `internal` prevents /404.html itself from becoming a crawlable public URL.
error_page 404 /404.html;
Expand Down Expand Up @@ -44,6 +56,7 @@ server {
# (Without this, index.html had no Cache-Control at all → heuristic caching.)
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Content-Security-Policy "img-src 'self' $upload_image_origins data: blob:" always;
expires -1;
}

Expand All @@ -54,5 +67,6 @@ server {
expires 6M;
access_log off;
add_header Cache-Control "public, immutable";
add_header Content-Security-Policy "img-src 'self' $upload_image_origins data: blob:" always;
}
}
29 changes: 29 additions & 0 deletions frontend/scripts/nginx-routing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,32 @@ test('unknown public routes are real 404s while documented app routes retain SPA
assert.match(notFoundPage, /<h1>We couldn’t find that page<\/h1>/);
assert.match(notFoundPage, /href="\/guides\/"/);
});

test('HTML and inherited NGINX locations enforce the same image source policy', async () => {
const config = await readFile(resolve(frontendDir, 'nginx.conf'), 'utf8');
assert.match(config, /map \$host \$upload_image_origins \{/);
// Every map value is an API origin for that frontend host. A new map arm
// cannot quietly permit a third-party image host via the variable.
const originMap = config.match(/map \$host \$upload_image_origins \{([^}]+)\}/)?.[1];
assert.ok(originMap);
assert.deepEqual(originMap.trim().split('\n').map((line) => line.trim()), [
'localhost "http://localhost:5000";',
'127.0.0.1 "http://localhost:5000";',
'~^app-dev\\.(.+)$ "https://api-dev.$1 http://api-dev.$1";',
'~^app\\.(.+)$ "https://api.$1 http://api.$1";',
'default "https://api.$host http://api.$host";',
]);
const policy = /add_header Content-Security-Policy "img-src 'self' \$upload_image_origins data: blob:" always;/g;
// Server scope, index.html (which adds Cache-Control), and asset location
// (which also adds Cache-Control) each need their own copy.
assert.equal([...config.matchAll(policy)].length, 3);
assert.doesNotMatch(config, /img-src[^"\n]*\*/);
const imagePolicies = [...config.matchAll(/Content-Security-Policy "img-src ([^"\n]+)"/g)];
assert.equal(imagePolicies.length, 3);
for (const [, sources] of imagePolicies) {
assert.deepEqual(sources.split(/\s+/), ["'self'", '$upload_image_origins', 'data:', 'blob:']);
}
const chatRoom = await readFile(resolve(frontendDir, 'src/components/ChatRoom.tsx'), 'utf8');
assert.doesNotMatch(chatRoom, /logo:\s*['"]https?:\/\//);
assert.doesNotMatch(chatRoom, /src\s*=\s*['"]https?:\/\//);
});
7 changes: 7 additions & 0 deletions frontend/src/components/ChatRoom.css
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,13 @@
object-fit: contain;
}

.external-link-mark {
color: white;
font-size: 12px;
font-weight: 700;
line-height: 1;
}

/* Update search field in sidebar */
.sidebar-section .MuiInputBase-root {
border-radius: 4px !important;
Expand Down
65 changes: 13 additions & 52 deletions frontend/src/components/ChatRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ const BASE_INTEGRATION_OPTIONS = [
color: '#5865F2',
hoverColor: '#4752C4',
description: 'Connect Discord to sync messages with your server.',
logo: 'https://cdn.simpleicons.org/discord/5865F2',
mark: 'D',
capabilities: ['gateway', 'summary', 'commands'],
},
{
Expand All @@ -190,7 +190,7 @@ const BASE_INTEGRATION_OPTIONS = [
color: '#4A154B',
hoverColor: '#3B1140',
description: 'Install the Commonly app and choose channels.',
logo: 'https://a.slack-edge.com/80588/marketing/img/icons/icon_slack_hash_colored.png',
mark: 'S',
capabilities: ['webhook', 'summary', 'commands'],
},
{
Expand All @@ -199,7 +199,7 @@ const BASE_INTEGRATION_OPTIONS = [
color: '#00A2FF',
hoverColor: '#0089D9',
description: 'Connect your GroupMe bot to start syncing.',
logo: 'https://cdn.simpleicons.org/groupme/00A2FF',
mark: 'G',
capabilities: ['webhook', 'commands', 'summary'],
},
{
Expand All @@ -208,7 +208,7 @@ const BASE_INTEGRATION_OPTIONS = [
color: '#229ED9',
hoverColor: '#1B86BC',
description: 'Add the bot to a chat and run /commonly-enable.',
logo: 'https://telegram.org/img/t_logo.png',
mark: 'T',
capabilities: ['webhook', 'summary', 'commands'],
},
{
Expand All @@ -217,7 +217,7 @@ const BASE_INTEGRATION_OPTIONS = [
color: '#111827',
hoverColor: '#0B0F1A',
description: 'Pull posts from X into this pod.',
logo: 'https://cdn.simpleicons.org/x/111827',
mark: 'X',
capabilities: ['polling', 'posts', 'summary'],
},
{
Expand All @@ -226,7 +226,7 @@ const BASE_INTEGRATION_OPTIONS = [
color: '#E4405F',
hoverColor: '#C13584',
description: 'Pull Instagram posts into this pod.',
logo: 'https://cdn.simpleicons.org/instagram/E4405F',
mark: 'I',
capabilities: ['polling', 'posts', 'summary'],
},
];
Expand Down Expand Up @@ -3340,64 +3340,28 @@ const ChatRoom = () => {
{/* Discord icon */}
{(link.type === 'discord' || link.icon === 'discord') && (
<div className="external-link-icon">
<img
src="https://discord.com/assets/f9bb9c4af2b9c32a2c5ee0014661546d.png"
width="16"
height="16"
alt="Discord"
onError={(e) => {
(e.target as HTMLImageElement).onerror = null;
((e.target as HTMLImageElement).parentNode as HTMLElement).innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#5865F2"><path d="M19.952,5.672c-1.904-1.531-4.916-1.79-5.044-1.801c-0.201-0.017-0.392,0.097-0.474,0.281 c0.006,0.012-0.072,0.163-0.145,0.398c1.259,0.212,2.806,0.64,4.206,1.509c0.224,0.139,0.293,0.434,0.154,0.659 c-0.09,0.146-0.247,0.226-0.407,0.226c-0.086,0-0.173-0.023-0.252-0.072C15.584,5.38,12.578,5.305,12,5.305S8.415,5.38,6.011,6.872 c-0.225,0.14-0.519,0.07-0.659-0.154c-0.14-0.225-0.07-0.519,0.154-0.659c1.4-0.868,2.947-1.297,4.206-1.509 c-0.074-0.236-0.14-0.386-0.145-0.398C9.484,3.968,9.294,3.852,9.092,3.872c-0.127,0.01-3.139,0.269-5.069,1.822 C3.015,6.625,1,12.073,1,16.783c0,0.083,0.022,0.165,0.063,0.237c1.391,2.443,5.185,3.083,6.05,3.111c0.005,0,0.01,0,0.015,0 c0.153,0,0.297-0.073,0.387-0.197l0.875-1.202c-2.359-0.61-3.564-1.645-3.634-1.706c-0.198-0.175-0.217-0.477-0.042-0.675 c0.175-0.198,0.476-0.217,0.674-0.043c0.029,0.026,2.248,1.909,6.612,1.909c4.372,0,6.591-1.891,6.613-1.91 c0.198-0.172,0.5-0.154,0.674,0.045c0.174,0.198,0.155,0.499-0.042,0.673c-0.07,0.062-1.275,1.096-3.634,1.706l0.875,1.202 c0.09,0.124,0.234,0.197,0.387,0.197c0.005,0,0.01,0,0.015,0c0.865-0.027,4.659-0.667,6.05-3.111 C22.978,16.947,23,16.866,23,16.783C23,12.073,20.985,6.625,19.952,5.672z M8.891,14.87c-0.924,0-1.674-0.857-1.674-1.913 s0.749-1.913,1.674-1.913s1.674,0.857,1.674,1.913S9.816,14.87,8.891,14.87z M15.109,14.87c-0.924,0-1.674-0.857-1.674-1.913 s0.749-1.913,1.674-1.913c0.924,0,1.674,0.857,1.674,1.913S16.033,14.87,15.109,14.87z"></path></svg>';
}}
/>
<span className="external-link-mark" aria-hidden="true">D</span>
</div>
)}

{/* Telegram icon */}
{(link.type === 'telegram' || link.icon === 'telegram') && (
<div className="external-link-icon" style={{ backgroundColor: '#31a8df' }}>
<img
src="https://telegram.org/img/t_logo.png"
width="16"
height="16"
alt="Telegram"
onError={(e) => {
(e.target as HTMLImageElement).onerror = null;
((e.target as HTMLImageElement).parentNode as HTMLElement).innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#FFFFFF"><path d="M19.2,4.4L2.9,10.7c-1.1,0.4-1.1,1.1-0.2,1.3l4.1,1.3l1.6,4.8c0.2,0.5,0.1,0.7,0.6,0.7c0.4,0,0.6-0.2,0.8-0.4 c0.1-0.1,1-1,2-2l4.2,3.1c0.8,0.4,1.3,0.2,1.5-0.7l2.8-13.1C20.6,4.6,19.9,4,19.2,4.4z M17.1,7.4l-7.8,7.1L9,17.8L7.4,13 l9.2-5.8C17,6.9,17.4,7.1,17.1,7.4z"></path></svg>';
}}
/>
<span className="external-link-mark" aria-hidden="true">T</span>
</div>
)}

{/* WeChat icon */}
{(link.type === 'wechat' || link.icon === 'wechat') && (
<div className="external-link-icon" style={{ backgroundColor: '#2DC100' }}>
<img
src="https://img.icons8.com/color/48/wechat.png"
width="16"
height="16"
alt="WeChat"
onError={(e) => {
(e.target as HTMLImageElement).onerror = null;
((e.target as HTMLImageElement).parentNode as HTMLElement).innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#FFFFFF"><path d="M8.691,2.188C3.891,2.188,0,5.476,0,9.53c0,2.256,1.305,4.296,3.357,5.605c-0.254,0.94-0.636,2.348-0.636,2.348 c-0.034,0.127,0.010,0.267,0.110,0.369c0.073,0.070,0.171,0.108,0.269,0.108c0.049,0,0.098-0.010,0.144-0.029 c0,0,2.022-1.166,2.37-1.366c0.988,0.256,2.015,0.394,3.076,0.394c0.168,0,0.336-0.007,0.504-0.014 c-0.272-0.804-0.418-1.66-0.418-2.546c0-4.439,4.253-8.030,9.504-8.030c0.303,0,0.602,0.015,0.896,0.041 C18.248,3.159,13.93,2.188,8.691,2.188z M5.726,7.537c-0.818,0-1.48-0.662-1.48-1.48s0.662-1.48,1.48-1.48s1.48,0.662,1.48,1.48 S6.544,7.537,5.726,7.537z M11.655,7.537c-0.818,0-1.48-0.662-1.48-1.48-1.48s0.662-1.48,1.48-1.48s1.48,0.662,1.48,1.48 S12.473,7.537,11.655,7.537z M14.727,13.845c0.168,0,0.336,0.007,0.504,0.014c0.605-0.477,1.347-0.843,2.166-1.078 c-0.254-0.095-0.529-0.167-0.818-0.201c-0.013,0-0.026-0.002-0.039-0.002c-0.371-0.043-0.748-0.064-1.124-0.064 c-4.383,0-7.941,3.053-7.941,6.819c0,0.832,0.198,1.609,0.547,2.311c-1.137-1.332-1.82-2.947-1.82-4.705 C6.202,12.503,10.027,13.845,14.727,13.845z M14.447,17.617c-0.649,0-1.172-0.524-1.172-1.172c0-0.649,0.524-1.172,1.172-1.172 c0.649,0,1.172,0.524,1.172,1.172C15.62,17.093,15.096,17.617,14.447,17.617z M19.337,17.617c-0.649,0-1.172-0.524-1.172-1.172 c0-0.649,0.524-1.172,1.172-1.172c0.649,0,1.172,0.524,1.172,1.172C20.51,17.093,19.986,17.617,19.337,17.617z M22.516,16.192 c0-0.006,0-0.012,0-0.017c-0.001-0.004-0.002-0.008-0.002-0.012c-0.254-3.137-3.351-5.605-7.163-5.605 c-3.95,0-7.156,2.651-7.156,5.922s3.206,5.922,7.156,5.922c0.879,0,1.724-0.129,2.524-0.369c0.289,0.166,1.353,0.781,1.705,0.977 c0.045,0.025,0.094,0.037,0.144,0.037c0.105,0,0.205-0.042,0.278-0.115c0.096-0.091,0.139-0.223,0.116-0.352 c0,0-0.34-1.246-0.522-1.914C21.562,19.612,22.516,17.986,22.516,16.192z"></path></svg>';
}}
/>
<span className="external-link-mark" aria-hidden="true">W</span>
</div>
)}

{/* GroupMe icon */}
{(link.type === 'groupme' || link.icon === 'groupme') && (
<div className="external-link-icon" style={{ backgroundColor: '#00AFF0' }}>
<img
src="https://web.groupme.com/favicon-32x32.png"
width="16"
height="16"
alt="GroupMe"
onError={(e) => {
(e.target as HTMLImageElement).onerror = null;
((e.target as HTMLImageElement).parentNode as HTMLElement).innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="#FFFFFF"><path d="M12,2C6.477,2,2,6.477,2,12c0,5.523,4.477,10,10,10s10-4.477,10-10C22,6.477,17.523,2,12,2z M12,5.5 c1.381,0,2.5,1.119,2.5,2.5c0,1.381-1.119,2.5-2.5,2.5c-1.381,0-2.5-1.119-2.5-2.5C9.5,6.619,10.619,5.5,12,5.5z M12,19.2 c-2.733,0-5.153-1.392-6.574-3.5C5.699,13.189,10,12.1,12,12.1c2,0,6.301,1.089,6.574,3.6C17.153,17.808,14.733,19.2,12,19.2z"></path></svg>';
}}
/>
<span className="external-link-mark" aria-hidden="true">G</span>
</div>
)}

Expand Down Expand Up @@ -3550,12 +3514,9 @@ const ChatRoom = () => {
borderRadius: 2
}}
>
<Box
component="img"
src={item.logo}
alt={`${item.label} logo`}
sx={{ width: 18, height: 18 }}
/>
<Box component="span" aria-hidden="true" sx={{ fontWeight: 700, fontSize: 17 }}>
{item.mark}
</Box>
</Box>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/components/common/MarkdownContent.images.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// @ts-nocheck
import React from 'react';
import { render, screen } from '@testing-library/react';
import MarkdownContent from './MarkdownContent';

jest.mock('react-markdown', () => {
const ReactForMock = require('react');
return {
__esModule: true,
default: ({ children, components }) => {
const image = /^!\[([^\]]*)\]\(([^)]+)\)$/.exec(String(children));
return image
? ReactForMock.createElement(components.img, { alt: image[1], src: image[2] })
: ReactForMock.createElement('div', null, children);
},
};
});

describe('legacy MarkdownContent image sources', () => {
const previousApiUrl = process.env.REACT_APP_API_URL;
beforeEach(() => { process.env.REACT_APP_API_URL = 'https://api.commonly.me'; });
afterEach(() => { process.env.REACT_APP_API_URL = previousApiUrl; });

test('foreign markdown image becomes a link with no image request', () => {
render(<MarkdownContent>{'![chart](https://outside.example/pixel.png?d=secret)'}</MarkdownContent>);
expect(screen.queryByRole('img')).toBeNull();
expect(screen.getByRole('link', { name: 'chart' })).toHaveAttribute('href', 'https://outside.example/pixel.png?d=secret');
});

test('same-instance uploads still render', () => {
render(<MarkdownContent>{'![chart](https://api.commonly.me/api/uploads/chart.png)'}</MarkdownContent>);
expect(screen.getByRole('img', { name: 'chart' })).toHaveAttribute('src', 'https://api.commonly.me/api/uploads/chart.png');
});
});
9 changes: 9 additions & 0 deletions frontend/src/components/common/MarkdownContent.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import { Box, Typography } from '@mui/material';
import { messageImageUrl } from '../../utils/messageImageUrl';

const HASHTAG_RE = /(#\w+)/g;

Expand Down Expand Up @@ -61,6 +62,14 @@ export default function MarkdownContent({
// Using Record<string, unknown> for component props since react-markdown v10
// types vary by component key and we only need runtime behaviour here.
const components: Record<string, React.ComponentType<Record<string, unknown>>> = {
img({ src, alt }) {
const source = String(src || '');
const trustedSrc = messageImageUrl(source);
if (trustedSrc) return <Box component="img" src={trustedSrc} alt={String(alt || '')} sx={{ maxWidth: '100%' }} />;
return /^https?:\/\//i.test(source)
? <Box component="a" href={source} target="_blank" rel="noopener noreferrer">{String(alt || source)}</Box>
: null;
},
p({ children: pChildren }) {
const content = onHashtagClick
? renderWithHashtags(childrenToText(pChildren as React.ReactNode), onHashtagClick)
Expand Down
Loading
Loading