From 8eb93f3e2419b4877dbcade41ce3aece51cbc794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Fri, 28 Apr 2023 13:50:40 +0100 Subject: [PATCH] Improve cookie access when running in iframe (#1888) --- assets/js/app.js | 87 ++++++++++++------- assets/js/lib/user.js | 10 ++- .../components/layouts/root.html.heex | 1 + lib/livebook_web/plugs/user_plug.ex | 20 ++++- 4 files changed, 81 insertions(+), 37 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index f2ff42d11..b01999ae7 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -62,44 +62,65 @@ function connect() { } // When Livebook runs in a cross-origin iframe the browser may restrict access -// to cookies. This is the case in Safari with the "Prevent cross-site tracking" -// option enabled, which is the default. Without cookies access, the session -// is not stored, so CSRF tokens are invalid. Consequently, LV keeps reloading -// the page, as we try to connect the socket with invalid token. To work around -// this we tell the user to open Livebook outside the iframe. +// to cookies. Without cookies access, the session is not stored, so CSRF tokens +// are invalid. Consequently, LV keeps reloading the page, as we try to connect +// the socket with invalid token. To work around this we tell the user to open +// Livebook outside the iframe. +// +// The behaviour varies across browsers and browsing modes (regular and private). +// A few examples (at the time of writing): +// +// * Safari by default blocks all cross-origin cookies. This is controlled by +// the "Prevent cross-site tracking" option +// +// * Chrome in incognito mode blocks all cross-origin cookies, can be relaxed +// on per-site basis +// +// * Firefox implements state partitioning (1) and it is enabled for storage +// by default since Firefox 103 (2). With storage partitioning, the embedded +// site gets a separate storage bucket scoped by the top-level origin, so +// the site generally works as expected +// +// * Brave also implements storage partitioning (3) +// +// To detect whether cookies are allowed, we check for the user data cookie, +// which should be set by the server on the initial request and is accessible +// from JavaScript (without HttpOnly). +// +// Also see the proposal (4), which may streamline this in the future. +// +// (1): https://developer.mozilla.org/en-US/docs/Web/Privacy/State_Partitioning#state_partitioning +// (2): https://www.mozilla.org/en-US/firefox/103.0/releasenotes +// (3): https://brave.com/privacy-updates/7-ephemeral-storage +// (4): https://github.com/privacycg/CHIPS -if (document.hasStorageAccess) { - document.hasStorageAccess().then((hasStorageAccess) => { - if (hasStorageAccess) { - connect(); - } else { - const overlayEl = document.createElement("div"); +if (loadUserData() === null) { + const overlayEl = document.createElement("div"); - overlayEl.innerHTML = ` -
-
-
- Action required -
-
- It looks like Livebook does not have access to cookies. This usually happens when - it runs in an iframe. To make sure the app is fully functional open it in a new - tab directly. -
- -
+ overlayEl.innerHTML = ` +
+
+
+ Action required
- `; +
+ It looks like Livebook does not have access to cookies. This usually happens when + it runs in an iframe. To make sure the app is fully functional open it in a new + tab directly. Alternatively you can relax security settings for this page to allow + third-party cookies. +
+ +
+
+ `; - overlayEl.querySelector("#open-app").href = window.location; + overlayEl.querySelector("#open-app").href = window.location; - document.body.appendChild(overlayEl); - } - }); + document.body.appendChild(overlayEl); } else { connect(); } diff --git a/assets/js/lib/user.js b/assets/js/lib/user.js index a9de85826..7929a2b82 100644 --- a/assets/js/lib/user.js +++ b/assets/js/lib/user.js @@ -38,6 +38,14 @@ function getCookieValue(key) { } function setCookie(key, value, maxAge) { - const cookie = `${key}=${value};max-age=${maxAge};path=/`; + const cookie = `${key}=${value};max-age=${maxAge};path=/${cookieOptions()}`; document.cookie = cookie; } + +function cookieOptions() { + if (document.body.hasAttribute("data-within-iframe")) { + return ";SameSite=None;Secure"; + } else { + return ";SameSite=Lax"; + } +} diff --git a/lib/livebook_web/components/layouts/root.html.heex b/lib/livebook_web/components/layouts/root.html.heex index 01625384c..a44002000 100644 --- a/lib/livebook_web/components/layouts/root.html.heex +++ b/lib/livebook_web/components/layouts/root.html.heex @@ -20,6 +20,7 @@ Enum.join(",")} + data-within-iframe={Livebook.Config.within_iframe?()} > <%= @inner_content %> diff --git a/lib/livebook_web/plugs/user_plug.ex b/lib/livebook_web/plugs/user_plug.ex index 1b51d4078..c764dcd34 100644 --- a/lib/livebook_web/plugs/user_plug.ex +++ b/lib/livebook_web/plugs/user_plug.ex @@ -45,9 +45,23 @@ defmodule LivebookWeb.UserPlug do else user_data = user_data(User.new()) encoded = user_data |> Jason.encode!() |> Base.encode64() - # Set `http_only` to `false`, so that it can be accessed on the client - # Set expiration in 5 years - put_resp_cookie(conn, "lb:user_data", encoded, http_only: false, max_age: 157_680_000) + + put_resp_cookie( + conn, + "lb:user_data", + encoded, + # We disable HttpOnly, so that it can be accessed on the client + # and set expiration to 5 years + [http_only: false, max_age: 157_680_000] ++ cookie_options() + ) + end + end + + defp cookie_options() do + if Livebook.Config.within_iframe?() do + [same_site: "None", secure: true] + else + [same_site: "Lax"] end end