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
66 changes: 65 additions & 1 deletion public/handlers/urlValidator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import 'jest';
import {
validateAzureIoTHostname,
validateEventHubHostname,
extractEventHubHostname
extractEventHubHostname,
validatePath
} from './urlValidator';

describe('validateAzureIoTHostname', () => {
Expand Down Expand Up @@ -358,3 +359,66 @@ describe('extractEventHubHostname', () => {
expect(extractEventHubHostname(connStr)).toBe('mynamespace.servicebus.windows.net');
});
});

describe('validatePath', () => {
describe('valid paths', () => {
it('accepts simple device path', () => {
expect(validatePath('devices/mydevice')).toBe(true);
});

it('accepts device ID with dots', () => {
expect(validatePath('devices/test.test2')).toBe(true);
});

it('accepts device ID with special characters allowed by IoT Hub', () => {
expect(validatePath("devices/my-device_01.v2")).toBe(true);
});

it('accepts module identity path', () => {
expect(validatePath('devices/mydevice/modules/mymodule')).toBe(true);
});

it('accepts path with percent-encoded characters', () => {
expect(validatePath('devices/my%20device')).toBe(true);
});

it('accepts device query path', () => {
expect(validatePath('devices/query')).toBe(true);
});

it('accepts device ID with colon', () => {
expect(validatePath('devices/device:001')).toBe(true);
});

it('accepts device ID with @ symbol', () => {
expect(validatePath('devices/user@device')).toBe(true);
});
});

describe('invalid paths', () => {
it('rejects path traversal with double dots', () => {
expect(validatePath('devices/../etc/passwd')).toBe(false);
});

it('rejects double slashes', () => {
expect(validatePath('devices//mydevice')).toBe(false);
});

it('rejects empty string', () => {
expect(validatePath('')).toBe(false);
});

it('rejects null/undefined', () => {
expect(validatePath(null as unknown as string)).toBe(false);
expect(validatePath(undefined as unknown as string)).toBe(false);
});

it('rejects path with angle brackets', () => {
expect(validatePath('devices/<script>')).toBe(false);
});

it('rejects path with backtick', () => {
expect(validatePath('devices/test`cmd')).toBe(false);
});
});
});
15 changes: 8 additions & 7 deletions public/handlers/urlValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,16 +249,17 @@ export function validatePath(path: string): boolean {
return false;
}

// Allow alphanumeric, hyphens, underscores, and forward slashes for path segments
// But not double slashes, dots (path traversal), or other special chars
const pathRegex = /^[a-zA-Z0-9\-_\/]+$/;

if (!pathRegex.test(path)) {
// Block path traversal and double slashes
if (path.includes('..') || path.includes('//')) {
return false;
}

// Block path traversal attempts
if (path.includes('..') || path.includes('//')) {
// Allow characters valid in IoT Hub device/module IDs per Azure documentation:
// alphanumeric plus: - . % _ * ? ! ( ) , : = @ $ '
// Also allow / for path segments and URL-encoded characters (%XX)
const pathRegex = /^[a-zA-Z0-9\-._~:@!$&'()*+,;=%/]+$/;

if (!pathRegex.test(path)) {
return false;
}

Expand Down
34 changes: 34 additions & 0 deletions src/app/css/_resizableDetailsList.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/

// IMPORTANT: style DataGrid header/body cells via these classNames, never an
// inline `style` prop on DataGridHeaderCell/DataGridCell. Those components
// spread the consumer's props (including `style`) AFTER the column-sizing
// props, so an inline `style` overwrites the `width` that resizableColumns
// injects and the columns silently stop resizing.
.rdl-header-cell {
font-weight: 600;
}

// Fluent v9 DataGrid does not provide its own horizontal scrollbar (unlike the
// v8 DetailsList). When the grid can no longer shrink to fit (every column is at
// its minWidth and the container is narrower than their combined width), wrap it
// so a horizontal scrollbar appears and all columns stay reachable.
.rdl-scroll-container {
width: 100%;
overflow-x: auto;
}

// Chevron affordance indicating the column header opens an options menu
// (Resize / keyboard resize), mirroring the v8 column dropdown indicator.
.rdl-header-chevron {
margin-left: 4px;
font-size: 12px;
flex-shrink: 0;
}

.rdl-cell {
padding-right: 8px;
}
60 changes: 56 additions & 4 deletions src/app/shared/resizeDetailsList/resizableDetailsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import {
createTableColumn,
DataGrid,
Expand All @@ -11,10 +12,19 @@ import {
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
Menu,
MenuItem,
MenuList,
MenuPopover,
MenuTrigger,
TableColumnDefinition,
TableColumnSizingOptions,
TableRowId,
} from '@fluentui/react-components';
import { ResourceKeys } from '../../../localization/resourceKeys';
import { ChevronDownRegular } from '@fluentui/react-icons';
import { ResizeColumnDialog } from './resizeColumnDialog';
import '../../css/_resizableDetailsList.scss';

export interface IColumn {
ariaLabel?: string;
Expand Down Expand Up @@ -43,6 +53,7 @@ export interface ResizableDetailsListProps {
ariaLabel?: string;
ariaLabelForSelectionColumn?: string;
ariaLabelForSelectAllCheckbox?: string;
autoFitColumns?: boolean;
checkboxVisibility?: CheckboxVisibility;
checkButtonAriaLabel?: string | ((item: any) => string); // tslint:disable-line:no-any
className?: string;
Expand All @@ -57,9 +68,11 @@ export interface ResizableDetailsListProps {
}

export const ResizableDetailsList: React.FC<ResizableDetailsListProps> = props => {
const { t } = useTranslation();
const {
ariaLabel,
ariaLabelForSelectAllCheckbox,
autoFitColumns = true,
checkboxVisibility,
checkButtonAriaLabel,
className,
Expand All @@ -71,6 +84,13 @@ export const ResizableDetailsList: React.FC<ResizableDetailsListProps> = props =
getRowId: getRowIdProp,
} = props;

// Column being resized via the "Resize Column" dialog (undefined when closed).
const [resizeColumnId, setResizeColumnId] = React.useState<string | undefined>(undefined);
// Captured from the DataGrid header render prop so the dialog (rendered
// outside that scope) can apply an exact width via setColumnWidth.
const columnSizingRef = React.useRef<any>(null); // tslint:disable-line:no-any


const getRowId = React.useCallback(
(item: any) => getRowIdProp ? getRowIdProp(item) : String(items.indexOf(item)), // tslint:disable-line:no-any
[getRowIdProp, items]
Expand Down Expand Up @@ -128,11 +148,14 @@ export const ResizableDetailsList: React.FC<ResizableDetailsListProps> = props =
);

return (
<>
<div className="rdl-scroll-container">
<DataGrid
items={items}
columns={dgColumns}
getRowId={getRowId}
resizableColumns={true}
resizableColumnsOptions={{ autoFitColumns }}
columnSizingOptions={columnSizingOptions}
{...(dgSelectionMode ? { selectionMode: dgSelectionMode, onSelectionChange: handleSelectionChange } : {})}
className={className}
Expand All @@ -144,9 +167,31 @@ export const ResizableDetailsList: React.FC<ResizableDetailsListProps> = props =
'aria-label': ariaLabelForSelectAllCheckbox,
} : undefined}
>
{({ renderHeaderCell }) => (
<DataGridHeaderCell style={{ fontWeight: 600 }}>{renderHeaderCell()}</DataGridHeaderCell>
)}
{({ renderHeaderCell, columnId }, dataGrid) => {
columnSizingRef.current = dataGrid.columnSizing_unstable;
return (
<Menu>
<MenuTrigger disableButtonEnhancement={true}>
<DataGridHeaderCell
className="rdl-header-cell"
>
<span className="rdl-header-label">{renderHeaderCell()}</span>
<ChevronDownRegular className="rdl-header-chevron" aria-hidden={true} />
</DataGridHeaderCell>
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem onClick={() => setResizeColumnId(String(columnId))}>
{t(ResourceKeys.resizableDetailsList.buttons.resize)}
</MenuItem>
<MenuItem onClick={dataGrid.columnSizing_unstable.enableKeyboardMode(columnId)}>
{t(ResourceKeys.resizableDetailsList.buttons.keyboardResize)}
</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
);
}}
</DataGridRow>
</DataGridHeader>
<DataGridBody<any>>
Expand All @@ -158,13 +203,20 @@ export const ResizableDetailsList: React.FC<ResizableDetailsListProps> = props =
} : undefined}
>
{({ renderCell }) => (
<DataGridCell focusMode="group" className="rdl-cell" style={{ paddingRight: 8 }}>
<DataGridCell focusMode="group" className="rdl-cell">
{renderCell(item)}
</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
<ResizeColumnDialog
open={resizeColumnId !== undefined}
onResize={width => columnSizingRef.current?.setColumnWidth(resizeColumnId, width)}
onDismiss={() => setResizeColumnId(undefined)}
/>
</>
);
};
67 changes: 67 additions & 0 deletions src/app/shared/resizeDetailsList/resizeColumnDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/***********************************************************
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dialog, DialogSurface, DialogBody, DialogTitle, DialogContent, DialogActions, Field, Input } from '@fluentui/react-components';
import { ResourceKeys } from '../../../localization/resourceKeys';

export interface ResizeColumnDialogProps {
open: boolean;
onResize: (width: number) => void;
onDismiss: () => void;
}

export const ResizeColumnDialog: React.FC<ResizeColumnDialogProps> = ({ open, onResize, onDismiss }) => {
const { t } = useTranslation();
const [value, setValue] = React.useState<string>('');

React.useEffect(() => {
if (open) {
setValue('');
}
}, [open]);

const confirm = () => {
const width = Number(value);
if (width > 0) {
onResize(width);
}
onDismiss();
};

const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
confirm();
}
};

return (
<Dialog open={open} onOpenChange={(_e, data) => { if (!data.open) { onDismiss(); } }}>
<DialogSurface>
<DialogBody>
<DialogTitle>{t(ResourceKeys.resizableDetailsList.content.title)}</DialogTitle>
<DialogContent>
<Field label={t(ResourceKeys.resizableDetailsList.content.subText)}>
<Input
type="number"
min={1}
value={value}
autoFocus={true}
onChange={(_e, data) => setValue(data.value)}
onKeyDown={onKeyDown}
aria-label={t(ResourceKeys.resizableDetailsList.content.subText)}
/>
</Field>
</DialogContent>
<DialogActions>
<Button appearance="primary" onClick={confirm}>{t(ResourceKeys.resizableDetailsList.buttons.resize)}</Button>
<Button onClick={onDismiss}>{t(ResourceKeys.resizableDetailsList.buttons.cancel)}</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
};
4 changes: 3 additions & 1 deletion src/localization/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@
},
"buttons": {
"resize": "Resize",
"cancel": "Cancel"
"cancel": "Cancel",
"keyboardResize": "Resize with keyboard"
},
"columnOptionsAriaLabel": "Column options",
"defaultAriaLabel": "Data table"
},
"header": {
Expand Down
2 changes: 2 additions & 0 deletions src/localization/resourceKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,8 +1015,10 @@ export class ResourceKeys {
public static resizableDetailsList = {
buttons : {
cancel : "resizableDetailsList.buttons.cancel",
keyboardResize : "resizableDetailsList.buttons.keyboardResize",
resize : "resizableDetailsList.buttons.resize",
},
columnOptionsAriaLabel : "resizableDetailsList.columnOptionsAriaLabel",
content : {
closeButtonAriaLabel : "resizableDetailsList.content.closeButtonAriaLabel",
subText : "resizableDetailsList.content.subText",
Expand Down
8 changes: 8 additions & 0 deletions webpack.common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ const config: webpack.Configuration = {
new webpack.DefinePlugin({
'process.env.userdnsdomain': JSON.stringify(process.env.userdnsdomain),
}),
// node-polyfill-webpack-plugin v4 no longer injects the `process` global by default
// (it was dropped from the plugin's defaultPolyfills set). The packaged Electron renderer
// runs without Node integration, so bundled dependencies that reference `process` throw
// "process is not defined". Provide it explicitly. The '.js' extension keeps the request
// fully specified so it also resolves from strict ESM (.mjs) dependencies.
new webpack.ProvidePlugin({
process: 'process/browser.js'
}),
new ESLintPlugin({
extensions: ['ts', 'tsx'],
failOnError: false,
Expand Down
5 changes: 1 addition & 4 deletions webpack.dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License
**********************************************************/
import { Configuration as WebpackConfig, NormalModuleReplacementPlugin, ProvidePlugin } from 'webpack';
import { Configuration as WebpackConfig, NormalModuleReplacementPlugin } from 'webpack';
import { Configuration as WebpackDevServerConfig } from "webpack-dev-server";
import { merge } from 'webpack-merge';
import common from './webpack.common';
Expand Down Expand Up @@ -33,9 +33,6 @@ const config: Config = merge(common, {
},

plugins: [
new ProvidePlugin({
process: require.resolve('process/browser'),
}),
new NormalModuleReplacementPlugin(
/(.*)appConfig.ENV(\.*)/,
resource => resource.request = resource.request.replace(/ENV/, 'electrondev')
Expand Down
Loading