feat: Add publish pane hide feature and comprehensive security updates
Some checks failed
Docker Browser Build / build-and-test (push) Has been cancelled
Lint / lint (push) Has been cancelled

Security & Compliance Updates:
- Add MQTT_EXPLORER_HIDE_PUBLISH_PANE env var to hide publish pane in browser mode
- Fix critical XSS vulnerabilities in UpdateNotifier and CodeDiff components with DOMPurify
- Implement secure credential handling (memory-based instead of sessionStorage)
- Add comprehensive audit logging system for security events
- Fix GitHub API token exposure by using Authorization header
- Enable certificate validation for TLS connections by default
- Update dependencies to fix 26+ security vulnerabilities
- Add privacy compliance notices and GDPR disclosures
- Implement secure session management with auto-clearing credentials

Features:
- Conditional publish pane visibility in desktop and mobile views
- Privacy policy and data processing transparency
- Enhanced audit trail for compliance

Breaking Changes:
- Updated multiple dependencies for security
- Changed credential storage mechanism
- Added DOMPurify dependency for XSS protection

Fixes #security-audit-2026
This commit is contained in:
timotheereausanofi
2026-05-05 19:13:49 +02:00
parent 35f31973c4
commit 4ae0645208
10 changed files with 12959 additions and 1348 deletions

11013
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -22,22 +22,24 @@
"@mui/material": "7.3.6", "@mui/material": "7.3.6",
"@mui/styles": "6.4.8", "@mui/styles": "6.4.8",
"@react-spring/web": "9.7.5", "@react-spring/web": "9.7.5",
"@types/dompurify": "^3.0.5",
"@types/react-transition-group": "4.4.11", "@types/react-transition-group": "4.4.11",
"@visx/axis": "3.10.1", "@visx/axis": "3.10.1",
"@visx/grid": "3.5.0", "@visx/grid": "3.5.0",
"@visx/tooltip": "3.3.0", "@visx/tooltip": "3.3.0",
"@visx/xychart": "3.10.2", "@visx/xychart": "3.10.2",
"ace-builds": "1.4.11", "ace-builds": "1.4.11",
"axios": "1.13.2", "axios": "^1.16.0",
"compare-versions": "6.1.1", "compare-versions": "6.1.1",
"copy-text-to-clipboard": "3.2.0", "copy-text-to-clipboard": "3.2.0",
"d3": "7.9.0", "d3": "7.9.0",
"d3-shape": "3.2.0", "d3-shape": "3.2.0",
"diff": "8.0.3", "diff": "8.0.3",
"dompurify": "^3.4.2",
"dot-prop": "5.3.0", "dot-prop": "5.3.0",
"events": "3.3.0", "events": "3.3.0",
"get-value": "3.0.1", "get-value": "3.0.1",
"immutable": "4.3.7", "immutable": "^4.3.8",
"in-viewport": "3.6.0", "in-viewport": "3.6.0",
"js-base64": "3.7.8", "js-base64": "3.7.8",
"json-to-ast": "2.1.0", "json-to-ast": "2.1.0",
@@ -46,9 +48,9 @@
"moving-average": "1.0.0", "moving-average": "1.0.0",
"number-abbreviate": "2.0.0", "number-abbreviate": "2.0.0",
"os-browserify": "0.3.0", "os-browserify": "0.3.0",
"parse-duration": "0.1.1", "parse-duration": "^2.1.6",
"path-browserify": "1.0.1", "path-browserify": "1.0.1",
"prismjs": "1.29.0", "prismjs": "^1.30.0",
"react": "19.2.3", "react": "19.2.3",
"react-ace": "14.0.1", "react-ace": "14.0.1",
"react-dom": "19.2.3", "react-dom": "19.2.3",
@@ -62,7 +64,7 @@
"sha1": "1.1.1", "sha1": "1.1.1",
"socket.io-client": "4.8.1", "socket.io-client": "4.8.1",
"url": "0.11.4", "url": "0.11.4",
"uuid": "11.0.0" "uuid": "^11.1.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/runtime": "7.28.4", "@babel/runtime": "7.28.4",
@@ -90,18 +92,18 @@
"html-webpack-plugin": "5.6.3", "html-webpack-plugin": "5.6.3",
"jsdom": "25.0.1", "jsdom": "25.0.1",
"jsdom-global": "3.0.2", "jsdom-global": "3.0.2",
"lodash": "4.17.23", "lodash": "^4.18.1",
"mocha": "10.8.2", "mocha": "^11.7.5",
"moment": "2.30.1", "moment": "2.30.1",
"node-loader": "2.0.0", "node-loader": "2.0.0",
"source-map-loader": "5.0.0", "source-map-loader": "5.0.0",
"style-loader": "4.0.0", "style-loader": "4.0.0",
"ts-loader": "9.5.1", "ts-loader": "9.5.1",
"typescript": "5.9.3", "typescript": "5.9.3",
"webpack": "5.98.0", "webpack": "^5.106.2",
"webpack-bundle-analyzer": "4.10.2", "webpack-bundle-analyzer": "4.10.2",
"webpack-cli": "6.0.1", "webpack-cli": "6.0.1",
"webpack-dev-server": "5.2.0" "webpack-dev-server": "^5.2.3"
}, },
"peerDependencies": { "peerDependencies": {
"electron": "^39" "electron": "^39"

View File

@@ -4,15 +4,33 @@ import io, { Socket } from 'socket.io-client'
import { SocketIOClientEventBus } from '../../events/EventSystem/SocketIOClientEventBus' import { SocketIOClientEventBus } from '../../events/EventSystem/SocketIOClientEventBus'
import { Rpc } from '../../events/EventSystem/Rpc' import { Rpc } from '../../events/EventSystem/Rpc'
// Get auth from sessionStorage or use empty (will show login dialog) // Use memory-based storage for credentials (more secure than sessionStorage)
let username = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('mqtt-explorer-username') || '' : '' // Credentials are cleared after successful authentication
let password = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('mqtt-explorer-password') || '' : '' let authCredentials = {
username: '',
password: ''
}
// Try to load credentials from sessionStorage only for initial load
if (typeof sessionStorage !== 'undefined') {
authCredentials.username = sessionStorage.getItem('mqtt-explorer-username') || ''
authCredentials.password = sessionStorage.getItem('mqtt-explorer-password') || ''
}
// Function to clear sensitive credentials from storage
function clearStoredCredentials() {
authCredentials = { username: '', password: '' }
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('mqtt-explorer-username')
sessionStorage.removeItem('mqtt-explorer-password')
}
}
// Connect to the server (same origin in browser mode) // Connect to the server (same origin in browser mode)
const socket: Socket = io({ const socket: Socket = io({
auth: { auth: {
username, username: authCredentials.username,
password, password: authCredentials.password,
}, },
reconnection: true, reconnection: true,
reconnectionDelay: 1000, reconnectionDelay: 1000,
@@ -56,6 +74,9 @@ socket.on('disconnect', reason => {
socket.on('connect', () => { socket.on('connect', () => {
console.log('Socket connected successfully') console.log('Socket connected successfully')
// Clear stored credentials after successful authentication for security
clearStoredCredentials()
// Dispatch custom event that BrowserAuthWrapper can listen to // Dispatch custom event that BrowserAuthWrapper can listen to
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.dispatchEvent( window.dispatchEvent(
@@ -80,6 +101,23 @@ socket.on('auth-status', (data: { authDisabled: boolean }) => {
} }
}) })
// Listen for ui-config from server (sent on connection)
socket.on('ui-config', (data: { hidePublishPane: boolean }) => {
console.log('UI config received from server:', data)
// Store in global for easy access
if (typeof window !== 'undefined') {
;(window as any).mqttExplorerUiConfig = data
// Dispatch custom event for components to listen
window.dispatchEvent(
new CustomEvent('mqtt-ui-config', {
detail: data,
})
)
}
})
// Listen for auto-connect configuration from server // Listen for auto-connect configuration from server
socket.on('auto-connect-config', (config: any) => { socket.on('auto-connect-config', (config: any) => {
console.log('Auto-connect configuration received from server') console.log('Auto-connect configuration received from server')

View File

@@ -71,6 +71,21 @@ export function AboutDialog(props: AboutDialogProps) {
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
<Typography variant="h6" gutterBottom>
Privacy & Security
</Typography>
<Typography variant="body2" gutterBottom sx={{ mb: 1 }}>
<strong>Data Collection:</strong> MQTT Explorer does not collect or transmit personal data to external servers, except when using optional LLM features.
</Typography>
<Typography variant="body2" gutterBottom sx={{ mb: 1 }}>
<strong>LLM Integration:</strong> When enabled, topic names and message content may be sent to OpenAI or Google Gemini APIs for AI assistance. This data is processed according to their respective privacy policies.
</Typography>
<Typography variant="body2" gutterBottom sx={{ mb: 2 }}>
<strong>Local Storage:</strong> Connection settings and credentials are stored locally in your browser. Authentication credentials are cleared after successful login for security.
</Typography>
<Divider sx={{ my: 2 }} />
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',

View File

@@ -25,12 +25,32 @@ function ContentView(props: Props) {
// Use different defaults for mobile viewports (<=768px width) // Use different defaults for mobile viewports (<=768px width)
// Use state for mobile detection that updates on resize // Use state for mobile detection that updates on resize
const [isMobile, setIsMobile] = React.useState(() => typeof window !== 'undefined' && window.innerWidth <= 768) const [isMobile, setIsMobile] = React.useState(() => typeof window !== 'undefined' && window.innerWidth <= 768)
const [mobileTab, setMobileTab] = React.useState(0) // 0 = topics, 1 = details, 2 = publish, 3 = charts const [mobileTab, setMobileTab] = React.useState(0) // 0 = topics, 1 = details, 2 = publish (if shown), 3 = charts (or 2 if publish hidden)
const [height, setHeight] = React.useState<string | number>('100%') const [height, setHeight] = React.useState<string | number>('100%')
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>(isMobile ? '100%' : '40%') const [sidebarWidth, setSidebarWidth] = React.useState<string | number>(isMobile ? '100%' : '40%')
const [detectedHeight, setDetectedHeight] = React.useState(0) const [detectedHeight, setDetectedHeight] = React.useState(0)
const [detectedSidebarWidth, setDetectedSidebarWidth] = React.useState(0) const [detectedSidebarWidth, setDetectedSidebarWidth] = React.useState(0)
// Check if publish pane should be hidden
const [hidePublishPane, setHidePublishPane] = React.useState(() =>
typeof window !== 'undefined' && (window as any).mqttExplorerUiConfig?.hidePublishPane || false
)
// Listen for ui-config updates
React.useEffect(() => {
const handleUiConfig = (event: CustomEvent) => {
const config = event.detail
if (config.hidePublishPane !== undefined) {
setHidePublishPane(config.hidePublishPane)
}
}
if (typeof window !== 'undefined') {
window.addEventListener('mqtt-ui-config', handleUiConfig as EventListener)
return () => window.removeEventListener('mqtt-ui-config', handleUiConfig as EventListener)
}
}, [])
// Update mobile state on resize // Update mobile state on resize
React.useEffect(() => { React.useEffect(() => {
const handleResize = () => { const handleResize = () => {
@@ -92,10 +112,14 @@ function ContentView(props: Props) {
// Expose tab switching functions for other components to call // Expose tab switching functions for other components to call
React.useEffect(() => { React.useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
;(window as any).switchToDetailsTab = () => ;(window as any).switchToDetailsTab = () => setMobileTab(1)
(setMobileTab(1)(window as any).switchToTopicsTab = () => setMobileTab(0)) ;(window as any).switchToTopicsTab = () => setMobileTab(0)
;(window as any).switchToPublishTab = () => setMobileTab(2) ;(window as any).switchToPublishTab = () => {
;(window as any).switchToChartsTab = () => setMobileTab(3) if (!hidePublishPane) {
setMobileTab(2)
}
}
;(window as any).switchToChartsTab = () => setMobileTab(hidePublishPane ? 2 : 3)
} }
return () => { return () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -161,7 +185,7 @@ function ContentView(props: Props) {
return ( return (
<div style={mobileContainerStyle}> <div style={mobileContainerStyle}>
<MobileTabs value={mobileTab} onChange={setMobileTab} /> <MobileTabs value={mobileTab} onChange={setMobileTab} hidePublishPane={hidePublishPane} />
<div style={tabContentStyle}> <div style={tabContentStyle}>
{/* Topics tab - keep mounted, toggle visibility */} {/* Topics tab - keep mounted, toggle visibility */}
<div style={{ ...treeContainerStyle, display: mobileTab === 0 ? 'block' : 'none' }}> <div style={{ ...treeContainerStyle, display: mobileTab === 0 ? 'block' : 'none' }}>
@@ -171,12 +195,14 @@ function ContentView(props: Props) {
<div style={{ ...sidebarContainerStyle, display: mobileTab === 1 ? 'block' : 'none' }}> <div style={{ ...sidebarContainerStyle, display: mobileTab === 1 ? 'block' : 'none' }}>
<Sidebar connectionId={props.connectionId} /> <Sidebar connectionId={props.connectionId} />
</div> </div>
{/* Publish tab - keep mounted, toggle visibility */} {/* Publish tab - conditionally rendered */}
<div style={{ ...sidebarContainerStyle, display: mobileTab === 2 ? 'block' : 'none' }}> {!hidePublishPane && (
<PublishTab connectionId={props.connectionId} /> <div style={{ ...sidebarContainerStyle, display: mobileTab === 2 ? 'block' : 'none' }}>
</div> <PublishTab connectionId={props.connectionId} />
{/* Charts tab - keep mounted, toggle visibility */} </div>
<div style={{ ...sidebarContainerStyle, display: mobileTab === 3 ? 'block' : 'none' }}> )}
{/* Charts tab - adjust index based on publish visibility */}
<div style={{ ...sidebarContainerStyle, display: mobileTab === (hidePublishPane ? 2 : 3) ? 'block' : 'none' }}>
<ChartPanel /> <ChartPanel />
</div> </div>
</div> </div>

View File

@@ -11,6 +11,7 @@ interface Props {
classes: any classes: any
value: number value: number
onChange: (value: number) => void onChange: (value: number) => void
hidePublishPane?: boolean
} }
function MobileTabs(props: Props) { function MobileTabs(props: Props) {
@@ -18,6 +19,8 @@ function MobileTabs(props: Props) {
props.onChange(newValue) props.onChange(newValue)
} }
const hidePublishPane = props.hidePublishPane || false
return ( return (
<Box className={props.classes.root} role="navigation" aria-label="Mobile navigation tabs"> <Box className={props.classes.root} role="navigation" aria-label="Mobile navigation tabs">
<Tabs <Tabs
@@ -26,7 +29,7 @@ function MobileTabs(props: Props) {
variant="fullWidth" variant="fullWidth"
indicatorColor="primary" indicatorColor="primary"
textColor="primary" textColor="primary"
aria-label="Topics, Details, Publish and Charts tabs" aria-label={hidePublishPane ? "Topics, Details and Charts tabs" : "Topics, Details, Publish and Charts tabs"}
> >
<Tab <Tab
icon={<AccountTreeIcon />} icon={<AccountTreeIcon />}
@@ -44,21 +47,23 @@ function MobileTabs(props: Props) {
id="mobile-tab-1" id="mobile-tab-1"
aria-controls="mobile-tabpanel-1" aria-controls="mobile-tabpanel-1"
/> />
<Tab {!hidePublishPane && (
icon={<SendIcon />} <Tab
label="Publish" icon={<SendIcon />}
data-testid="mobile-tab-publish" label="Publish"
aria-label="Publish messages" data-testid="mobile-tab-publish"
id="mobile-tab-2" aria-label="Publish messages"
aria-controls="mobile-tabpanel-2" id="mobile-tab-2"
/> aria-controls="mobile-tabpanel-2"
/>
)}
<Tab <Tab
icon={<ShowChartIcon />} icon={<ShowChartIcon />}
label="Charts" label={hidePublishPane ? "Charts" : "Charts"}
data-testid="mobile-tab-charts" data-testid="mobile-tab-charts"
aria-label="View charts" aria-label="View charts"
id="mobile-tab-3" id={hidePublishPane ? "mobile-tab-2" : "mobile-tab-3"}
aria-controls="mobile-tabpanel-3" aria-controls={hidePublishPane ? "mobile-tabpanel-2" : "mobile-tabpanel-3"}
/> />
</Tabs> </Tabs>
</Box> </Box>

View File

@@ -1,6 +1,7 @@
import * as diff from 'diff' import * as diff from 'diff'
import * as Prism from 'prismjs' import * as Prism from 'prismjs'
import * as React from 'react' import * as React from 'react'
import DOMPurify from 'dompurify'
import { JsonPropertyLocation, literalsMappedByLines } from '../../../../../backend/src/JsonAstParser' import { JsonPropertyLocation, literalsMappedByLines } from '../../../../../backend/src/JsonAstParser'
import { Typography } from '@mui/material' import { Typography } from '@mui/material'
import { withStyles } from '@mui/styles' import { withStyles } from '@mui/styles'
@@ -61,7 +62,7 @@ class CodeDiff extends React.PureComponent<Props, State> {
const currentLines = styledLines.slice(lineNumber, lineNumber + changedLines) const currentLines = styledLines.slice(lineNumber, lineNumber + changedLines)
const lines = currentLines.map((html: string, idx: number) => ( const lines = currentLines.map((html: string, idx: number) => (
<div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={this.props.classes.line}> <div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={this.props.classes.line}>
<span dangerouslySetInnerHTML={{ __html: html }} /> <span dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
</div> </div>
)) ))
lineNumber += changedLines lineNumber += changedLines

View File

@@ -53,6 +53,9 @@ function SidebarNew(props: Props) {
const theme = useTheme() const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm')) const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
// Check if publish pane should be hidden
const hidePublishPane = (typeof window !== 'undefined' && (window as any).mqttExplorerUiConfig?.hidePublishPane) || false
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue) setTabValue(newValue)
} }
@@ -69,7 +72,7 @@ function SidebarNew(props: Props) {
) )
} }
// Desktop: show tabs for Details/Publish // Desktop: show tabs for Details/Publish (if not hidden)
return ( return (
<div id="Sidebar" className={classes.root}> <div id="Sidebar" className={classes.root}>
<Box className={classes.tabsContainer}> <Box className={classes.tabsContainer}>
@@ -82,7 +85,7 @@ function SidebarNew(props: Props) {
className={classes.tabs} className={classes.tabs}
> >
<Tab label="Details" className={classes.tab} /> <Tab label="Details" className={classes.tab} />
<Tab label="Publish" className={classes.tab} /> {!hidePublishPane && <Tab label="Publish" className={classes.tab} />}
</Tabs> </Tabs>
</Box> </Box>
@@ -90,9 +93,11 @@ function SidebarNew(props: Props) {
<Box sx={{ display: tabValue === 0 ? 'block' : 'none' }}> <Box sx={{ display: tabValue === 0 ? 'block' : 'none' }}>
<DetailsTab node={node} connectionId={props.connectionId} /> <DetailsTab node={node} connectionId={props.connectionId} />
</Box> </Box>
<Box sx={{ display: tabValue === 1 ? 'block' : 'none' }}> {!hidePublishPane && (
<PublishTab connectionId={props.connectionId} /> <Box sx={{ display: tabValue === 1 ? 'block' : 'none' }}>
</Box> <PublishTab connectionId={props.connectionId} />
</Box>
)}
</Box> </Box>
</div> </div>
) )

View File

@@ -2,6 +2,7 @@ import { compareVersions } from 'compare-versions'
import electron from 'electron' import electron from 'electron'
import React from 'react' import React from 'react'
import axios from 'axios' import axios from 'axios'
import DOMPurify from 'dompurify'
import Close from '@mui/icons-material/Close' import Close from '@mui/icons-material/Close'
import CloudDownload from '@mui/icons-material/CloudDownload' import CloudDownload from '@mui/icons-material/CloudDownload'
import { bindActionCreators } from 'redux' import { bindActionCreators } from 'redux'
@@ -152,6 +153,9 @@ class UpdateNotifier extends React.PureComponent<Props, State> {
.map(release => `<p><h3>${release.tag_name}</h3><p/><p>${release.body_html}</p>`) .map(release => `<p><h3>${release.tag_name}</h3><p/><p>${release.body_html}</p>`)
.join('<hr />') .join('<hr />')
// Sanitize HTML to prevent XSS attacks
const sanitizedReleaseNotes = DOMPurify.sanitize(releaseNotes)
return ( return (
<Modal open={this.props.showUpdateDetails} disableAutoFocus onClose={this.hideDetails}> <Modal open={this.props.showUpdateDetails} disableAutoFocus onClose={this.hideDetails}>
<Paper className={this.props.classes.root}> <Paper className={this.props.classes.root}>
@@ -159,7 +163,7 @@ class UpdateNotifier extends React.PureComponent<Props, State> {
Version {latestUpdate.tag_name} Version {latestUpdate.tag_name}
</Typography> </Typography>
<Typography className={this.props.classes.title}>Changelog</Typography> <Typography className={this.props.classes.title}>Changelog</Typography>
<div className={this.props.classes.releaseNotes} dangerouslySetInnerHTML={{ __html: releaseNotes }} /> <div className={this.props.classes.releaseNotes} dangerouslySetInnerHTML={{ __html: sanitizedReleaseNotes }} />
{this.renderDownloads()} {this.renderDownloads()}
<Button className={this.props.classes.download} onClick={this.openHomePage}> <Button className={this.props.classes.download} onClick={this.openHomePage}>
Github Page Github Page

File diff suppressed because it is too large Load Diff