From 97feb0ac001445e3fc32c81d14710c72e2bc516e Mon Sep 17 00:00:00 2001 From: Luna D Date: Sat, 8 Jun 2024 14:43:10 -0400 Subject: [PATCH] Add version 4 TypeScript-based FP calculation --- assets/js/fingerprint.js | 51 ----- assets/js/fp.ts | 210 ++++++++++++++++++ assets/js/when-ready.ts | 4 +- lib/philomena_web/fingerprint.ex | 85 +++++++ lib/philomena_web/plugs/current_ban_plug.ex | 4 +- .../plugs/filter_banned_users_plug.ex | 4 +- lib/philomena_web/router.ex | 2 + lib/philomena_web/user_auth.ex | 3 +- lib/philomena_web/user_fingerprint_updater.ex | 13 +- test/philomena_web/user_auth_test.exs | 1 + test/support/conn_case.ex | 4 +- 11 files changed, 313 insertions(+), 68 deletions(-) delete mode 100644 assets/js/fingerprint.js create mode 100644 assets/js/fp.ts create mode 100644 lib/philomena_web/fingerprint.ex diff --git a/assets/js/fingerprint.js b/assets/js/fingerprint.js deleted file mode 100644 index 8fadd183..00000000 --- a/assets/js/fingerprint.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Fingerprints - */ - -// http://stackoverflow.com/a/34842797 -function hashCode(str) { - return str.split('').reduce((prevHash, currVal) => - ((prevHash << 5) - prevHash) + currVal.charCodeAt(0), 0) >>> 0; -} - -function createFingerprint() { - const prints = [ - navigator.userAgent, - navigator.cpuClass, - navigator.oscpu, - navigator.platform, - - navigator.browserLanguage, - navigator.language, - navigator.systemLanguage, - navigator.userLanguage, - - screen.availLeft, - screen.availTop, - screen.availWidth, - screen.height, - screen.width, - - window.devicePixelRatio, - new Date().getTimezoneOffset(), - ]; - - return hashCode(prints.join('')); -} - -function setFingerprintCookie() { - let fingerprint; - - // The prepended 'c' acts as a crude versioning mechanism. - try { - fingerprint = `c${createFingerprint()}`; - } - // If fingerprinting fails, use fakeprint "c1836832948" as a last resort. - catch { - fingerprint = 'c1836832948'; - } - - document.cookie = `_ses=${fingerprint}; path=/; SameSite=Lax`; -} - -export { setFingerprintCookie }; diff --git a/assets/js/fp.ts b/assets/js/fp.ts new file mode 100644 index 00000000..65f0c583 --- /dev/null +++ b/assets/js/fp.ts @@ -0,0 +1,210 @@ +/** + * FP version 4 + * + * Not reliant on deprecated properties, and potentially + * more accurate at what it's supposed to do. + */ + +import store from './utils/store'; + +const storageKey = 'cached_ses_value'; + +declare global { + interface Keyboard { + getLayoutMap: () => Promise> + } + + interface UserAgentData { + brands: [{brand: string, version: string}], + mobile: boolean, + platform: string, + } + + interface Navigator { + deviceMemory: number | undefined, + keyboard: Keyboard | undefined, + userAgentData: UserAgentData | undefined, + } +} + +/** + * Creates a 53-bit non-cryptographic hash of a string. + * + * @param str The string to hash. + * @param seed The seed to use for hash generation. + * @return The resulting hash as a 53-bit number. + * @see {@link https://stackoverflow.com/a/52171480} + */ +function cyrb53(str: string, seed: number = 0x16fe7b0a): number { + let h1 = 0xdeadbeef ^ seed; + let h2 = 0x41c6ce57 ^ seed; + + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + + h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507); + h1 ^= Math.imul(h2 ^ h2 >>> 13, 3266489909); + h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507); + h2 ^= Math.imul(h1 ^ h1 >>> 13, 3266489909); + + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +} + +/** + * Get keyboard layout data from the navigator layout map. + * + * @return String containing layout map entries, or `none` when unavailable + */ +async function getKeyboardData(): Promise { + if (navigator.keyboard) { + const layoutMap = await navigator.keyboard.getLayoutMap(); + + return Array.from(layoutMap.entries()) + .sort() + .map(([k, v]) => `${k}${v}`) + .join(''); + } + + return 'none'; +} + +/** + * Get an approximation of memory available in gigabytes. + * + * @return String containing device memory data, or `1` when unavailable + */ +function getMemoryData(): string { + if (navigator.deviceMemory) { + return navigator.deviceMemory.toString(); + } + + return '1'; +} + +/** + * Get the "brands" of the user agent. + * + * For Chromium-based browsers this returns additional data like "Edge" or "Chrome" + * which may also contain additional data beyond the `userAgent` property. + * + * @return String containing brand data, or `none` when unavailable + */ +function getUserAgentBrands(): string { + const data = navigator.userAgentData; + + if (data) { + let brands = 'none'; + + if (data.brands && data.brands.length > 0) { + // NB: Chromium implements GREASE protocol to prevent ossification of + // the "Not a brand" string - see https://stackoverflow.com/a/64443187 + brands = data.brands + .filter(e => !e.brand.match(/.*ot.*rand.*/gi)) + .map(e => `${e.brand}${e.version}`) + .sort() + .join(''); + } + + return `${brands}${data.mobile}${data.platform}`; + } + + return 'none'; +} + +/** + * Get the size in rems of the default body font. + * + * Causes a forced layout. Be sure to cache this value. + * + * @return String with the rem size + */ +function getFontRemSize(): string { + const testElement = document.createElement('span'); + testElement.style.minWidth = '1rem'; + testElement.style.maxWidth = '1rem'; + testElement.style.position = 'absolute'; + + document.body.appendChild(testElement); + + const width = testElement.clientWidth.toString(); + + document.body.removeChild(testElement); + + return width; +} + +/** + * Create a semi-unique string from browser attributes. + * + * @return Hexadecimally encoded 53 bit number padded to 7 bytes. + */ +async function createFp(): Promise { + const prints: string[] = [ + navigator.userAgent, + navigator.hardwareConcurrency.toString(), + navigator.maxTouchPoints.toString(), + navigator.language, + await getKeyboardData(), + getMemoryData(), + getUserAgentBrands(), + getFontRemSize(), + + screen.height.toString(), + screen.width.toString(), + screen.colorDepth.toString(), + screen.pixelDepth.toString(), + + window.devicePixelRatio.toString(), + new Date().getTimezoneOffset().toString(), + ]; + + return cyrb53(prints.join('')) + .toString(16) + .padStart(14, '0'); +} + +/** + * Gets the existing `_ses` value from local storage or cookies. + * + * @return String `_ses` value or `null` + */ +function getSesValue(): string | null { + // Try storage + const storageValue: string | null = store.get(storageKey); + if (storageValue) { + return storageValue; + } + + // Try cookie + const match = document.cookie.match(/_ses=([a-f0-9]+)/); + if (match && match[1]) { + return match[1]; + } + + // Not found + return null; +} + +/** + * Sets the `_ses` cookie. + * + * If `cached_ses_value` is present in local storage, uses it to set the `_ses` cookie. + * Otherwise, if the `_ses` cookie already exists, uses its value instead. + * Otherwise, attempts to generate a new value for the `_ses` cookie based on + * various browser attributes. + * Failing all previous methods, sets the `_ses` cookie to a fallback value. + */ +export async function setSesCookie() { + let sesValue = getSesValue(); + + if (!sesValue || sesValue.charAt(0) !== 'd' || sesValue.length !== 15) { + // The prepended 'd' acts as a crude versioning mechanism. + sesValue = `d${await createFp()}`; + store.set(storageKey, sesValue); + } + + document.cookie = `_ses=${sesValue}; path=/; SameSite=Lax`; +} diff --git a/assets/js/when-ready.ts b/assets/js/when-ready.ts index 3bd3e049..efefea64 100644 --- a/assets/js/when-ready.ts +++ b/assets/js/when-ready.ts @@ -11,7 +11,7 @@ import { setupBurgerMenu } from './burger'; import { bindCaptchaLinks } from './captcha'; import { setupComments } from './comment'; import { setupDupeReports } from './duplicate_reports'; -import { setFingerprintCookie } from './fingerprint'; +import { setSesCookie } from './fp'; import { setupGalleryEditing } from './galleries'; import { initImagesClientside } from './imagesclientside'; import { bindImageTarget } from './image_expansion'; @@ -44,7 +44,7 @@ whenReady(() => { initImagesClientside(); setupComments(); setupDupeReports(); - setFingerprintCookie(); + setSesCookie(); setupGalleryEditing(); bindImageTarget(); setupEvents(); diff --git a/lib/philomena_web/fingerprint.ex b/lib/philomena_web/fingerprint.ex new file mode 100644 index 00000000..99d8fadd --- /dev/null +++ b/lib/philomena_web/fingerprint.ex @@ -0,0 +1,85 @@ +defmodule PhilomenaWeb.Fingerprint do + import Plug.Conn + + @type t :: String.t() + @name "_ses" + + @doc """ + Assign the current fingerprint to the conn. + """ + @spec fetch_fingerprint(Plug.Conn.t(), any()) :: Plug.Conn.t() + def fetch_fingerprint(conn, _opts) do + conn = + conn + |> fetch_session() + |> fetch_cookies() + + # Try to get the fingerprint from the session, then from the cookie. + fingerprint = upgrade(get_session(conn, @name), conn.cookies[@name]) + + # If the fingerprint is valid, persist to session. + case valid_format?(fingerprint) do + true -> + conn + |> put_session(@name, fingerprint) + |> assign(:fingerprint, fingerprint) + + false -> + assign(conn, :fingerprint, nil) + end + end + + defp upgrade(<<"c", _::binary>> = session_value, <<"d", _::binary>> = cookie_value) do + if valid_format?(cookie_value) do + # When both fingerprint values are valid and the session value + # is an old version, use the cookie value. + cookie_value + else + # Use the session value. + session_value + end + end + + defp upgrade(session_value, cookie_value) do + # Prefer the session value, using the cookie value if it is unavailable. + session_value || cookie_value + end + + @doc """ + Determine whether the fingerprint corresponds to a valid format. + + Valid formats start with `c` or `d` (for the version). The `c` format is a legacy format + corresponding to an integer-valued hash from the frontend. The `d` format is the current + format corresponding to a hex-valued hash from the frontend. By design, it is not + possible to infer anything else about these values from the server. + + See assets/js/fp.ts for additional information on the generation of the `d` format. + + ## Examples + + iex> valid_format?("b2502085657") + false + + iex> valid_format?("c637334158") + true + + iex> valid_format?("d63c4581f8cf58d") + true + + iex> valid_format?("5162549b16e8448") + false + + """ + @spec valid_format?(any()) :: boolean() + def valid_format?(fingerprint) + + def valid_format?(<<"c", rest::binary>>) when byte_size(rest) <= 12 do + match?({_result, ""}, Integer.parse(rest)) + end + + def valid_format?(<<"d", rest::binary>>) when byte_size(rest) == 14 do + match?({:ok, _result}, Base.decode16(rest, case: :lower)) + end + + def valid_format?(_fingerprint), do: false +end diff --git a/lib/philomena_web/plugs/current_ban_plug.ex b/lib/philomena_web/plugs/current_ban_plug.ex index 0924b858..273a7889 100644 --- a/lib/philomena_web/plugs/current_ban_plug.ex +++ b/lib/philomena_web/plugs/current_ban_plug.ex @@ -16,9 +16,7 @@ defmodule PhilomenaWeb.CurrentBanPlug do @doc false @spec call(Conn.t(), any()) :: Conn.t() def call(conn, _opts) do - conn = Conn.fetch_cookies(conn) - - fingerprint = conn.cookies["_ses"] + fingerprint = conn.assigns.fingerprint user = conn.assigns.current_user ip = conn.remote_ip diff --git a/lib/philomena_web/plugs/filter_banned_users_plug.ex b/lib/philomena_web/plugs/filter_banned_users_plug.ex index 5b5c440d..866bde43 100644 --- a/lib/philomena_web/plugs/filter_banned_users_plug.ex +++ b/lib/philomena_web/plugs/filter_banned_users_plug.ex @@ -37,9 +37,7 @@ defmodule PhilomenaWeb.FilterBannedUsersPlug do defp maybe_halt_no_fingerprint(%{method: "GET"} = conn), do: conn defp maybe_halt_no_fingerprint(conn) do - conn = Conn.fetch_cookies(conn) - - case conn.cookies["_ses"] do + case conn.assigns.fingerprint do nil -> PhilomenaWeb.NotAuthorizedPlug.call(conn) diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 8415b112..7a89f4b1 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -2,6 +2,7 @@ defmodule PhilomenaWeb.Router do use PhilomenaWeb, :router import PhilomenaWeb.UserAuth + import PhilomenaWeb.Fingerprint pipeline :browser do plug :accepts, ["html"] @@ -9,6 +10,7 @@ defmodule PhilomenaWeb.Router do plug :fetch_flash plug :protect_from_forgery plug :put_secure_browser_headers + plug :fetch_fingerprint plug :fetch_current_user plug PhilomenaWeb.ContentSecurityPolicyPlug plug PhilomenaWeb.CurrentFilterPlug diff --git a/lib/philomena_web/user_auth.ex b/lib/philomena_web/user_auth.ex index 19a4cdbc..00a9e94e 100644 --- a/lib/philomena_web/user_auth.ex +++ b/lib/philomena_web/user_auth.ex @@ -211,9 +211,8 @@ defmodule PhilomenaWeb.UserAuth do defp update_usages(conn, user) do now = DateTime.utc_now() |> DateTime.truncate(:second) - conn = fetch_cookies(conn) UserIpUpdater.cast(user.id, conn.remote_ip, now) - UserFingerprintUpdater.cast(user.id, conn.cookies["_ses"], now) + UserFingerprintUpdater.cast(user.id, conn.assigns.fingerprint, now) end end diff --git a/lib/philomena_web/user_fingerprint_updater.ex b/lib/philomena_web/user_fingerprint_updater.ex index 62e14270..41863dcf 100644 --- a/lib/philomena_web/user_fingerprint_updater.ex +++ b/lib/philomena_web/user_fingerprint_updater.ex @@ -3,6 +3,8 @@ defmodule PhilomenaWeb.UserFingerprintUpdater do alias Philomena.Repo import Ecto.Query + alias PhilomenaWeb.Fingerprint + def child_spec([]) do %{ id: PhilomenaWeb.UserFingerprintUpdater, @@ -14,14 +16,13 @@ defmodule PhilomenaWeb.UserFingerprintUpdater do {:ok, spawn_link(&init/0)} end - def cast(user_id, <<"c", _rest::binary>> = fingerprint, updated_at) - when byte_size(fingerprint) <= 12 do - pid = Process.whereis(:fingerprint_updater) - if pid, do: send(pid, {user_id, fingerprint, updated_at}) + def cast(user_id, fingerprint, updated_at) do + if Fingerprint.valid_format?(fingerprint) do + pid = Process.whereis(:fingerprint_updater) + if pid, do: send(pid, {user_id, fingerprint, updated_at}) + end end - def cast(_user_id, _fingerprint, _updated_at), do: nil - defp init do Process.register(self(), :fingerprint_updater) run() diff --git a/test/philomena_web/user_auth_test.exs b/test/philomena_web/user_auth_test.exs index deeaeca7..3465111a 100644 --- a/test/philomena_web/user_auth_test.exs +++ b/test/philomena_web/user_auth_test.exs @@ -9,6 +9,7 @@ defmodule PhilomenaWeb.UserAuthTest do conn = conn |> Map.replace!(:secret_key_base, PhilomenaWeb.Endpoint.config(:secret_key_base)) + |> assign(:fingerprint, "d015c342859dde3") |> init_test_session(%{}) %{user: user_fixture(), conn: conn} diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index aa9df751..dd4240a4 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -41,9 +41,11 @@ defmodule PhilomenaWeb.ConnCase do |> Philomena.Filters.change_filter() |> Philomena.Repo.insert!() + fingerprint = to_string(:io_lib.format(~c"d~14.16.0b", [:rand.uniform(2 ** 53)])) + conn = Phoenix.ConnTest.build_conn() - |> Phoenix.ConnTest.put_req_cookie("_ses", Integer.to_string(System.unique_integer())) + |> Phoenix.ConnTest.put_req_cookie("_ses", fingerprint) {:ok, conn: conn} end