mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-23 20:18:00 +01:00
Merge pull request #284 from philomena-dev/new-fp
Add version 4 TypeScript-based FP calculation
This commit is contained in:
commit
19eb5be999
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 { 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();
|
||||
|
|
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
|
||||
@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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue