diff --git a/apps/desktop/src/security.test.ts b/apps/desktop/src/security.test.ts index 25cc01f05..0a88499f1 100644 --- a/apps/desktop/src/security.test.ts +++ b/apps/desktop/src/security.test.ts @@ -85,6 +85,25 @@ describe('desktop URL security', () => { assert.equal(normalizeDesktopDashboardPath('/tasks?status=open#recent'), '/tasks?status=open#recent'); }); + it('revalidates open links after canonical serialization', () => { + const rawPath = `/tasks/${'é '.repeat(300)}end`; + const rawLink = `propr://open?path=${rawPath}`; + const expandedCanonicalLink = new URL(rawLink).href; + assert.ok(rawLink.length < 2_048); + assert.ok(expandedCanonicalLink.length > 2_048); + assert.notEqual(dashboardPathFromDeepLink(rawLink), null); + assert.equal(dashboardPathFromDeepLink(expandedCanonicalLink), null); + assert.equal(normalizeDeepLink(rawLink), null); + + const canonicalPrefix = 'propr://open?path=%2Ftasks%2F'; + const suffix = 'a'.repeat(2_048 - canonicalPrefix.length); + const boundaryCanonicalLink = `${canonicalPrefix}${suffix}`; + assert.equal(boundaryCanonicalLink.length, 2_048); + assert.equal(new URL(boundaryCanonicalLink).href, boundaryCanonicalLink); + assert.equal(dashboardPathFromDeepLink(boundaryCanonicalLink), `/tasks/${suffix}`); + assert.equal(normalizeDeepLink(boundaryCanonicalLink), boundaryCanonicalLink); + }); + it('rejects encoded delimiters combined with encoded traversal', () => { const rejectedPaths = [ '/tasks%23/%2e%2e/login', diff --git a/apps/desktop/src/security.ts b/apps/desktop/src/security.ts index f6d9a13d0..8b1695840 100644 --- a/apps/desktop/src/security.ts +++ b/apps/desktop/src/security.ts @@ -138,8 +138,16 @@ export const normalizeDeepLink = (value: string): string | null => { const url = parseUrl(value); if (!url || url.protocol !== `${DESKTOP_PROTOCOL}:` || hasCredentials(url)) return null; if (!DEEP_LINK_ACTIONS.has(url.hostname) || url.port || url.hash) return null; - if (url.hostname === 'open' && !dashboardPathFromDeepLink(value)) return null; - return url.href; + const dashboardPath = url.hostname === 'open' ? dashboardPathFromDeepLink(value) : null; + if (url.hostname === 'open' && dashboardPath === null) return null; + + const canonicalCandidate = url.href; + if (canonicalCandidate.length > 2_048 || /[\u0000-\u001F\u007F]/.test(canonicalCandidate)) return null; + if ( + url.hostname === 'open' + && dashboardPathFromDeepLink(canonicalCandidate) !== dashboardPath + ) return null; + return canonicalCandidate; }; export const deepLinkFromArguments = (argv: readonly string[]): string | null => { diff --git a/propr-ui/src/desktop-deep-link.test.ts b/propr-ui/src/desktop-deep-link.test.ts index 8e42cb6db..b431da2ff 100644 --- a/propr-ui/src/desktop-deep-link.test.ts +++ b/propr-ui/src/desktop-deep-link.test.ts @@ -34,6 +34,28 @@ describe('desktop open deep-link navigation', () => { expect(navigate).toHaveBeenCalledWith('/tasks?status=open#recent'); }); + it('rejects an expanded canonical link and accepts one at the length limit', () => { + const navigate = vi.fn(); + const navigation = new DesktopDeepLinkNavigation(navigate); + navigation.setDashboardReady(); + + const rawPath = `/tasks/${'é '.repeat(300)}end`; + const rawLink = `propr://open?path=${rawPath}`; + const expandedCanonicalLink = new URL(rawLink).href; + expect(rawLink.length).toBeLessThan(2_048); + expect(expandedCanonicalLink.length).toBeGreaterThan(2_048); + expect(navigation.receive(expandedCanonicalLink)).toBe(false); + + const canonicalPrefix = 'propr://open?path=%2Ftasks%2F'; + const suffix = 'a'.repeat(2_048 - canonicalPrefix.length); + const boundaryCanonicalLink = `${canonicalPrefix}${suffix}`; + expect(boundaryCanonicalLink).toHaveLength(2_048); + expect(new URL(boundaryCanonicalLink).href).toBe(boundaryCanonicalLink); + expect(navigation.receive(boundaryCanonicalLink)).toBe(true); + expect(navigate).toHaveBeenCalledOnce(); + expect(navigate).toHaveBeenCalledWith(`/tasks/${suffix}`); + }); + it('does not route malformed or unsafe links before or after dashboard load', () => { const navigate = vi.fn(); const navigation = new DesktopDeepLinkNavigation(navigate);