11import { beforeEach , describe , expect , it , vi } from "vitest" ;
22
3- const mockOpenExternal = vi . hoisted ( ( ) => vi . fn ( ) ) ;
3+ const mockOpenExternal = vi . hoisted ( ( ) => vi . fn ( ( ) => Promise . resolve ( ) ) ) ;
44const mockWarn = vi . hoisted ( ( ) => vi . fn ( ) ) ;
55
66vi . mock ( "electron" , ( ) => ( {
@@ -26,7 +26,13 @@ type WillNavigateHandler = (
2626 url : string ,
2727) => void ;
2828
29- function setup ( ) {
29+ // Packaged renderer served from a file: URL, and dev renderer from the Vite origin.
30+ const PROD_HOME = new URL (
31+ "file:///Applications/PostHog.app/resources/renderer/main_window/index.html" ,
32+ ) ;
33+ const DEV_HOME = new URL ( "http://localhost:5173" ) ;
34+
35+ function setup ( appHome : URL ) {
3036 let windowOpenHandler : WindowOpenHandler | undefined ;
3137 let willNavigateHandler : WillNavigateHandler | undefined ;
3238 const window = {
@@ -41,6 +47,7 @@ function setup() {
4147 } ;
4248 setupExternalLinkHandlers (
4349 window as unknown as Parameters < typeof setupExternalLinkHandlers > [ 0 ] ,
50+ appHome ,
4451 ) ;
4552 if ( ! windowOpenHandler || ! willNavigateHandler ) {
4653 throw new Error ( "Handlers were not registered" ) ;
@@ -66,19 +73,14 @@ const UNSAFE_URLS = [
6673 "not a url" ,
6774] ;
6875
69- // In production the renderer is served from file://, so the will-navigate
70- // handler treats file: URLs as in-app navigation rather than external links.
71- const NON_FILE_UNSAFE_URLS = UNSAFE_URLS . filter (
72- ( url ) => ! url . startsWith ( "file:" ) ,
73- ) ;
74-
7576beforeEach ( ( ) => {
7677 vi . clearAllMocks ( ) ;
78+ mockOpenExternal . mockImplementation ( ( ) => Promise . resolve ( ) ) ;
7779} ) ;
7880
7981describe ( "window open handler" , ( ) => {
8082 it . each ( SAFE_URLS ) ( "opens %s externally and denies the window" , ( url ) => {
81- const { windowOpenHandler } = setup ( ) ;
83+ const { windowOpenHandler } = setup ( PROD_HOME ) ;
8284
8385 const result = windowOpenHandler ( { url } ) ;
8486
@@ -87,54 +89,109 @@ describe("window open handler", () => {
8789 } ) ;
8890
8991 it . each ( UNSAFE_URLS ) ( "blocks %s without opening it" , ( url ) => {
90- const { windowOpenHandler } = setup ( ) ;
92+ const { windowOpenHandler } = setup ( PROD_HOME ) ;
9193
9294 const result = windowOpenHandler ( { url } ) ;
9395
9496 expect ( result ) . toEqual ( { action : "deny" } ) ;
9597 expect ( mockOpenExternal ) . not . toHaveBeenCalled ( ) ;
9698 expect ( mockWarn ) . toHaveBeenCalledOnce ( ) ;
9799 } ) ;
100+
101+ it ( "swallows an openExternal rejection instead of leaving it unhandled" , async ( ) => {
102+ mockOpenExternal . mockImplementationOnce ( ( ) =>
103+ Promise . reject ( new Error ( "no handler" ) ) ,
104+ ) ;
105+ const { windowOpenHandler } = setup ( PROD_HOME ) ;
106+
107+ windowOpenHandler ( { url : "https://posthog.com" } ) ;
108+ await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
109+
110+ expect ( mockWarn ) . toHaveBeenCalledOnce ( ) ;
111+ } ) ;
98112} ) ;
99113
100- describe ( "will-navigate handler" , ( ) => {
101- it . each ( SAFE_URLS ) (
102- "prevents navigation to %s and opens it externally" ,
103- ( url ) => {
104- const { willNavigateHandler } = setup ( ) ;
105- const preventDefault = vi . fn ( ) ;
114+ describe ( "will-navigate (packaged, file: home)" , ( ) => {
115+ it . each ( [
116+ "file:///Applications/PostHog.app/resources/renderer/main_window/index.html" ,
117+ "file:///Applications/PostHog.app/resources/renderer/main_window/index.html#/tasks/1" ,
118+ "file:///Applications/PostHog.app/resources/renderer/main_window/assets/app.js" ,
119+ ] ) ( "treats in-app file %s as internal navigation" , ( url ) => {
120+ const { willNavigateHandler } = setup ( PROD_HOME ) ;
121+ const preventDefault = vi . fn ( ) ;
106122
107- willNavigateHandler ( { preventDefault } , url ) ;
123+ willNavigateHandler ( { preventDefault } , url ) ;
108124
109- expect ( preventDefault ) . toHaveBeenCalledOnce ( ) ;
110- expect ( mockOpenExternal ) . toHaveBeenCalledExactlyOnceWith ( url ) ;
111- } ,
112- ) ;
125+ expect ( preventDefault ) . not . toHaveBeenCalled ( ) ;
126+ expect ( mockOpenExternal ) . not . toHaveBeenCalled ( ) ;
127+ } ) ;
113128
114- it . each ( NON_FILE_UNSAFE_URLS ) (
115- "prevents navigation to %s without opening it" ,
129+ it . each ( [
130+ "file:///etc/passwd" ,
131+ "file:///Applications/PostHog.app/resources/renderer/other/index.html" ,
132+ ] ) ( "blocks out-of-app file %s (not opened externally either)" , ( url ) => {
133+ const { willNavigateHandler } = setup ( PROD_HOME ) ;
134+ const preventDefault = vi . fn ( ) ;
135+
136+ willNavigateHandler ( { preventDefault } , url ) ;
137+
138+ expect ( preventDefault ) . toHaveBeenCalledOnce ( ) ;
139+ expect ( mockOpenExternal ) . not . toHaveBeenCalled ( ) ;
140+ expect ( mockWarn ) . toHaveBeenCalledOnce ( ) ;
141+ } ) ;
142+
143+ it ( "routes an external https link to the browser" , ( ) => {
144+ const { willNavigateHandler } = setup ( PROD_HOME ) ;
145+ const preventDefault = vi . fn ( ) ;
146+
147+ willNavigateHandler ( { preventDefault } , "https://posthog.com" ) ;
148+
149+ expect ( preventDefault ) . toHaveBeenCalledOnce ( ) ;
150+ expect ( mockOpenExternal ) . toHaveBeenCalledExactlyOnceWith (
151+ "https://posthog.com" ,
152+ ) ;
153+ } ) ;
154+ } ) ;
155+
156+ describe ( "will-navigate (dev server, http: home)" , ( ) => {
157+ it . each ( [ "http://localhost:5173/" , "http://localhost:5173/sessions/42" ] ) (
158+ "treats same-origin dev URL %s as internal navigation" ,
116159 ( url ) => {
117- const { willNavigateHandler } = setup ( ) ;
160+ const { willNavigateHandler } = setup ( DEV_HOME ) ;
118161 const preventDefault = vi . fn ( ) ;
119162
120163 willNavigateHandler ( { preventDefault } , url ) ;
121164
122- expect ( preventDefault ) . toHaveBeenCalledOnce ( ) ;
165+ expect ( preventDefault ) . not . toHaveBeenCalled ( ) ;
123166 expect ( mockOpenExternal ) . not . toHaveBeenCalled ( ) ;
124- expect ( mockWarn ) . toHaveBeenCalledOnce ( ) ;
125167 } ,
126168 ) ;
127169
170+ // The old startsWith check treated these as in-app, so an attacker origin
171+ // could load inside the app window. They must now be punted to the browser:
172+ // userinfo that resolves to another host, a longer port, and a scheme swap.
128173 it . each ( [
129- "file:///app/.vite/renderer/main_window/index.html" ,
130- "file:///etc/passwd" ,
131- ] ) ( "treats %s as in-app navigation and never opens it externally" , ( url ) => {
132- const { willNavigateHandler } = setup ( ) ;
174+ "http://localhost:5173@evil.example/" ,
175+ "http://localhost:51730/" ,
176+ "https://localhost:5173/" ,
177+ ] ) ( "does not treat lookalike origin %s as internal" , ( url ) => {
178+ const { willNavigateHandler } = setup ( DEV_HOME ) ;
133179 const preventDefault = vi . fn ( ) ;
134180
135181 willNavigateHandler ( { preventDefault } , url ) ;
136182
137- expect ( preventDefault ) . not . toHaveBeenCalled ( ) ;
183+ expect ( preventDefault ) . toHaveBeenCalledOnce ( ) ;
184+ expect ( mockOpenExternal ) . toHaveBeenCalledExactlyOnceWith ( url ) ;
185+ } ) ;
186+
187+ it ( "blocks an unsafe scheme in dev too" , ( ) => {
188+ const { willNavigateHandler } = setup ( DEV_HOME ) ;
189+ const preventDefault = vi . fn ( ) ;
190+
191+ willNavigateHandler ( { preventDefault } , "file:///etc/passwd" ) ;
192+
193+ expect ( preventDefault ) . toHaveBeenCalledOnce ( ) ;
138194 expect ( mockOpenExternal ) . not . toHaveBeenCalled ( ) ;
195+ expect ( mockWarn ) . toHaveBeenCalledOnce ( ) ;
139196 } ) ;
140197} ) ;
0 commit comments