mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix: harden local file URL guard
This commit is contained in:
parent
8fb3e8c413
commit
33111e46ff
4 changed files with 72 additions and 19 deletions
|
|
@ -13,7 +13,21 @@
|
|||
*/
|
||||
export const ALLOWED_EXTERNAL_URL_SCHEMES = ['http:', 'https:', 'mailto:', 'file:'];
|
||||
|
||||
const LOCAL_FILE_URL_PREFIX = 'file:///';
|
||||
const ASCII_CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
|
||||
const _isSlash = (char: string): boolean => char === '/' || char === '\\';
|
||||
const _isEncodedSlashAt = (value: string, index: number): boolean =>
|
||||
value.startsWith('%2f', index) || value.startsWith('%5c', index);
|
||||
const _hasUncLikeLocalFilePathStart = (value: string): boolean => {
|
||||
const firstLocalPathCharIndex = LOCAL_FILE_URL_PREFIX.length;
|
||||
return (
|
||||
value[firstLocalPathCharIndex] === '/' ||
|
||||
value[firstLocalPathCharIndex] === '\\' ||
|
||||
_isEncodedSlashAt(value, firstLocalPathCharIndex)
|
||||
);
|
||||
};
|
||||
const _isLocalFileUrlWithoutUncPrefix = (value: string): boolean =>
|
||||
value.startsWith(LOCAL_FILE_URL_PREFIX) && !_hasUncLikeLocalFilePathStart(value);
|
||||
|
||||
/**
|
||||
* True for a UNC / network path such as `\\host\share` or `//host/share` — i.e.
|
||||
|
|
@ -60,11 +74,18 @@ export const isExternalUrlSchemeAllowed = (url: unknown): boolean => {
|
|||
if (parsed.protocol === 'file:') {
|
||||
// Allow ONLY the canonical local form `file:///<path>`. Anything with an
|
||||
// authority (`file://host/…`) or a path-based UNC (`file:////host`) is a
|
||||
// remote SMB reference and leaks the user's NTLM hash. Gate on the RAW
|
||||
// string: Chromium (renderer) and Node (main) parse file: hosts/paths
|
||||
// differently, so `parsed.host` / `parsed.pathname` are not portable.
|
||||
// remote SMB reference and leaks the user's NTLM hash. Gate on both the RAW
|
||||
// string and the parser-normalized href: raw catches parser differences,
|
||||
// normalized catches dot-segment/control-character rewrites.
|
||||
const lower = trimmed.toLowerCase();
|
||||
return lower.startsWith('file:///') && lower[8] !== '/' && lower[8] !== '\\';
|
||||
if (ASCII_CONTROL_CHAR_RE.test(lower)) {
|
||||
return false;
|
||||
}
|
||||
const normalizedLower = parsed.href.toLowerCase();
|
||||
return (
|
||||
_isLocalFileUrlWithoutUncPrefix(lower) &&
|
||||
_isLocalFileUrlWithoutUncPrefix(normalizedLower)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ describe('TaskAttachmentListComponent', () => {
|
|||
[
|
||||
'file://192.168.1.100/share/pixel.png',
|
||||
'file:////host/share/pixel.png',
|
||||
'file:///%5C%5Chost/share/pixel.png',
|
||||
'file:///%2F%2Fhost/share/pixel.png',
|
||||
'file:///%2e%2e/%2F%2Fhost/share/pixel.png',
|
||||
'\\\\host\\share\\pixel.png',
|
||||
'//host/share/pixel.png',
|
||||
].forEach((path) => {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,17 @@ describe('isExternalUrlSchemeAllowed', () => {
|
|||
'file://192.168.1.100/share/x',
|
||||
'file:////host/share', // path-based UNC, empty host
|
||||
'file://///host/share',
|
||||
'file:///%5C%5Chost/share', // percent-encoded \\host/share
|
||||
'file:///%5c%5chost/share',
|
||||
'file:///%5Chost/share',
|
||||
'file:///%2F%2Fhost/share', // percent-encoded //host/share
|
||||
'file:///%2f%2fhost/share',
|
||||
'file:///%2Fhost/share',
|
||||
'file:///%2e//host/share', // dot-segment normalizes to file:////host/share
|
||||
'file:///%2e%2e/%2F%2Fhost/share',
|
||||
'file:///a/..//host/share',
|
||||
'file:///./%2F%2Fhost/share',
|
||||
'file:///\t//host/share',
|
||||
'file:\\\\host\\share',
|
||||
'FILE://HOST/share', // case-insensitive
|
||||
];
|
||||
|
|
@ -96,6 +107,17 @@ describe('isExternalUrlSchemeAllowed', () => {
|
|||
'file://host/share',
|
||||
'file://192.168.1.100/share/x',
|
||||
'file:////host/share',
|
||||
'file:///%5C%5Chost/share',
|
||||
'file:///%5c%5chost/share',
|
||||
'file:///%5Chost/share',
|
||||
'file:///%2F%2Fhost/share',
|
||||
'file:///%2f%2fhost/share',
|
||||
'file:///%2Fhost/share',
|
||||
'file:///%2e//host/share',
|
||||
'file:///%2e%2e/%2F%2Fhost/share',
|
||||
'file:///a/..//host/share',
|
||||
'file:///./%2F%2Fhost/share',
|
||||
'file:///\t//host/share',
|
||||
'',
|
||||
undefined,
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -361,21 +361,25 @@ describe('markedOptionsFactory', () => {
|
|||
tokens.map((t: any) => t.raw || t.text || '').join(''),
|
||||
};
|
||||
|
||||
['ms-calculator:', 'javascript:alert(1)', 'ssh://h', '\\\\host\\share'].forEach(
|
||||
(href) => {
|
||||
it(`renders "${href}" as inert text, not an anchor`, () => {
|
||||
const linkRenderer = options.renderer!.link.bind({ parser: mockParser });
|
||||
const result = linkRenderer({
|
||||
href,
|
||||
title: 'x',
|
||||
tokens: [{ type: 'text', raw: 'Click here', text: 'Click here' }],
|
||||
} as any);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).not.toContain(`href="${href}"`);
|
||||
expect(result).toContain('Click here');
|
||||
});
|
||||
},
|
||||
);
|
||||
[
|
||||
'ms-calculator:',
|
||||
'javascript:alert(1)',
|
||||
'ssh://h',
|
||||
'\\\\host\\share',
|
||||
'file:///%2e%2e/%2F%2Fhost/share',
|
||||
].forEach((href) => {
|
||||
it(`renders "${href}" as inert text, not an anchor`, () => {
|
||||
const linkRenderer = options.renderer!.link.bind({ parser: mockParser });
|
||||
const result = linkRenderer({
|
||||
href,
|
||||
title: 'x',
|
||||
tokens: [{ type: 'text', raw: 'Click here', text: 'Click here' }],
|
||||
} as any);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).not.toContain(`href="${href}"`);
|
||||
expect(result).toContain('Click here');
|
||||
});
|
||||
});
|
||||
|
||||
it('still renders allowed schemes (mailto:, file:) as anchors', () => {
|
||||
const linkRenderer = options.renderer!.link.bind({ parser: mockParser });
|
||||
|
|
@ -531,6 +535,9 @@ describe('markedOptionsFactory', () => {
|
|||
[
|
||||
'file://192.168.1.100/share/pixel.png',
|
||||
'file:////host/share/pixel.png',
|
||||
'file:///%5C%5Chost/share/pixel.png',
|
||||
'file:///%2F%2Fhost/share/pixel.png',
|
||||
'file:///%2e%2e/%2F%2Fhost/share/pixel.png',
|
||||
'\\\\host\\share\\pixel.png',
|
||||
'//host/share/pixel.png',
|
||||
].forEach((href) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue