-
Notifications
You must be signed in to change notification settings - Fork 26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Cancelling UI initiated navigations (back/forward) #32
Comments
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 |
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 Does this make sense? |
Can we intercept it if they change the pathname or the search through the back and forward buttons?
To me, this part isn't clear 😅 :
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 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 |
No; the back and forward buttons are not interceptable, for the reasons explained above and below.
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.
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.
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. |
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
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. |
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
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:
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. |
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. |
I was just thinking about the back button and wondering if it would count as a 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 |
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
The |
Bottom of #32 (comment), 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 |
FYI, I'm working on a pull request to solve #53 by allowing On allowing A tentative proposal the three of us came up with is that you get one "free" 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 whatwg/html#5744 (and perhaps an 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 |
* Closes #53 by allowing respondWith() for same-document back/forward navigations (but not yet allowing preventDefault(); that's #32). * Closes #51 by transitioning from event.sameOrigin to event.canRespond, which has updated semantics. * Does not fire navigate events for document.open(), as per some initial implementation investigations that's more trouble than it's worth. * Adds an example for special back/forward handling in the navigate event, per #53 (comment). * Makes the appendix table a bit more precise about userInitiated, and expands it to cover cancelable and canRespond.
Yes, thank you!
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.
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 |
One question: if you can only call |
* Closes #53 by allowing respondWith() for same-document back/forward navigations (but not yet allowing preventDefault(); that's #32). * Closes #51 by transitioning from event.sameOrigin to event.canRespond, which has updated semantics. * Does not fire navigate events for document.open(), as per some initial implementation investigations that's more trouble than it's worth. * Adds an example for special back/forward handling in the navigate event, per #53 (comment). * Makes the appendix table a bit more precise in various ways, and expands it to cover cancelable and canRespond.
You'd have to make a decision synchronously whether or not to call 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 |
That makes sense! |
+1, which I think is a really good use case for |
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),
});
}
}
}
}); |
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. |
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 :).
Well, what would be a new capability is trapping the user. Or did you find a way to trap the user today? |
Apologies, I didn't realize how unclear that was when I wrote it, I definitely did not describe that well.
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).
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)
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".
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 #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! |
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 I'm glad to hear that the discussion in #78 / #32 (comment) 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... |
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 angular/angular#13586). We've had a community contributor propose a fix that use |
About the addition at https://github.com/WICG/app-history/pull/182/files. Specifically:
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 |
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).
Correct. |
It's cristal clear now, thanks! |
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? |
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). |
Have you tried the navigation API? This is already the behavior. |
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). |
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 a430943 and the spec in 7ece8d7 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 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! |
That’s awesome — it sounds like the consumable sticky activation model would capture what’s needed precisely. Way better than |
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: #254. Thoughts appreciated, especially on the question of whether blocking cross-document traversals is important. |
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 whatwg/html#8502 . And web platform tests have fully landed. So we can probably close this out! |
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. |
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 withevent.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 confusingAll of the above is for same origin and document navigations, which is the context of an SPA.
The text was updated successfully, but these errors were encountered: