feat: Add publish pane hide feature and comprehensive security updates
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:
11013
app/package-lock.json
generated
Normal file
11013
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,22 +22,24 @@
|
||||
"@mui/material": "7.3.6",
|
||||
"@mui/styles": "6.4.8",
|
||||
"@react-spring/web": "9.7.5",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/react-transition-group": "4.4.11",
|
||||
"@visx/axis": "3.10.1",
|
||||
"@visx/grid": "3.5.0",
|
||||
"@visx/tooltip": "3.3.0",
|
||||
"@visx/xychart": "3.10.2",
|
||||
"ace-builds": "1.4.11",
|
||||
"axios": "1.13.2",
|
||||
"axios": "^1.16.0",
|
||||
"compare-versions": "6.1.1",
|
||||
"copy-text-to-clipboard": "3.2.0",
|
||||
"d3": "7.9.0",
|
||||
"d3-shape": "3.2.0",
|
||||
"diff": "8.0.3",
|
||||
"dompurify": "^3.4.2",
|
||||
"dot-prop": "5.3.0",
|
||||
"events": "3.3.0",
|
||||
"get-value": "3.0.1",
|
||||
"immutable": "4.3.7",
|
||||
"immutable": "^4.3.8",
|
||||
"in-viewport": "3.6.0",
|
||||
"js-base64": "3.7.8",
|
||||
"json-to-ast": "2.1.0",
|
||||
@@ -46,9 +48,9 @@
|
||||
"moving-average": "1.0.0",
|
||||
"number-abbreviate": "2.0.0",
|
||||
"os-browserify": "0.3.0",
|
||||
"parse-duration": "0.1.1",
|
||||
"parse-duration": "^2.1.6",
|
||||
"path-browserify": "1.0.1",
|
||||
"prismjs": "1.29.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "19.2.3",
|
||||
"react-ace": "14.0.1",
|
||||
"react-dom": "19.2.3",
|
||||
@@ -62,7 +64,7 @@
|
||||
"sha1": "1.1.1",
|
||||
"socket.io-client": "4.8.1",
|
||||
"url": "0.11.4",
|
||||
"uuid": "11.0.0"
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "7.28.4",
|
||||
@@ -90,18 +92,18 @@
|
||||
"html-webpack-plugin": "5.6.3",
|
||||
"jsdom": "25.0.1",
|
||||
"jsdom-global": "3.0.2",
|
||||
"lodash": "4.17.23",
|
||||
"mocha": "10.8.2",
|
||||
"lodash": "^4.18.1",
|
||||
"mocha": "^11.7.5",
|
||||
"moment": "2.30.1",
|
||||
"node-loader": "2.0.0",
|
||||
"source-map-loader": "5.0.0",
|
||||
"style-loader": "4.0.0",
|
||||
"ts-loader": "9.5.1",
|
||||
"typescript": "5.9.3",
|
||||
"webpack": "5.98.0",
|
||||
"webpack": "^5.106.2",
|
||||
"webpack-bundle-analyzer": "4.10.2",
|
||||
"webpack-cli": "6.0.1",
|
||||
"webpack-dev-server": "5.2.0"
|
||||
"webpack-dev-server": "^5.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"electron": "^39"
|
||||
|
||||
@@ -4,15 +4,33 @@ import io, { Socket } from 'socket.io-client'
|
||||
import { SocketIOClientEventBus } from '../../events/EventSystem/SocketIOClientEventBus'
|
||||
import { Rpc } from '../../events/EventSystem/Rpc'
|
||||
|
||||
// Get auth from sessionStorage or use empty (will show login dialog)
|
||||
let username = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('mqtt-explorer-username') || '' : ''
|
||||
let password = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('mqtt-explorer-password') || '' : ''
|
||||
// Use memory-based storage for credentials (more secure than sessionStorage)
|
||||
// Credentials are cleared after successful authentication
|
||||
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)
|
||||
const socket: Socket = io({
|
||||
auth: {
|
||||
username,
|
||||
password,
|
||||
username: authCredentials.username,
|
||||
password: authCredentials.password,
|
||||
},
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
@@ -56,6 +74,9 @@ socket.on('disconnect', reason => {
|
||||
socket.on('connect', () => {
|
||||
console.log('Socket connected successfully')
|
||||
|
||||
// Clear stored credentials after successful authentication for security
|
||||
clearStoredCredentials()
|
||||
|
||||
// Dispatch custom event that BrowserAuthWrapper can listen to
|
||||
if (typeof window !== 'undefined') {
|
||||
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
|
||||
socket.on('auto-connect-config', (config: any) => {
|
||||
console.log('Auto-connect configuration received from server')
|
||||
|
||||
@@ -71,6 +71,21 @@ export function AboutDialog(props: AboutDialogProps) {
|
||||
|
||||
<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
|
||||
sx={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -25,12 +25,32 @@ function ContentView(props: Props) {
|
||||
// Use different defaults for mobile viewports (<=768px width)
|
||||
// Use state for mobile detection that updates on resize
|
||||
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 [sidebarWidth, setSidebarWidth] = React.useState<string | number>(isMobile ? '100%' : '40%')
|
||||
const [detectedHeight, setDetectedHeight] = 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
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
@@ -92,10 +112,14 @@ function ContentView(props: Props) {
|
||||
// Expose tab switching functions for other components to call
|
||||
React.useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
;(window as any).switchToDetailsTab = () =>
|
||||
(setMobileTab(1)(window as any).switchToTopicsTab = () => setMobileTab(0))
|
||||
;(window as any).switchToPublishTab = () => setMobileTab(2)
|
||||
;(window as any).switchToChartsTab = () => setMobileTab(3)
|
||||
;(window as any).switchToDetailsTab = () => setMobileTab(1)
|
||||
;(window as any).switchToTopicsTab = () => setMobileTab(0)
|
||||
;(window as any).switchToPublishTab = () => {
|
||||
if (!hidePublishPane) {
|
||||
setMobileTab(2)
|
||||
}
|
||||
}
|
||||
;(window as any).switchToChartsTab = () => setMobileTab(hidePublishPane ? 2 : 3)
|
||||
}
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -161,7 +185,7 @@ function ContentView(props: Props) {
|
||||
|
||||
return (
|
||||
<div style={mobileContainerStyle}>
|
||||
<MobileTabs value={mobileTab} onChange={setMobileTab} />
|
||||
<MobileTabs value={mobileTab} onChange={setMobileTab} hidePublishPane={hidePublishPane} />
|
||||
<div style={tabContentStyle}>
|
||||
{/* Topics tab - keep mounted, toggle visibility */}
|
||||
<div style={{ ...treeContainerStyle, display: mobileTab === 0 ? 'block' : 'none' }}>
|
||||
@@ -171,12 +195,14 @@ function ContentView(props: Props) {
|
||||
<div style={{ ...sidebarContainerStyle, display: mobileTab === 1 ? 'block' : 'none' }}>
|
||||
<Sidebar connectionId={props.connectionId} />
|
||||
</div>
|
||||
{/* Publish tab - keep mounted, toggle visibility */}
|
||||
<div style={{ ...sidebarContainerStyle, display: mobileTab === 2 ? 'block' : 'none' }}>
|
||||
<PublishTab connectionId={props.connectionId} />
|
||||
</div>
|
||||
{/* Charts tab - keep mounted, toggle visibility */}
|
||||
<div style={{ ...sidebarContainerStyle, display: mobileTab === 3 ? 'block' : 'none' }}>
|
||||
{/* Publish tab - conditionally rendered */}
|
||||
{!hidePublishPane && (
|
||||
<div style={{ ...sidebarContainerStyle, display: mobileTab === 2 ? 'block' : 'none' }}>
|
||||
<PublishTab connectionId={props.connectionId} />
|
||||
</div>
|
||||
)}
|
||||
{/* Charts tab - adjust index based on publish visibility */}
|
||||
<div style={{ ...sidebarContainerStyle, display: mobileTab === (hidePublishPane ? 2 : 3) ? 'block' : 'none' }}>
|
||||
<ChartPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface Props {
|
||||
classes: any
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
hidePublishPane?: boolean
|
||||
}
|
||||
|
||||
function MobileTabs(props: Props) {
|
||||
@@ -18,6 +19,8 @@ function MobileTabs(props: Props) {
|
||||
props.onChange(newValue)
|
||||
}
|
||||
|
||||
const hidePublishPane = props.hidePublishPane || false
|
||||
|
||||
return (
|
||||
<Box className={props.classes.root} role="navigation" aria-label="Mobile navigation tabs">
|
||||
<Tabs
|
||||
@@ -26,7 +29,7 @@ function MobileTabs(props: Props) {
|
||||
variant="fullWidth"
|
||||
indicatorColor="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
|
||||
icon={<AccountTreeIcon />}
|
||||
@@ -44,21 +47,23 @@ function MobileTabs(props: Props) {
|
||||
id="mobile-tab-1"
|
||||
aria-controls="mobile-tabpanel-1"
|
||||
/>
|
||||
<Tab
|
||||
icon={<SendIcon />}
|
||||
label="Publish"
|
||||
data-testid="mobile-tab-publish"
|
||||
aria-label="Publish messages"
|
||||
id="mobile-tab-2"
|
||||
aria-controls="mobile-tabpanel-2"
|
||||
/>
|
||||
{!hidePublishPane && (
|
||||
<Tab
|
||||
icon={<SendIcon />}
|
||||
label="Publish"
|
||||
data-testid="mobile-tab-publish"
|
||||
aria-label="Publish messages"
|
||||
id="mobile-tab-2"
|
||||
aria-controls="mobile-tabpanel-2"
|
||||
/>
|
||||
)}
|
||||
<Tab
|
||||
icon={<ShowChartIcon />}
|
||||
label="Charts"
|
||||
label={hidePublishPane ? "Charts" : "Charts"}
|
||||
data-testid="mobile-tab-charts"
|
||||
aria-label="View charts"
|
||||
id="mobile-tab-3"
|
||||
aria-controls="mobile-tabpanel-3"
|
||||
id={hidePublishPane ? "mobile-tab-2" : "mobile-tab-3"}
|
||||
aria-controls={hidePublishPane ? "mobile-tabpanel-2" : "mobile-tabpanel-3"}
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as diff from 'diff'
|
||||
import * as Prism from 'prismjs'
|
||||
import * as React from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { JsonPropertyLocation, literalsMappedByLines } from '../../../../../backend/src/JsonAstParser'
|
||||
import { Typography } from '@mui/material'
|
||||
import { withStyles } from '@mui/styles'
|
||||
@@ -61,7 +62,7 @@ class CodeDiff extends React.PureComponent<Props, State> {
|
||||
const currentLines = styledLines.slice(lineNumber, lineNumber + changedLines)
|
||||
const lines = currentLines.map((html: string, idx: number) => (
|
||||
<div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={this.props.classes.line}>
|
||||
<span dangerouslySetInnerHTML={{ __html: html }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
|
||||
</div>
|
||||
))
|
||||
lineNumber += changedLines
|
||||
|
||||
@@ -53,6 +53,9 @@ function SidebarNew(props: Props) {
|
||||
const theme = useTheme()
|
||||
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) => {
|
||||
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 (
|
||||
<div id="Sidebar" className={classes.root}>
|
||||
<Box className={classes.tabsContainer}>
|
||||
@@ -82,7 +85,7 @@ function SidebarNew(props: Props) {
|
||||
className={classes.tabs}
|
||||
>
|
||||
<Tab label="Details" className={classes.tab} />
|
||||
<Tab label="Publish" className={classes.tab} />
|
||||
{!hidePublishPane && <Tab label="Publish" className={classes.tab} />}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
@@ -90,9 +93,11 @@ function SidebarNew(props: Props) {
|
||||
<Box sx={{ display: tabValue === 0 ? 'block' : 'none' }}>
|
||||
<DetailsTab node={node} connectionId={props.connectionId} />
|
||||
</Box>
|
||||
<Box sx={{ display: tabValue === 1 ? 'block' : 'none' }}>
|
||||
<PublishTab connectionId={props.connectionId} />
|
||||
</Box>
|
||||
{!hidePublishPane && (
|
||||
<Box sx={{ display: tabValue === 1 ? 'block' : 'none' }}>
|
||||
<PublishTab connectionId={props.connectionId} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { compareVersions } from 'compare-versions'
|
||||
import electron from 'electron'
|
||||
import React from 'react'
|
||||
import axios from 'axios'
|
||||
import DOMPurify from 'dompurify'
|
||||
import Close from '@mui/icons-material/Close'
|
||||
import CloudDownload from '@mui/icons-material/CloudDownload'
|
||||
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>`)
|
||||
.join('<hr />')
|
||||
|
||||
// Sanitize HTML to prevent XSS attacks
|
||||
const sanitizedReleaseNotes = DOMPurify.sanitize(releaseNotes)
|
||||
|
||||
return (
|
||||
<Modal open={this.props.showUpdateDetails} disableAutoFocus onClose={this.hideDetails}>
|
||||
<Paper className={this.props.classes.root}>
|
||||
@@ -159,7 +163,7 @@ class UpdateNotifier extends React.PureComponent<Props, State> {
|
||||
Version {latestUpdate.tag_name}
|
||||
</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()}
|
||||
<Button className={this.props.classes.download} onClick={this.openHomePage}>
|
||||
Github Page
|
||||
|
||||
3108
app/yarn.lock
3108
app/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user