mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-23 12:08:00 +01:00
Add version 4 TypeScript-based FP calculation
This commit is contained in:
parent
3f4f697afc
commit
97feb0ac00
11 changed files with 313 additions and 68 deletions
|
@ -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
210
assets/js/fp.ts
Normal 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`;
|
||||||
|
}
|
|
@ -11,7 +11,7 @@ import { setupBurgerMenu } from './burger';
|
||||||
import { bindCaptchaLinks } from './captcha';
|
import { bindCaptchaLinks } from './captcha';
|
||||||
import { setupComments } from './comment';
|
import { setupComments } from './comment';
|
||||||
import { setupDupeReports } from './duplicate_reports';
|
import { setupDupeReports } from './duplicate_reports';
|
||||||
import { setFingerprintCookie } from './fingerprint';
|
import { setSesCookie } from './fp';
|
||||||
import { setupGalleryEditing } from './galleries';
|
import { setupGalleryEditing } from './galleries';
|
||||||
import { initImagesClientside } from './imagesclientside';
|
import { initImagesClientside } from './imagesclientside';
|
||||||
import { bindImageTarget } from './image_expansion';
|
import { bindImageTarget } from './image_expansion';
|
||||||
|
@ -44,7 +44,7 @@ whenReady(() => {
|
||||||
initImagesClientside();
|
initImagesClientside();
|
||||||
setupComments();
|
setupComments();
|
||||||
setupDupeReports();
|
setupDupeReports();
|
||||||
setFingerprintCookie();
|
setSesCookie();
|
||||||
setupGalleryEditing();
|
setupGalleryEditing();
|
||||||
bindImageTarget();
|
bindImageTarget();
|
||||||
setupEvents();
|
setupEvents();
|
||||||
|
|
85
lib/philomena_web/fingerprint.ex
Normal file
85
lib/philomena_web/fingerprint.ex
Normal 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
|
|
@ -16,9 +16,7 @@ defmodule PhilomenaWeb.CurrentBanPlug do
|
||||||
@doc false
|
@doc false
|
||||||
@spec call(Conn.t(), any()) :: Conn.t()
|
@spec call(Conn.t(), any()) :: Conn.t()
|
||||||
def call(conn, _opts) do
|
def call(conn, _opts) do
|
||||||
conn = Conn.fetch_cookies(conn)
|
fingerprint = conn.assigns.fingerprint
|
||||||
|
|
||||||
fingerprint = conn.cookies["_ses"]
|
|
||||||
user = conn.assigns.current_user
|
user = conn.assigns.current_user
|
||||||
ip = conn.remote_ip
|
ip = conn.remote_ip
|
||||||
|
|
||||||
|
|
|
@ -37,9 +37,7 @@ defmodule PhilomenaWeb.FilterBannedUsersPlug do
|
||||||
defp maybe_halt_no_fingerprint(%{method: "GET"} = conn), do: conn
|
defp maybe_halt_no_fingerprint(%{method: "GET"} = conn), do: conn
|
||||||
|
|
||||||
defp maybe_halt_no_fingerprint(conn) do
|
defp maybe_halt_no_fingerprint(conn) do
|
||||||
conn = Conn.fetch_cookies(conn)
|
case conn.assigns.fingerprint do
|
||||||
|
|
||||||
case conn.cookies["_ses"] do
|
|
||||||
nil ->
|
nil ->
|
||||||
PhilomenaWeb.NotAuthorizedPlug.call(conn)
|
PhilomenaWeb.NotAuthorizedPlug.call(conn)
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ defmodule PhilomenaWeb.Router do
|
||||||
use PhilomenaWeb, :router
|
use PhilomenaWeb, :router
|
||||||
|
|
||||||
import PhilomenaWeb.UserAuth
|
import PhilomenaWeb.UserAuth
|
||||||
|
import PhilomenaWeb.Fingerprint
|
||||||
|
|
||||||
pipeline :browser do
|
pipeline :browser do
|
||||||
plug :accepts, ["html"]
|
plug :accepts, ["html"]
|
||||||
|
@ -9,6 +10,7 @@ defmodule PhilomenaWeb.Router do
|
||||||
plug :fetch_flash
|
plug :fetch_flash
|
||||||
plug :protect_from_forgery
|
plug :protect_from_forgery
|
||||||
plug :put_secure_browser_headers
|
plug :put_secure_browser_headers
|
||||||
|
plug :fetch_fingerprint
|
||||||
plug :fetch_current_user
|
plug :fetch_current_user
|
||||||
plug PhilomenaWeb.ContentSecurityPolicyPlug
|
plug PhilomenaWeb.ContentSecurityPolicyPlug
|
||||||
plug PhilomenaWeb.CurrentFilterPlug
|
plug PhilomenaWeb.CurrentFilterPlug
|
||||||
|
|
|
@ -211,9 +211,8 @@ defmodule PhilomenaWeb.UserAuth do
|
||||||
|
|
||||||
defp update_usages(conn, user) do
|
defp update_usages(conn, user) do
|
||||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
conn = fetch_cookies(conn)
|
|
||||||
|
|
||||||
UserIpUpdater.cast(user.id, conn.remote_ip, now)
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,6 +3,8 @@ defmodule PhilomenaWeb.UserFingerprintUpdater do
|
||||||
alias Philomena.Repo
|
alias Philomena.Repo
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias PhilomenaWeb.Fingerprint
|
||||||
|
|
||||||
def child_spec([]) do
|
def child_spec([]) do
|
||||||
%{
|
%{
|
||||||
id: PhilomenaWeb.UserFingerprintUpdater,
|
id: PhilomenaWeb.UserFingerprintUpdater,
|
||||||
|
@ -14,14 +16,13 @@ defmodule PhilomenaWeb.UserFingerprintUpdater do
|
||||||
{:ok, spawn_link(&init/0)}
|
{:ok, spawn_link(&init/0)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def cast(user_id, <<"c", _rest::binary>> = fingerprint, updated_at)
|
def cast(user_id, fingerprint, updated_at) do
|
||||||
when byte_size(fingerprint) <= 12 do
|
if Fingerprint.valid_format?(fingerprint) do
|
||||||
pid = Process.whereis(:fingerprint_updater)
|
pid = Process.whereis(:fingerprint_updater)
|
||||||
if pid, do: send(pid, {user_id, fingerprint, updated_at})
|
if pid, do: send(pid, {user_id, fingerprint, updated_at})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def cast(_user_id, _fingerprint, _updated_at), do: nil
|
|
||||||
|
|
||||||
defp init do
|
defp init do
|
||||||
Process.register(self(), :fingerprint_updater)
|
Process.register(self(), :fingerprint_updater)
|
||||||
run()
|
run()
|
||||||
|
|
|
@ -9,6 +9,7 @@ defmodule PhilomenaWeb.UserAuthTest do
|
||||||
conn =
|
conn =
|
||||||
conn
|
conn
|
||||||
|> Map.replace!(:secret_key_base, PhilomenaWeb.Endpoint.config(:secret_key_base))
|
|> Map.replace!(:secret_key_base, PhilomenaWeb.Endpoint.config(:secret_key_base))
|
||||||
|
|> assign(:fingerprint, "d015c342859dde3")
|
||||||
|> init_test_session(%{})
|
|> init_test_session(%{})
|
||||||
|
|
||||||
%{user: user_fixture(), conn: conn}
|
%{user: user_fixture(), conn: conn}
|
||||||
|
|
|
@ -41,9 +41,11 @@ defmodule PhilomenaWeb.ConnCase do
|
||||||
|> Philomena.Filters.change_filter()
|
|> Philomena.Filters.change_filter()
|
||||||
|> Philomena.Repo.insert!()
|
|> Philomena.Repo.insert!()
|
||||||
|
|
||||||
|
fingerprint = to_string(:io_lib.format(~c"d~14.16.0b", [:rand.uniform(2 ** 53)]))
|
||||||
|
|
||||||
conn =
|
conn =
|
||||||
Phoenix.ConnTest.build_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}
|
{:ok, conn: conn}
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue