Merge pull request #284 from philomena-dev/new-fp

Add version 4 TypeScript-based FP calculation
This commit is contained in:
Nighty 2024-06-15 21:44:40 +02:00 committed by GitHub
commit 19eb5be999
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 313 additions and 68 deletions

View file

@ -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 };

210
assets/js/fp.ts Normal file
View file

@ -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<Map<string, string>>
}
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<string> {
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<string> {
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`;
}

View file

@ -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();

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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,13 +16,12 @@ 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
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
def cast(_user_id, _fingerprint, _updated_at), do: nil
end
defp init do
Process.register(self(), :fingerprint_updater)

View file

@ -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}

View file

@ -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