Skip to content

Commit 31e76e3

Browse files
fix: implement secure BFF proxy for image attachments
1 parent a9b03ab commit 31e76e3

5 files changed

Lines changed: 127 additions & 36 deletions

File tree

packages/auth/src/RocketChatAuth.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,22 @@ class RocketChatAuth {
180180

181181
async save() {
182182
await this.saveToken(this.currentUser.authToken);
183+
try {
184+
if (typeof window !== "undefined") {
185+
const proxyUrl = "/api/proxy-auth";
186+
await fetch(proxyUrl, {
187+
method: "POST",
188+
headers: { "Content-Type": "application/json" },
189+
body: JSON.stringify({
190+
rc_token: this.currentUser.authToken,
191+
rc_uid: this.currentUser.userId,
192+
host: this.host,
193+
}),
194+
}).catch(() => null); // Fail silently if no proxy is configured
195+
}
196+
} catch (e) {
197+
// Ignore proxy errors
198+
}
183199
this.notifyAuthListeners();
184200
}
185201

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// packages/react/.storybook/middleware.js
2+
const express = require('express');
3+
4+
// Simple in-memory session store for development
5+
const sessions = {};
6+
7+
module.exports = function expressMiddleware(app) {
8+
app.use(express.json());
9+
10+
// 1. Session Setup Endpoint
11+
app.post('/api/proxy-auth', (req, res) => {
12+
const { rc_token, rc_uid, host } = req.body;
13+
if (!rc_token || !rc_uid || !host) {
14+
return res.status(400).json({ error: 'Missing parameters' });
15+
}
16+
17+
// Generate a simple session ID
18+
const sessionId = Math.random().toString(36).substring(2, 15);
19+
sessions[sessionId] = { rc_token, rc_uid, host };
20+
21+
// Set HTTP-only cookie
22+
res.cookie('ec_session', sessionId, {
23+
httpOnly: true,
24+
secure: false, // For local Storybook (http://localhost:6006)
25+
sameSite: 'lax',
26+
path: '/',
27+
});
28+
29+
res.json({ success: true });
30+
});
31+
32+
// 2. Image Proxy Endpoint
33+
app.get('/api/proxy-media', async (req, res) => {
34+
const { url } = req.query;
35+
if (!url) {
36+
return res.status(400).send('Missing url parameter');
37+
}
38+
39+
// Parse cookies manually to avoid needing cookie-parser
40+
const cookieHeader = req.headers.cookie || '';
41+
const cookies = cookieHeader.split(';').reduce((acc, cookieStr) => {
42+
const [key, val] = cookieStr.split('=').map((s) => s.trim());
43+
if (key && val) acc[key] = val;
44+
return acc;
45+
}, {});
46+
47+
const sessionId = cookies['ec_session'];
48+
const session = sessionId ? sessions[sessionId] : null;
49+
50+
const headers = new Headers();
51+
if (session) {
52+
headers.append('X-Auth-Token', session.rc_token);
53+
headers.append('X-User-Id', session.rc_uid);
54+
}
55+
56+
try {
57+
// Fetch the file from the remote Rocket.Chat server
58+
const proxyRes = await fetch(url, { headers });
59+
60+
if (!proxyRes.ok) {
61+
return res.status(proxyRes.status).send('RC Server returned an error');
62+
}
63+
64+
// Copy relevant headers (Content-Type, Content-Length)
65+
const contentType = proxyRes.headers.get('content-type');
66+
const contentLength = proxyRes.headers.get('content-length');
67+
if (contentType) res.setHeader('Content-Type', contentType);
68+
if (contentLength) res.setHeader('Content-Length', contentLength);
69+
70+
// Pipe the stream using Node API
71+
// proxyRes.body is a web stream in Node 18+, convert to Node stream if needed or use Response.arrayBuffer
72+
const arrayBuffer = await proxyRes.arrayBuffer();
73+
const buffer = Buffer.from(arrayBuffer);
74+
res.send(buffer);
75+
} catch (e) {
76+
console.error('Proxy Fetch Error:', e);
77+
res.status(500).send('Proxy backend error');
78+
}
79+
});
80+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React, { useState } from 'react';
2+
3+
const AuthenticatedImage = ({ url, alt, ...props }) => {
4+
const [hasError, setHasError] = useState(false);
5+
6+
if (!url) return null;
7+
8+
// The proxy endpoint established in Storybook middleware.
9+
// In a real application, the host would provide their own media proxy URL.
10+
const proxyUrl = `/api/proxy-media?url=${encodeURIComponent(url)}`;
11+
12+
if (hasError) return null;
13+
14+
return (
15+
<img
16+
src={proxyUrl}
17+
alt={alt}
18+
onError={() => setHasError(true)}
19+
{...props}
20+
/>
21+
);
22+
};
23+
24+
export default AuthenticatedImage;

packages/react/src/views/AttachmentHandler/ImageAttachment.js

Lines changed: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Box, Avatar, useTheme } from '@embeddedchat/ui-elements';
55
import AttachmentMetadata from './AttachmentMetadata';
66
import ImageGallery from '../ImageGallery/ImageGallery';
77
import RCContext from '../../context/RCInstance';
8+
import AuthenticatedImage from './AuthenticatedImage';
89

910
const ImageAttachment = ({
1011
attachment,
@@ -16,37 +17,6 @@ const ImageAttachment = ({
1617
}) => {
1718
const { RCInstance } = useContext(RCContext);
1819
const [showGallery, setShowGallery] = useState(false);
19-
const [authParams, setAuthParams] = useState(null);
20-
21-
useEffect(() => {
22-
let cancelled = false;
23-
RCInstance.auth.getCurrentUser().then((user) => {
24-
if (!cancelled) {
25-
setAuthParams(
26-
user?.authToken && user?.userId
27-
? `rc_token=${user.authToken}&rc_uid=${user.userId}`
28-
: ''
29-
);
30-
}
31-
}).catch(() => {
32-
if (!cancelled) setAuthParams('');
33-
});
34-
return () => { cancelled = true; };
35-
}, [RCInstance]);
36-
37-
const withAuth = (url) => {
38-
if (!url) return url;
39-
// Only add auth to URLs served from our own RC host — never leak creds to 3rd parties
40-
try {
41-
const rcHostname = new URL(host).hostname;
42-
if (new URL(url).hostname !== rcHostname) return url;
43-
} catch {
44-
return url; // malformed URL — skip auth
45-
}
46-
if (!authParams) return url;
47-
const sep = url.includes('?') ? '&' : '?';
48-
return `${url}${sep}${authParams}`;
49-
};
5020

5121
const getUserAvatarUrl = (icon) => {
5222
const instanceHost = RCInstance.getHost();
@@ -127,8 +97,8 @@ const ImageAttachment = ({
12797
</Box>
12898
{isExpanded && (
12999
<Box onClick={() => setShowGallery(true)}>
130-
<img
131-
src={withAuth(host + attachment.image_url)}
100+
<AuthenticatedImage
101+
url={host + attachment.image_url}
132102
style={{
133103
maxWidth: '100%',
134104
objectFit: 'contain',
@@ -191,8 +161,8 @@ const ImageAttachment = ({
191161
}
192162
variantStyles={variantStyles}
193163
/>
194-
<img
195-
src={withAuth(host + nestedAttachment.image_url)}
164+
<AuthenticatedImage
165+
url={host + nestedAttachment.image_url}
196166
style={{
197167
maxWidth: '100%',
198168
objectFit: 'contain',

packages/react/src/views/ImageGallery/ImageGallery.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { useRCContext } from '../../context/RCInstance';
1212
import { Swiper, SwiperSlide } from './Swiper';
1313
import getImageGalleryStyles from './ImageGallery.styles';
14+
import AuthenticatedImage from '../AttachmentHandler/AuthenticatedImage';
1415

1516
const ImageGallery = ({ currentFileId, setShowGallery }) => {
1617
const { theme } = useTheme();
@@ -104,7 +105,7 @@ const ImageGallery = ({ currentFileId, setShowGallery }) => {
104105
{files.map(({ _id, url }) => (
105106
<SwiperSlide key={_id}>
106107
<Box css={styles.imageContainer}>
107-
<img src={url} css={styles.image} />
108+
<AuthenticatedImage url={url} css={styles.image} />
108109
</Box>
109110
</SwiperSlide>
110111
))}

0 commit comments

Comments
 (0)