WICG / navigation-api

The new navigation API provides a new interface for navigations and session history, with a focus on single-page application navigations.
https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigation-api
487 stars 26 forks source link

Cancelling UI initiated navigations (back/forward) #32

Closed posva closed 1 year ago

posva commented 3 years ago

Hello, looking at the part regarding navigation cancellation (https://github.com/WICG/app-history#navigation-monitoring-and-interception) and later on, the comparison with navigation guards makes me wonder why can't UI based navigations (or history.back()) be cancelled as well with event.preventDefault() for same document and origin navigations? If they can't be cancelled navigation guards cannot be implemented like proposed since they also run when the user navigates backwards and forwards. Currently, on Vue Router 4, I restore the history entry (when possible): https://github.com/vuejs/router/blob/main/packages/router/src/router.ts#L1039-L1042 when a ui initiated navigation is cancelled.

It would be even nicer if the URL stays the same until the navigation is confirmed, no matter what initiated it, making it consistent when doing history.back() (or clicking the ui button) or clicking on a link of the application. In applications, users show modals or confirmations messages when before navigating away from a page (e.g. to not lose unsaved changes) and the URL changing is often confusing

All of the above is for same origin and document navigations, which is the context of an SPA.

Yay295 commented 3 years ago

To prevent someone from trapping a user on their page by canceling all navigations.

posva commented 3 years ago

To prevent someone from trapping a user on their page by canceling all navigations.

I updated the description, what I said is only relevant for the same origin and document (e.g. SPA) navigations

domenic commented 3 years ago

Hey, thanks for opening this! Unfortunately, we can't allow intercepting the back/forward buttons, even for same-document navigations. Consider the following attack:

You have now completely disabled the user's back button, since pressing back does a same-document navigation, but that navigation never succeeds.

This isn't an acceptable outcome for our users, so we can't allow interception in this way.

We can allow interception of same-document URL-bar triggered navigations, i.e. if the user changes the fragment component. I'm working to clarify that in #26. There's also no problem with intercepting history.back(), or clicking links; those are all interceptable. But for the browser UI's back/forward button, interception is too abuse-prone, even same-document.

Does this make sense?

posva commented 3 years ago

We can allow interception of same-document URL-bar triggered navigations, i.e. if the user changes the fragment component.

Can we intercept it if they change the pathname or the search through the back and forward buttons?

Does this make sense?

To me, this part isn't clear 😅 :

This isn't an acceptable outcome for our users, so we can't allow interception in this way.

As a developer, we can create pop-ups and move them around. We can make the experience as annoying as possible but nobody would use our website if we wanted to. To me, that has always been part of software.

I understand browsers want to protect their users to some extent and I think being able to lock the user while they are on the same web application is where to draw the line because it enables enhanced user experiences like avoiding losing changes by accident (a two finger swipe, trying to resize/show a sidebar for example). Users are never trapped as they can close the window/tab or change the URL and the application won't be able to intercept that. Or at least, that's what I would like to have.

FWIW, if I cannot intercept same document/URL navigations from anywhere, I won't be able to benefit from e.preventDefault() or e.respondWith() as much as I wished because I will have to make the code work in all scenarios and therefore fall back to what I'm doing right now by saving the position in the history state to compute a delta and restore the navigation (which can still be escaped by the user by pressing multiple times the back button).

It's still great for all the other improvements it brings but I fail to see how intercepting same domain navigations no matter where they come from are abuse-prone.

I also don't understand why history.back() is interceptable but clicking the back button isn't. Don't they trigger the same behavior? 🤔

domenic commented 3 years ago

Can we intercept it if they change the pathname or the search through the back and forward buttons?

No; the back and forward buttons are not interceptable, for the reasons explained above and below.

I understand browsers want to protect their users to some extent and I think being able to lock the user while they are on the same web application

The problem is that it crosses the boundary. The user is trying to exit your web application, using the back/forward buttons. And you're not letting them. That's what's not acceptable. This is true even if the current navigation is same-document, because of the example I gave above.

I understand you think that the close button and location bar editing are an acceptable alternative, but our believe is that this isn't good enough---especially on mobile.

I fail to see how intercepting same domain navigations no matter where they come from are abuse-prone.

Hmm, I tried to make this pretty clear in my above example. Is the issue that you don't believe disabling the user's back button is abusive? Maybe that's the root of our disagreement.

I also don't understand why history.back() is interceptable but clicking the back button isn't. Don't they trigger the same behavior? 🤔

They do, but this isn't about the effect; it's about which party is trying to go back. One is user-triggered, and the other is web-developer triggered. There's no problem with the web developer overriding their own attempt at navigation. The problem is when the web developer's desires to prevent back interferes with the user's attempt at navigation.

domenic commented 3 years ago

I do want to emphasize we're not 100% confident in our thinking. I've been making categorical statements which might imply that I insist on the current restrictions, but what I've been trying to be categorical about is that we can't allow abuse and trapping the user. There might be other ways of accomplishing this than the current proposal.

For example, you could imagine well-specified rules like "the second back button push will go through if the first one was intercepted" which would allow the user to escape a site with only a bit more friction. Or, the browser could pop up a dialog saying "It seems this site might be trying to trap you. Would you like to kill it?" if you try to intercept a back button navigation. (Maybe only after you do it a couple of times.)

One way to approach such rules is figuring out how browsers handle abuse abusive back-button-disabling experiences today. Although I can't guarantee that if we found an abusive experience we'd be OK with letting appHistory replicate it---more likely we'd look at fixing it in the existing APIs---it'd be at least an interesting starting point. For example, navigating to

  1. https://example.com/ using your URL bar
  2. https://output.jsbin.com/xaniyazowe/2 in the same window using your URL bar

and then pressing back, results in you being trapped in Firefox, but able to escape in Chrome. Chrome seems to prevent the trap by having the back button skip the intermediate history entry. Maybe that's a route forward here, where we could allow navigation interception, but skip (some? which?) intercepted navigations whenever the user uses the back button? Hmm.

posva commented 3 years ago

Hmm, I tried to make this pretty clear in my above example. Is the issue that you don't believe disabling the user's back button is abusive? Maybe that's the root of our disagreement.

This is the part that isn't clear for me and I do think it's the root of me not being able to properly explain the valid use case we see today in applications and is a bit hackish so let me rephrase. In my opinion:

Currently, when pressing back or forward, the URL immediately changes. For client-side routers, this means that any navigation guard that would prevent the navigation, will have to restore the previous entry with history.go(-delta) (delta being a number different from 0 and representing the number of entries the user traversed) if the navigation has to be cancelled, e.g. in a navigation guard.

and then pressing back, results in you being trapped in Firefox, but able to escape in Chrome. Chrome seems to prevent the trap by having the back button skip the intermediate history entry

I have the same result on OSX on both browsers: being able to leave

One possibility is to separate the entry position displayed by the UI buttons from the URL being displayed and the information being passed to the application. Given the history: [example.com, word.com/documents, word.com/documents/20] with unsaved changes, back button is enabled, forward is disabled,

It's up to the router implementation to properly handle unfinished navigations (e.g. if there are more than 2 entries with the same domain URL and the user goes through them) no matter where they started from.

tbondwilkinson commented 3 years ago

I think there's definitely trade-offs to be made here, and we could allow this if we also had creative solutions to address abuse. I tend to side with "trusting the application" not to do the wrong thing, but I also think that we do need to give users the power to ban abusive websites from using controls if they have shown they're abusive.

E.g., we have the ability to say "Stop this page from showing me alerts". Maybe we also need to have a "Stop this page from writing to history".

A user could turn off JS for a page at any point. Not many users are familiar enough with how web technologies work to know that usually abusive history patterns derive from JS usage of history APIs.

mmocny commented 3 years ago

I was just thinking about the back button and wondering if it would count as a userInitiated navigation, and was also surprised that it wouldn't even trigger a navigate event.

I don't understand the use cases for "navigation guards" when navigating backwards, and I certainly share @domenic 's concerns about trapping.

However, I'd like to know, functionally, how is back expected to work for SPA if it doesn't fire a navigate event? Will it force a hard navigation? That feels like a bad result.

(Also, isn't back button already interceptable and widely used via onpopstate?)

domenic commented 3 years ago

However, I'd like to know, functionally, how is back expected to work for SPA if it doesn't fire a navigate event? Will it force a hard navigation? That feels like a bad result.

The same way as it does today. If the history entry being navigated back to is same-document, then it'll do a same-document navigation. That will fire all the appropriate after-the-fact events, such as currententrychange, which can be used to observe the process.

(Also, isn't back button already interceptable and widely used via onpopstate?)

The popstate event does not allow intercepting the back button. It allows observing a back navigation after the fact. That's the role that currententrychange serves in app history.

posva commented 3 years ago

I don't understand the use cases for "navigation guards" when navigating backwards, and I certainly share @domenic 's concerns about trapping.

Bottom of https://github.com/WICG/app-history/issues/32#issuecomment-775777087, tldr: leaving a page by accident and losing changes. In reality, it's the same as a regular navigation guard, it's just that the navigation happened using the back button instead of clicking on a link, but it's still a navigation from the router perspective

domenic commented 3 years ago

FYI, I'm working on a pull request to solve #53 by allowing respondWith() for such navigations, even if we don't allow preventDefault() yet.

On allowing preventDefault(): similar concerns about leaving a page by accident and losing changes came up in an offline discussion with @mjackson and @ryanflorence. It seems like this is an important use case we need to solve.

A tentative proposal the three of us came up with is that you get one "free" preventDefault() after the user has interacted with your page. If you want a second one, the user must interact with your page again between the two preventDefault() calls. For example, if the user clicked on a "No, let me save my work" button, that would reset the preventDefault() guard, and let you cancel it again.

Note that this doesn't solve the "route guard" case of preventing access to logged-in state after you're logged out. However, maybe that is better solved by an API like https://github.com/whatwg/html/issues/5744 (and perhaps an appHistory counterpart, which is in the same spirit as #9).

The other route we could go to solve the unsaved data case is to come up with something more specifically targeted at that for same-document navigations, like beforeunload is targeted at that case for cross-document navigations today. That is, some API where the browser explicitly pops up a dialog box asking about whether you're sure you want to leave or not. This has pros and cons versus having pages manage such a dialog themselves while using preventDefault().

posva commented 3 years ago

On allowing preventDefault(): similar concerns about leaving a page by accident and losing changes came up in an offline discussion with @mjackson and @ryanflorence. It seems like this is an important use case we need to solve.

Yes, thank you!

A tentative proposal the three of us came up with is that you get one "free" preventDefault() after the user has interacted with your page. If you want a second one, the user must interact with your page again between the two preventDefault() calls. For example, if the user clicked on a "No, let me save my work" button, that would reset the preventDefault() guard, and let you cancel it again.

Being able to call it once it's all a client router needs to abort it 🙂. It's up to the developer to handle accessing pages that shouldn't be accessed by the user and routers are already able to expose such mechanisms.

The other route we could go to solve the unsaved data case is to come up with something more specifically targeted at that for same-document navigations, like beforeunload is targeted at that case for cross-document navigations today. That is, some API where the browser explicitly pops up a dialog box asking about whether you're sure you want to leave or not. This has pros and cons versus having pages manage such a dialog themselves while using preventDefault().

I think it would be more consistent to have the same event for both actions instead of a new one. On the other hand, an API that is implemented by the browser to display the UI to avoid leaving a page would avoid abusing preventDefault() while also covering the most common case (losing unsaved data). Being able to call preventDefault() just once seems more flexible though.

posva commented 3 years ago

One question: if you can only call preventDefault() but not respondWith(), does that mean that we can only synchronously avoid going back/forward? That would limit the possibilities to only window.confirm() and synchronous functions, wouldn't it?

domenic commented 3 years ago

One question: if you can only call preventDefault() but not respondWith(), does that mean that we can only synchronously avoid going back/forward? That would limit the possibilities to only window.confirm() and synchronous functions, wouldn't it?

You'd have to make a decision synchronously whether or not to call preventDefault(), that's true. (The same for respondWith().) But the way I'd envision it is that if you have unsaved data, you call preventDefault() without asking the user, and then you pop up a box to ask them. If they say "proceed anyway", then you navigate to the destination.

With the current API, this would look like:

appHistory.addEventListener("navigate", async () => {
  if (e.cancelable && hasUnsavedData()) {
    e.preventDefault();
    const continueAnyway = await askTheUser();
    if (continueAnyway) {
      setHasUnsavedDataToFalse();
      if (appHistory.entries.includes(e.destination)) {
        appHistory.navigateTo(e.destination.key, { navigateInfo: e.info }); // #58
      } else {
        appHistory.push(e.destination.url, { state: e.destination.state, navigateInfo: e.info });
      }
    }
  }
});

The dance inside the if (continueAnyway) { ... } block seems somewhat inelegant and easy to mess up, and I wonder if we can make it nicer, but that's the basic idea.

posva commented 3 years ago

That makes sense! I imagine that from a router perspective I would probably differentiate the navigation initiated by the router itself to avoid the need for something like setHasUnsavedDataToFalse()

tbondwilkinson commented 3 years ago

+1, which I think is a really good use case for navigateInfo, since if you know the originator or intent of the redirect, you can selectively ignore events and prevent cycles.

tbondwilkinson commented 3 years ago
appHistory.addEventListener("navigate", async (event) => {
  if (e.cancelable && hasUnsavedData() && !event.info.forceNavigate) {
    e.preventDefault();
    const continueAnyway = await askTheUser();
    if (continueAnyway) {
      if (appHistory.entries.includes(e.destination)) {
        appHistory.navigateTo(e.destination.key, {
            navigateInfo: Object.assign({forceNavigate: true}, e.info),
        });
      } else {
        appHistory.push(e.destination.url, {
           state: e.destination.state,
           navigateInfo: Object.assign({forceNavigate: true}, e.info),
        });
      }
    }
  }
});
bathos commented 3 years ago

We ended up implementing this in some AppHistory-inspired stuff. For same-document back-forward, once you have a JSH representation to work with, it’s as simple as just reversing immediately and issuing the request instead. This seems to work reliably so far and appears instantaneous; it is not really discernably different from regular intercepted traversal, and was surprisingly one of the least complicated parts of switching to an interception model.

I would suggest that, given the above can be done today, intercepting same doc back/forward is not a new capability. If you stopImmediatePropagation on the “unexpected popstate” event, etc, no code except the history mediation layer even knows there was a nanosecond where you were on a different entry. The consuming code doesn’t observe any difference in responding to “history.back()” vs “user clicked back button” for same-document (though we do expose the “cause” in our equivalent of the navigate event).

In the cross-document+same-origin case, it’s also currently possible to follow this pattern by dropping a “rescue mission” payload into sessionStorage for the target entry to open. It replaces the “rescue mission” with a “request mission” and again reverses the nav. Upon return to the original entry, the interceptable request is created. This is not instantaneous, and since the originating document has unloaded and reloaded, it’s a pretty hacky idea of interception (esp. since the user will perceive the flicker), but it does work.

It seems that agents are already smart about not being overeager to assume any “instant reversal” is nefarious. I tried to stress test it and it doesn’t seem like ordinary navigation patterns ever trigger agent-blocking in these cases so far.

domenic commented 3 years ago

We ended up implementing this in some AppHistory-inspired stuff. For same-document back-forward, once you have a JSH representation to work with, it’s as simple as just reversing immediately and issuing the request instead.

Sorry, can you describe this in more detail. What is the "this" you implemented? What is a JSH representation? What did you reverse, and what request did you issue? Maybe a code example or pseudocode would help :).

I would suggest that, given the above can be done today, intercepting same doc back/forward is not a new capability.

Well, what would be a new capability is trapping the user. Or did you find a way to trap the user today?

bathos commented 3 years ago

Apologies, I didn't realize how unclear that was when I wrote it, I definitely did not describe that well.

What is the "this" you implemented?

The "this" there is (in the context of a userland history mediation layer) mapping of "abrupt" history traversals to "navigation requests" (following the navigate event / respondWith / preventDefault model, and allowing interception or prevention, and if neither is done, performing the default action).

What is a JSH representation?

I meant joint session history. Modeling (and healing*) a representation of the joint session history persisted through session storage turned out to be the key to enabling a js-layer interception model like AppHistory's. Upon landing somewhere, you have knowledge of your index in the joint session history and relationship to other same-origin entries (assuming an SPA where all same-origin cross doc is loading the same code).

(* to the extent possible. there are some specific navigation patterns that place previously known forward-entries into limbo where you know they were same origin, but don't know if the entries in those positions are still the same ones you had recorded. fortunately this isn't especially common to run into, nor does it prevent most functionality from working)

What did you reverse, and what request did you issue?

When the agent issues a popstate event, we can discern whether this event was "unexpected," i.e. not the end result of completing a navigate event's lifecycle. Most of the time this would be browser back/forward. If the traversal was same-document, then knowing our modeled current entry's index and the new index implied by the popstate, we also know the delta to pass to go() to reverse it. We do so, then behave as if the equivalent of AppHistory's navigateTo operation had been called with the entry we briefly visited. (This is what I referred to as a "request", not really a good term.) The state, title, and url never end up wrong vis a vis the expected current entry - or at least, the chance of any code outside this layer observing that they are briefly wrong is .. low. Users see nothing at all, and if nothing ends up intercepting or preventing the navigation request, history.go is called with the reverse of the prior delta, but this time it's "expected".

Well, what would be a new capability is trapping the user. Or did you find a way to trap the user today?

No, I don't think I did. This wasn't the new capability I meant anyway :) If we performed that reversal 30 times rapidly, all browsers know it’s either abuse or a bug and halt it. But if you perform the reversal as needed (and then, often, immediately reverse the reversal) no browser bats an eye.

I think "can an agent intercede if it suspects anti-user trapping behavior?" and "can a browser-chrome level back/forward be intercepted the same as history.go() would be?" are independent questions whose answers can both be true — and for same-document traversals, both currently are effectively true. I assume that AppHistory will not be changing the fact that agents are free to block navigation attempts (or navigation preventions, here) that they find suspect.

It seems like agents should/would continue being arbiters of when to block traversal-related capabilities, so I don’t know why AppHistory would act as though a behavior which can be implemented in JS today — but which the agent can choose to block — doesn’t exist. Making it concrete might even facilitate/improve the agent’s decision making — e.g. “after prevention or interception of a traversal initiated through browser UI, if the user action is repeated within x seconds, block a second prevention and abort the prior interception if it’s still pending.” This is pretty close to how it behaves today, though it arguably should be more aggressive about blocking than it is in Chromium.


I just saw https://github.com/WICG/app-history/issues/78, which seems to be heading towards a pretty similar conclusion except that it aims to codify when preventDefault() would be unavailable rather than leaving it totally up to the agent. That seems just as good to me!

domenic commented 3 years ago

Thanks, that's super-helpful! Indeed, I'd like to get something more concrete and interoperable than the existing heuristics that we have today for preventing abuse, especially since the inverted model of app history (where we'd need to not fire an event, instead of ignore a history.pushState() call) changes the calculus a bit.

I'm glad to hear that the discussion in #78 / https://github.com/WICG/app-history/issues/32#issuecomment-789944257 seems like it's trending in the right direction to you! I've recently reached out to the person who implemented Chrome's current history manipulation intervention to see what they think of that, and I'm hopeful they'll be on board...

atscott commented 3 years ago

Just to quickly chime in from an Angular perspective here: I also think that this discussion is moving in the right direction. The need to cancel a navigation that's triggered by the back button is something Angular developers struggle with as well (because we don't handle it well in the Angular Router https://github.com/angular/angular/issues/13586). We've had a community contributor propose a fix that use history.go for restoration, as @bathos and @posva have mentioned (as well as storing the page ID's to enable this, as @bathos described). It would be great to have this all managed in the app history API rather than having each framework struggle with this history tracking / restoration behavior on a rejected navigation.

posva commented 3 years ago

About the addition at https://github.com/WICG/app-history/pull/182/files. Specifically:

We would like to make these cancelable in the future. However, we need to take care when doing so:

I'm confused because I understood there was an agreement on being able to cancel user-initiated same-document traversals to prevent the user from losing data when leaving a page.

They still trigger a navigate event, don't they? that way, we can still rollback to the previous history entry if necessary even if the URL changes.

domenic commented 3 years ago

I'm confused because I understood there was an agreement on being able to cancel user-initiated same-document traversals to prevent the user from losing data when leaving a page.

There is agreement on doing so in the future, when we can do the extra implementation work (requires lots of extra complexity since it causes cross-process messaging) and anti-abuse work (what is mentioned in what you quote).

They still trigger a navigate event, don't they? that way, we can still rollback to the previous history entry if necessary even if the URL changes.

Correct.

posva commented 3 years ago

It's cristal clear now, thanks!

lukebrowell commented 2 years ago

Is preventing preventDefault on browser back button invocation enough?

Could a threat actor use the navigation API in it's current form to change window.location, open a new tab or trigger a blocking interaction like an alert, in response to a navigation event?

jjkola-eskosystems commented 2 years ago

I would like to see the navigation event fired even if user used back/forward functionality. I have already had to circumvent around unload events not firing if not enough interaction has happened (unload events were used for closing opened windows from SPA).

I support allowing cancelling UI initiated navigations once but if not then the next best would be that event would be fired but you couldn't prevent navigation (preventDefault and intercept blocked).

Shouldn't alert be blocked in same way as is done in unload events? If we also block location change then threat actor can only open a new tab/window but it can be done only once as the user will close the opened window (unload events won't fire).

domenic commented 2 years ago

I would like to see the navigation event fired even if user used back/forward functionality. I have already had to circumvent around unload events not firing if not enough interaction has happened (unload events were used for closing opened windows from SPA).

I support allowing cancelling UI initiated navigations once but if not then the next best would be that event would be fired but you couldn't prevent navigation (preventDefault and intercept blocked).

Have you tried the navigation API? This is already the behavior.

jjkola-eskosystems commented 2 years ago

I replied based on the discusions here but yes it seems to behave that way (although there seems to be some problems with debugging at least back navigation event, which I have reported to chrome moments ago).

domenic commented 2 years ago

Hi all, I'm here to give an update on our progress on this issue. (Really, it's all @natechapin's progress!)

We've updated the explainer in https://github.com/WICG/navigation-api/commit/a430943e9e82c9288120f2201dabf588f126a273 and the spec in https://github.com/WICG/navigation-api/commit/7ece8d774615a101d4c469579b849c3b2377b404 to allow canceling traversals in some cases. Specifically, we currently allow such cancelation:

Simultaneously, Nate has worked on a behind-a-flag change to Chromium to prototype this, which is undergoing review. As mentioned previously, this is a pretty tricky change from an implementation perspective, so that review will take some time. But we're optimistic.

However, the above limitations are too strict, and are not what we want to ship. Instead, we want to loosen "The traversal happens when there is user activation" to something more like "There has been non-consumed user activation at some point in the past". This avoids the short time limit, and makes it so that:

We're calling this concept "consumable sticky activation", since it's a variant of sticky activation. It'll be a generalization of other work we've previously done for the not-yet-shipped CloseWatcher proposal.

The exact details are still under some discussion, so that'll add a bit more time. But I wanted to let people know there's been significant progress here, and that it's our current main work item for improving the navigation API now that v1 has shipped!

bathos commented 2 years ago

That’s awesome — it sounds like the consumable sticky activation model would capture what’s needed precisely. Way better than [insert implementation-specific heuristics here]!

domenic commented 2 years ago

I spun out a separate issue to discuss a particular aspect of this API, which is how exactly to allow it to block some traversals without slowing down all traversals: https://github.com/WICG/navigation-api/issues/254. Thoughts appreciated, especially on the question of whether blocking cross-document traversals is important.

domenic commented 1 year ago

Hey folks! Cancelable same-document traversals are now available in Chromium ≥112.0.5613.0, including the appropriate safeguards against back-trapping. Please try them out in the demo at https://gigantic-honored-octagon.glitch.me/ ! (There's a new checkbox at the bottom to try to cancel the traversals.)

From the issue-tracking point of view, I think the explainer is still pretty accurate, except for not being updated for the conclusion of #254. The spec updates are being incorporated into https://github.com/whatwg/html/pull/8502 . And web platform tests have fully landed. So we can probably close this out!

domenic commented 8 months ago

It sounds like you are describing some sort of Chromium bug. A closed issue on a web specification is not the best place to report those; I suggest https://crbug.com. And as always with bug reports, minimal reproduction cases are key.