Evercookie представляет разработчику идентифицировать пользователя, если тот удалил куки. Основная цель evercookie — это записать во все доступные хранилища браузера уникальный идентификатор. Если идентификатор удаляется из какого либо хранилища, то значение восстанавливается с помощью чтения значения из другого хранилища.
Реализацию evercookie можно глянуть в репозитории на github — https://github.com/samyk/evercookie.
Принцип работы evercookie по шагам:
Получение уникального идентификатора (UID) из доступных хранилищ.
Если при получении идентификаторов были получены разные значения,то тогда выбирается лучшее значение из доступных (максимум из найденных значений).
Если UID не найден, то тогда пользователю нужно присвоить уникальный идентификатор. Так как кэширование в некоторых механизмах подразумевает кодирование значения, то для полноты работы желательно использовать числовой идентификатор, который проще закодировать.
Механизмы хранения данных
Решение построено на семнадцати механизмах хранения.
Стандартные cookie‑файлы HTTP
HTTP Strict Transport Security (HSTS)
Локальные общие объекты (Flash cookies)
Изолированное хранилище Silverlight
Хранение cookie‑файлов, закодированных в значениях RGB автоматически сгенерированных, принудительно кэшированных изображений PNG с использованием тега HTML5 Canvas для обратного считывания пикселей (файлов cookie).
Сохранение cookie‑файлов в истории поиска
Хранение cookie‑файлов в HTTP ETag
Сохранение cookie‑файлов в веб‑кэше
Кэширование window.name
Хранилище пользовательских данных Internet Explorer
Веб‑хранилище сеансов HTML5
Локальное веб‑хранилище HTML5
Глобальное хранилище HTML5
База данных HTML5 Web SQL через SQLite
HTML5 IndexedDB
Java JNLP PersistenceService
Эксплойт Java CVE-2013–0422
Список взят с Википедии и сравнен с приведенными механизмами.
Под вопросом пункт - сохранение cookie‑файлов в веб‑кэше. Если речь идет про basic authorization, то тогда все хорошо.
Краткое описание используемых механизмов с объяснением применения механизма кэширования.
Стандартные cookie-файлы HTTP
HTTP-куки — это классические куки браузера. Большинство сайтов используют куки для сессий клиентов. Пользователь посещает сайт и ему сервер устанавливает в куки UID, по которому в дальнейшем сервер будет понимать, что за клиент запрашивает данные.
HTTP Strict Transport Security (HSTS)
HTTP Strict Transport Security — это специальный механизм для определения использования защищенного соединения. Результат использования хранится в браузере и связан с конкретным доменом и поддоменами. Механизм использует набор доменов, каждый их которых может хранить «да» или «нет». Формально один домен это один бит, если взять 32 домена, то этого достаточно, чтобы закодировать int.
Наример, если пользователю присвоить значение равное 21, то в двоичной системе это будет: 00 000 000 000 000 000 000 000 000 010 101.
Теперь на каждый из доменов отправляется значение 0 или 1, где сервер себе сохраняет работает ли HSTS
для данного домена. После удаления данных из хранилищ, можно запросить у каждого из доменов нужно ли использовать https
. И преобразовав обратно в число из двоичной системы можно получить стертый идентификатор.
Локальные общие объекты (Flash cookies)
Adobe Flash - одна из технологий создания анимаций в браузере. Флеш можно запустить на современном браузере, но из‑за своей ограниченности этого уже никто не делает. Механизм восстановления заключается в использовании кеша flash. Наверное, это единственное, трудно удаляемое значение, так как его сложно сбросить в браузере.
Стоит проверить работоспособность решения. Возможно этот механизм работает до сих пор.
Я отказался от хранения данных во флеше, так как все современные браузеры будут блокировать его. А обходные решения для запуска выглядят сомнительными.
Изолированное хранилище Silverlight
Silverlight — это аналог flash от Microsoft. Механизм восстановления аналогичен флешу, где просто используется системный кеш.
Решение может работать до сих пор, однако количество пользователей с работающим
silverlight
стремится к нулю.
Хранение cookie‑файлов, закодированных в значениях RGB автоматически сгенерированных, принудительно кэшированных изображений PNG с использованием тега HTML5 Canvas для обратного считывания пикселей (файлов cookie).
Механизм построен на генерации изображения и его кешировании. При запросе изображения, идет обратное декодирование и таким образом можно восстановить стертый UID.
Сохранение cookie-файлов в истории поиска
Метод построенный на определении посещенных сайтов. Раньше браузер позволял понять, посещен ли сайт ранее с помощью псевдокласса :visited
в CSS. В настоящий момент, Javascript не имеет доступа к данному псевдоклассу элемента ссылки.
Хранение cookie-файлов в HTTP ETag
Etag — это заголовок, который указывает версию ресурса. Механизм заключается в присваивании UID версии etag.
Сохранение cookie‑файлов в веб‑кэше (Basic Authentication cache)
Раньше при базовой аутентификации можно было записать кеш. Механизм строился на записи изображения на сервер. Для восстановления была попытка загрузки изображения с сервера, где требовалась авторизация.
Я лично сам не сталкивался с подобным и в поздних коммитах эту функциональность выключили.
Кэширование window.name
У глобального объекта Window есть записываемое свойство — name
, которое задает имя окна. Обычно с помощью этого свойства передавали данные между окнами.
Особенность записи в window.name
- это возможность сохранять значение даже при очистке хранилищ.
Например, если в браузере открыть панель разработчика и перейти во вкладку данных и очистить их и не закрывать текущую страницу, то имя окна не будет сброшено, а значит evercookie будет восстановлена и записана снова везде.
Хранилище пользовательских данных Internet Explorer
Механизм записи данных в IE. Возможно это работает до сих пор, но использование IE уменьшается, и скоро совсем исчезнет.
Метод построен на создании div и записи UID. При восстановлении можно прочитать установленное значение из кеша.
Так как это работает только в IE, остается надеяться, что все так и есть. Я не углублялся в данную технику.
Веб-хранилище сеансов HTML5
SessionStorage — хранилище для данных сессии. Автоматически очищается после завершении сессии.
Механизм построен просто на записи ключа в хранилище и его чтении.
Работает как часы.
Локальное веб-хранилище HTML5
LocalStorage — это хранилище для локальных данных. Данные хранятся пока не будут очищены вручную. Механизм построен на записи ключа в хранилище и его чтении.
Глобальное хранилище HTML5
Кроссдоменное хранилище, которое раньше было доступно для записи и чтения. В настоящий момент не доступно.
Механизм работы аналогичен LocalStorage
и SessionStorage
.
База данных HTML5 Web SQL через SQLite
Web SQL — локальная база SQLite данных доступная в браузере.
Механизм работы заключается в создании новой таблицы и записи туда UID. Так как стандарт больше не развивается, то скоро браузеры прекратят его поддержку.
HTML5 IndexedDB
IndexedDB — это замена Web SQL.
Механизм работы такой же как и при Web SQL. Сначала создается таблица, а затем записывается UID.
Java JNLP PersistenceService
PersistenceService — это сервис, который позволяет сохранять данные в кеше с помощью java.
Механизм использует технологию Java Applet, которая умерла вместе с Flash и Silverlight.
Она часто требует установки дополнительных плагинов, ждет запуска виртуальной машины. Использование в современном вебе сомнительно.
Эксплойт Java CVE-2013-0422
Эксплойт, который позволяет выполнять любой код из‑за неизвестной ошибки. Применимо к старым версиям Java.
В итоге получается актуальный на 2023 год список работающих механизмов:
+/- |
Метод |
+ |
Стандартные cookie-файлы HTTP |
+ |
HSTS |
- |
Локальные общие объекты* |
- |
Изолированное хранилище Silverlight* |
+ |
Хранение cookie-файлов, закодированных в значениях RGB автоматически сгенерированных, принудительно кэшированных |
- |
Сохранение cookie-файлов в истории поиска |
+ |
Хранение cookie-файлов в HTTP ETag |
- |
Сохранение cookie-файлов в веб-кэше (Basic Authentication cache) |
+ |
Кэширование window.name |
- |
Хранилище пользовательских данных Internet Explorer* |
+ |
Веб-хранилище сеансов HTML5 |
+ |
Локальное веб-хранилище HTML5 |
- |
Глобальное хранилище HTML5 |
+ |
База данных HTML5 Web SQL через SQLite |
+ |
HTML5 IndexedDB |
- |
Java JNLP PersistenceService* |
- |
Эксплойт Java CVE-2013-0422* |
Все механизмы помеченные звездочкой скорее не работают, чем работают.
Обычно это очень старые браузеры, причем настольные.
Единственное под вопросом HSTS. Потенциально, все должно работать, но стоит это проверить.
Работает ли это все в 2023 году
Если говорить о доступных средствах, то большая часть старых уязвимостей была исправлена в современных браузерах.
Если вы хотите создать новую сессию, достаточно открыть новую вкладку в инкогнито. Текущая реализация evercookie не сможет восстановить значение.
Для чего тогда стоит использовать evercookie?
Evercookie хорошо работает для обычных пользователей, которые не чистят историю, не удаляют куки. Это нужно чтобы связать конкретные устройства с пользователем. Для этих целей эверкука подходит идеально.
Другими словами это не работает как механизм вечных кук?
Есть другие инструменты, для определения пользователей - фингерпринтинг. Одна из техник это определение установленных шрифтов в браузере.
Однако, стоит учитывать, что все фингерпринты не уникальны. Если взять два новых iphone, одной серии и модели и запустить на них фингерпринты, то они совпадут с 99.99% вероятностью.
Альтернативные способы
У читателя может появиться вопрос, а появились ли какие-то новые способы кэширования?
Из последних - это supercookie построенная на фавиконах. Проект можно посмотреть тут - https://github.com/jonasstrehle/supercookie.
Утверждается, что это работает на chrome 111.
Подробнее можно прочитать в статье ITSumma - Эффективный фингерпринтинг через кэш фавиконов в браузере.
Также есть issue в проекте эверкуки https://github.com/samyk/evercookie/issues/136, в которой утверждается что можно использовать кэш TLS сессии.
Мне неизвестно работает это или нет. Нужно исследовать.
Зачем использовать Evercookie
Как говорилось раньше, эверкука позволяет восстанавливать сессию пользователя даже после очистки им данных и удаления кук.
В основном это необходимо для создания антифрод системы. Обычный пользователь не будет удалять свои данные, а вот мошенник только и пытается замести следы.
Если клиент будет регистрироваться на сайте с одной и той же evercookie, то в дальнейшем можно будет связать все эти регистрации в единое целое и что‑то с этим сделать.
Структура Evercookie
Структура проекта в репозитории:
├── assets
│ ├── EvercookieCacheServlet.java
│ ├── EvercookieEtagServlet.java
│ ├── evercookie.fla
│ ├── evercookie.jar
│ ├── evercookie.jnlp
│ ├── EvercookiePngServlet.java
│ ├── evercookie_sl
│ │ ├── evercookie
│ │ │ ├── App.xaml
│ │ │ ├── App.xaml.cs
│ │ │ ├── Bin
│ │ │ │ └── Debug
│ │ │ │ ├── AppManifest.xaml
│ │ │ │ ├── evercookie.dll
│ │ │ │ ├── evercookie.pdb
│ │ │ │ ├── evercookieTestPage.html
│ │ │ │ └── evercookie.xap
│ │ │ ├── evercookie.csproj
│ │ │ ├── evercookie.csproj.user
│ │ │ ├── MainPage.xaml
│ │ │ ├── MainPage.xaml.cs
│ │ │ ├── obj
│ │ │ │ └── Debug
│ │ │ │ ├── App.g.cs
│ │ │ │ ├── App.g.i.cs
│ │ │ │ ├── DesignTimeResolveAssemblyReferences.cache
│ │ │ │ ├── DesignTimeResolveAssemblyReferencesInput.cache
│ │ │ │ ├── evercookie.csproj.FileListAbsolute.txt
│ │ │ │ ├── evercookie.dll
│ │ │ │ ├── evercookie.g.resources
│ │ │ │ ├── evercookie.pdb
│ │ │ │ ├── MainPage.g.cs
│ │ │ │ ├── MainPage.g.i.cs
│ │ │ │ └── XapCacheFile.xml
│ │ │ └── Properties
│ │ │ ├── AppManifest.xml
│ │ │ └── AssemblyInfo.cs
│ │ ├── evercookie.sln
│ │ └── evercookie.suo
│ ├── evercookie.swf
│ └── evercookie.xap
├── ChangeLog
├── css
│ └── master.css
├── index.html
├── js
│ ├── evercookie.js
│ └── swfobject-2.2.min.js
├── php
│ ├── _cookie_name.php
│ ├── evercookie_cache.php
│ ├── evercookie_etag.php
│ ├── evercookie_png.php
│ └── hsts_cookie.php
└── README.md
Так как Java
, Flash
и Silverlight
выбыли, интересными остаются только следующие файлы:
├── js
│ └── evercookie.js
└── php
├── _cookie_name.php
├── evercookie_cache.php
├── evercookie_etag.php
├── evercookie_png.php
└── hsts_cookie.php
Реализация Evercookie
Исходный код evercookie выглядит следующим образом:
this.evercookie_cache = function (name, value) {
if (value !== undefined) {
// make sure we have evercookie session defined first
document.cookie = opts.cacheCookieName + "=" + value + "; path=/; domain=" + _ec_domain;
// {{ajax request to opts.cachePath}} handles caching
self.ajax({
url: _ec_baseurl + _ec_phpuri + opts.cachePath + "?name=" + name + "&cookie=" + opts.cacheCookieName,
success: function (data) {}
});
} else {
// interestingly enough, we want to erase our evercookie
// http cookie so the php will force a cached response
var origvalue = this.getFromStr(opts.cacheCookieName, document.cookie);
self._ec.cacheData = undefined;
document.cookie = opts.cacheCookieName + "=; expires=Mon, 20 Sep 2010 00:00:00 UTC; path=/; domain=" + _ec_domain;
self.ajax({
url: _ec_baseurl + _ec_phpuri + opts.cachePath + "?name=" + name + "&cookie=" + opts.cacheCookieName,
success: function (data) {
// put our cookie back
document.cookie = opts.cacheCookieName + "=" + origvalue + "; expires=Tue, 31 Dec 2030 00:00:00 UTC; path=/; domain=" + _ec_domain;
self._ec.cacheData = data;
}
});
}
};
Полный код тут: https://github.com/samyk/evercookie/blob/master/js/evercookie.js
Код немного запутан, когда видишь его в первый раз, но потом приходит его понимание.
Я долго обходил стороной рефакторинг evercookie, так как она работала. Но все больше ее частей становится неактуальной, начали появляться проблемы с CORS. Так как один из моих Angular проектов активно использует evercookie, то я решил переписать evercookie на новый стек.
Сначала определим интерфейс, который будет хранить параметры evercookie:
/**
* Evercookie options
*/
export interface EvercookieOptions {
/**
* Cookie name for HTTP cookie
*
* default: `evercookie_cache`
*/
cacheCookieName: string;
/**
* Path to script cache
*
* default: `/cache.php`
*/
cachePath: string;
/**
* Cookie name for PNG
*
* default: `evercookie_png`
*/
pngCookieName: string;
/**
* Path to script png
*
* default: `/png.php`
*/
pngPath: string;
/**
* Cookie name for ETag
*
* default: `evercookie_etag`
*/
etagCookieName: string;
/**
* Path to script etag
*
* default: `/etag.php`
*/
etagPath: string;
/**
* Path to backend
*
* example: `https://your-evercookie-server.com`
*/
baseurl: string;
/**
* Domain from window.location.host
*
* example: `.your-site-with-evercookie.com`
*/
domain: string;
/**
* Path to PHP folder in backend
*
* default: `/php`
*/
phpuri: string;
/**
* Name evercookie in storages
*
* default: `uid`
*/
name: string;
/**
* Past date
*
* default: `'Thu, 01 Jun 2000 00:00:00 GMT'`
*/
expiresPast: string;
/**
* Expires date
*
* default: `'Thu, 01 Jun 2000 00:00:00 GMT'`
*/
expires: string;
}
cacheCookieName
- имя Cache куки;cachePath
- путь до скрипта с Cache;pngCookieName
- имя PNG куки;pngPath
- путь до скрипта с PNG;etagCookieName
- имя ETag куки;etagPath
- путь до скрипта ETag;phpuri
- путь до папки со скриптами PHP;baseurl
- путь до бекенда, где выложен проект evercookie;domain
- домен, на который будут ставиться куки. Домен по-умолчанию берется как '.' + window.location.host
;name
- имя ключа, который будет использован при записи в хранилище;expiresPast
- прошедшая дата, для сброса куки;expires
- дата, до которой будет действовать кука.
Создадим сервис, который будет читать и записывать evercookie с дефолтными опциями:
export class EvercookieService {
private readonly options: EvercookieOptions = {
cacheCookieName: 'evercookie_cache',
pngCookieName: 'evercookie_png',
etagCookieName: 'evercookie_etag',
etagPath: '/etag.php',
baseurl: 'http://localhost:8080/evercookie',
domain: '.',
phpuri: '/php',
pngPath: '/png.php',
cachePath: '/cache.php',
name: 'uid',
expiresPast: 'Thu, 01 Jun 2000 00:00:00 GMT',
expires: 'Sat, 01 Jun 2030 00:00:00 GMT',
};
readonly isBrowser!: boolean;
constructor(
private readonly destroyRef: DestroyRef,
private readonly httpClient: HttpClient,
@Inject(DOCUMENT) private readonly document: Document,
// eslint-disable-next-line @typescript-eslint/ban-types
@Inject(PLATFORM_ID) private readonly platformId: Object
) {
this.isBrowser = isPlatformBrowser(this.platformId);
if (this.isBrowser && this.window) {
this.options.domain = `.${this.window?.location.host.replace(/:\d+/, '')}`;
}
}
get window() {
return this.document.defaultView;
}
}
Для создания запросов используем стандартный fetch, но с выключенными CORS:
fetch(url, {
method: 'GET',
mode: 'no-cors',
headers: {
Accept: 'text/javascript, text/html, application/xml, text/xml, */*',
},
});
Так как Angular построен на Observable, то метод примет вид:
private request(url: string): Observable<string> {
return from(
fetch(url, {
method: 'GET',
mode: 'no-cors',
headers: {
Accept: 'text/javascript, text/html, application/xml, text/xml, */*',
},
})
).pipe(
switchMap((response) => from(response.text())),
map((result) => result ?? undefined),
takeUntilDestroyed(this.destroyRef)
);
}
Для парсинга ответов и значений понадобятся две утилиты, которые просто взяты из реализации evercookie:
export function replace(str: string, key: string, value: string | number): string {
if (str.indexOf('&' + key + '=') > -1 || str.indexOf(key + '=') === 0) {
// find start
let idx = str.indexOf('&' + key + '=');
if (idx === -1) {
idx = str.indexOf(key + '=');
}
// find end
const end = str.indexOf('&', idx + 1);
return end !== -1 ? `${str.substr(0, idx)}${str.substr(end + (idx ? 0 : 1))}&${key}=${value}` : `${str.substr(0, idx)}&${key}=${value}`;
}
return `${str}&${key}=${value}`;
}
export function getFromStr(name: string, text: unknown) {
if (typeof text !== 'string') {
return;
}
const nameEQ = name + '=';
const ca = text.split(/[;&]/);
let c;
for (let i = 0; i < ca.length; i++) {
c = ca[i];
while (c.charAt(0) === ' ') {
c = c.substring(1, c.length);
}
if (c.indexOf(nameEQ) === 0) {
return c.substring(nameEQ.length, c.length);
}
}
return;
}
Добавим типы для Window:
declare global {
interface Window {
readonly mozIndexedDB?: IDBFactory;
readonly webkitIndexedDB?: IDBFactory;
readonly msIndexedDB?: IDBFactory;
readonly openDatabase?: (
name: string,
version: string,
displayName: string,
estimatedSize: number
) => {
transaction(
callback: (arg: {
executeSql: (sql: string, params?: any[], cb?: (tx: any, cb: any) => void, eh?: (err: any) => void) => void;
}) => void
): void;
};
}
}
mozIndexedDB
,webkitIndexedDB
,msIndexedDB
- платформенные реализации IndexedDB;openDatabase
- метод доступа к Web SQL.
Реализация кэширование window.name
Для установки значения достатоно записать в window.name нужное значение.
private setWindowName(value: string | number): void {
window.name = replace(window.name, this.options.name, value);
}
Для чтения просто взять значение window.name.
private getWindowName(): number | undefined {
const value = getFromStr(this.options.name, window.name);
return typeof value !== 'undefined' ? Number(value) : undefined;
}
Angular:
private getWindowName(): Observable<number | undefined> {
return new Observable((observer) => {
try {
const eid = getFromStr(this.options.name, this.window?.name);
observer.next(typeof eid !== 'undefined' ? Number(eid) : undefined);
} catch (e) {
observer.next(undefined);
}
observer.complete();
});
}
private setWindowName(value: string | number): void {
try {
if (this.window) {
this.window.name = replace(this.window.name, this.options.name, value);
}
} catch (e) {
/* empty */
}
}
Так как не все методы чтения выполняются синхронно, то можно все методы оборачиваются в Observable.
Для корректного завершения потоков, все операции выполняются в try‑catch.
Стандартные cookie‑файлы HTTP
Установка куки производится просто записью в document.cookie.
private setCache(value: string | number): void {
document.cookie = `${this.options.cacheCookieName}=${value}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;
void fetch(`${this.options.baseurl}${this.options.phpuri}${this.options.cachePath}?name=${this.options.name}&cookie=${this.options.cacheCookieName}`);
}
Чтение куки:
private async getCache(): Promise<number | undefined> {
const origvalue = getFromStr(this.options.cacheCookieName, this.document.cookie);
// reset cookie
this.document.cookie = `${this.options.cacheCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;
return fetch(
`${this.options.baseurl}${this.options.phpuri}${this.options.cachePath}?name=${this.options.name}&cookie=${this.options.cacheCookieName}`
).then((response) => {
this.document.cookie = `${this.options.cacheCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;
return response.text();
}).then(result => result !== '' ? Number(result) : undefined);
}
Сначала стирается предыдущее значение куки.
Затем выполняется запрос куки.
После получения ответа сервера, восстанавливается предыдущее значение и метод возвращает ответ от сервера.
Зачем нужно восстанавливать предыдущее значение я не понял. Возможно когда все методы отработают, есть шанс что данное значение было лучшим и его нужно будет восстановить. Если сервер вернет undefined и кука будет перезатерта, то после рефреша не получиться ничего восстановить.
Angular реализация:
private getCache(): Observable<number | undefined> {
return new Observable((observer) => {
// interestingly enough, we want to erase our evercookie
// http cookie so the php will force a cached response
const origvalue = getFromStr(this.options.cacheCookieName, this.document.cookie);
this.document.cookie = `${this.options.cacheCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;
this.request(
`${this.options.baseurl}${this.options.phpuri}${this.options.cachePath}?name=${this.options.name}&cookie=${this.options.cacheCookieName}`
)
.pipe(
tap((response) => {
this.document.cookie = `${this.options.cacheCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;
observer.next(response !== '' ? Number(response) : undefined);
observer.complete();
}),
catchError(() => {
observer.next(undefined);
observer.complete();
return EMPTY;
})
)
.subscribe();
});
}
private setCache(value: string | number): void {
// eslint-disable-next-line max-len
this.document.cookie = `${this.options.cacheCookieName}=${value}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;
this.request(
// eslint-disable-next-line max-len
`${this.options.baseurl}${this.options.phpuri}${this.options.cachePath}?name=${this.options.name}&cookie=${this.options.cacheCookieName}`
)
.pipe(catchError(() => EMPTY))
.subscribe();
}
Реализация серверной части на PHP:
<?php
/* evercookie, by samy kamkar, 09/20/2010
* http://samy.pl : code@samy.pl
*
* This is the server-side simple caching mechanism.
*
* -samy kamkar
*/
// we get cookie name from current file name so remember about it when rename of this file will be required
include dirname(__FILE__) . DIRECTORY_SEPARATOR . '_cookie_name.php';
$cookie_name = evercookie_get_cookie_name(__FILE__);
// we don't have a cookie, user probably deleted it, force cache
if (empty($_COOKIE[$cookie_name])) {
header('HTTP/1.1 304 Not Modified');
exit;
}
header('Content-Type: text/html');
header('Last-Modified: Wed, 30 Jun 2010 21:36:48 GMT');
header('Expires: Tue, 31 Dec 2030 23:30:45 GMT');
header('Cache-Control: private, max-age=630720000');
echo $_COOKIE[$cookie_name];
Как видно из реализации, то просто читается кука и записывается в ответ с необходимыми заголовками.
Хранение cookie‑файлов, закодированных в значениях RGB
Установка значения:
private setPng(value: string | number): void {
try {
this.document.cookie = this.options.pngCookieName + '=' + value + '; path=/; domain=' + this.options.domain;
const img = new Image();
img.style.visibility = 'hidden';
img.style.position = 'absolute';
img.src = `${this.options.baseurl}${this.options.phpuri}${this.options.pngPath}?name=${this.options.name}&cookie=${this.options.pngCookieName}`;
img.crossOrigin = 'Anonymous';
} catch (e) {
/* empty */
}
}
Суть метода в том, чтобы запросить необходимое изображение, которое будет в дальнейшем закешировано.
Механизм получения куки построен на создании карты с закодированными цветами. Перебирая каждый пиксель можно получить закодированный UID.
По шагам:
Создается новый canvas.
Сбрасывается кука.
Создается новый контекст.
Отрисовывается изображение.
Декодируем каждый пиксель.
private getPng(): Observable<number | undefined> {
return new Observable((observer) => {
const canvas = this.document.createElement('canvas');
canvas.style.visibility = 'hidden';
canvas.style.position = 'absolute';
canvas.width = 200;
canvas.height = 1;
if (canvas && canvas.getContext) {
const img = new Image();
img.style.visibility = 'hidden';
img.style.position = 'absolute';
const ctx = canvas.getContext('2d');
const origvalue = getFromStr(this.options.pngCookieName, this.document.cookie);
this.document.cookie = `${this.options.pngCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;
img.onload = () => {
this.document.cookie = `${this.options.pngCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;
let png = '';
if (ctx) {
ctx.drawImage(img, 0, 0);
// get CanvasPixelArray from given coordinates and dimensions
const pix = ctx.getImageData(0, 0, 200, 1).data;
// loop over each pixel to get the "RGB" values (ignore alpha)
for (let i = 0, n = pix.length; i < n; i += 4) {
if (pix[i] === 0) {
break;
}
png += String.fromCharCode(pix[i]);
if (pix[i + 1] === 0) {
break;
}
png += String.fromCharCode(pix[i + 1]);
if (pix[i + 2] === 0) {
break;
}
png += String.fromCharCode(pix[i + 2]);
}
}
observer.next(png !== '' ? Number(png) : undefined);
observer.complete();
};
img.onerror = () => {
observer.next(undefined);
observer.complete();
};
img.src = `${this.options.baseurl}${this.options.phpuri}${this.options.pngPath}?name=${this.options.name}&cookie=${this.options.pngCookieName}`;
img.crossOrigin = 'Anonymous';
} else {
observer.next(undefined);
observer.complete();
}
});
}
Реализация на бекенде:
<?php
/* evercookie, by samy kamkar, 09/20/2010
* http://samy.pl : code@samy.pl
*
* This is the server-side variable PNG generator for evercookie.
* If an HTTP cookie is passed, the cookie data gets converted into
* RGB-values in a PNG image. The PNG image is printed out with a
* 20-year cache expiration date.
*
* If for any reason this file is accessed again WITHOUT the cookie,
* as in the user deleted their cookie, the code returns back with
* a forced 'Not Modified' meaning the browser should look at its
* cache for the image.
*
* The client-side code then places the cached image in a canvas and
* reads it in pixel by pixel, converting the PNG back into a cookie.
*
* -samy kamkar
*/
// we get cookie name from current file name so remember about it when rename of this file will be required
include dirname(__FILE__) . DIRECTORY_SEPARATOR . '_cookie_name.php';
$cookie_name = evercookie_get_cookie_name(__FILE__);
// we don't have a cookie, user probably deleted it, force cache
if (empty($_COOKIE[$cookie_name])) {
if(!headers_sent()) {
header('HTTP/1.1 304 Not Modified');
}
exit;
}
// width of 200 means 600 bytes (3 RGB bytes per pixel)
$x = 200;
$y = 1;
$gd = imagecreatetruecolor($x, $y);
$data_arr = str_split($_COOKIE[$cookie_name]);
$x = 0;
$y = 0;
for ($i = 0, $i_count = count($data_arr); $i < $i_count; $i += 3) {
$red = isset($data_arr[$i]) ? ord($data_arr[$i]) : 0;
$green = isset($data_arr[$i+1]) ? ord($data_arr[$i+1]) : 0;
$blue = isset($data_arr[$i+2]) ? ord($data_arr[$i+2]) : 0;
$color = imagecolorallocate($gd, $red, $green, $blue);
imagesetpixel($gd, $x++, $y, $color);
}
if(!headers_sent()) {
header('Content-Type: image/png');
header('Last-Modified: Wed, 30 Jun 2010 21:36:48 GMT');
header('Expires: Tue, 31 Dec 2030 23:30:45 GMT');
header('Cache-Control: private, max-age=630720000');
}
// boom. headshot.
imagepng($gd);
Хранение cookie‑файлов в HTTP ETag
Сохранение ETag аналогично записи куки.
private setEtag(value: string | number): void {
try {
this.document.cookie = `${this.options.etagCookieName}=${value}; path=/; domain=${this.options.domain}`;
this.request(
`${this.options.baseurl}${this.options.phpuri}${this.options.etagPath}?name=${this.options.name}&cookie=${this.options.etagCookieName}`
)
.pipe(catchError(() => EMPTY))
.subscribe();
} catch (e) {
/* empty */
}
}
Получение ETag аналогично получению куки. Разница будет только в серверной реализации.
private getEtag(): Observable<number | undefined> {
return new Observable((observer) => {
const origvalue = getFromStr(this.options.etagCookieName, this.document.cookie);
this.document.cookie = `${this.options.etagCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;
this.request(
`${this.options.baseurl}${this.options.phpuri}${this.options.etagPath}?name=${this.options.name}&cookie=${this.options.etagCookieName}`
)
.pipe(
tap((response) => {
this.document.cookie = `${this.options.etagCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;
observer.next(response !== '' ? Number(response) : undefined);
observer.complete();
}),
catchError(() => {
observer.next(undefined);
observer.complete();
return EMPTY;
})
)
.subscribe();
});
}
Реализация на PHP:
<?php
/* evercookie, by samy kamkar, 09/20/2010
* http://samy.pl : code@samy.pl
*
* This is the server-side ETag software which tags a user by
* using the Etag HTTP header, as well as If-None-Match to check
* if the user has been tagged before.
*
* -samy kamkar
*/
// we get cookie name from current file name so remember about it when rename of this file will be required
include dirname(__FILE__) . DIRECTORY_SEPARATOR . '_cookie_name.php';
$cookie_name = evercookie_get_cookie_name(__FILE__);
// we don't have a cookie, so we're not setting it
if (empty($_COOKIE[$cookie_name])) {
// read our etag and pass back
if (!function_exists('apache_request_headers')) {
function apache_request_headers() {
// Source: http://www.php.net/manual/en/function.apache-request-headers.php#70810
$arh = array();
$rx_http = '/\AHTTP_/';
foreach ($_SERVER as $key => $val) {
if (preg_match($rx_http, $key)) {
$arh_key = preg_replace($rx_http, '', $key);
$rx_matches = array();
// do some nasty string manipulations to restore the original letter case
// this should work in most cases
$rx_matches = explode('_', $arh_key);
if (count($rx_matches) > 0 and strlen($arh_key) > 2) {
foreach ($rx_matches as $ak_key => $ak_val) {
$rx_matches[$ak_key] = ucfirst(strtolower($ak_val));
}
$arh_key = implode('-', $rx_matches);
}
$arh[$arh_key] = $val;
}
}
return ($arh);
}
}
// Headers might have different letter case depending on the web server.
// So, change all headers to uppercase and compare it.
$headers = array_change_key_case(apache_request_headers(), CASE_UPPER);
if(isset($headers['IF-NONE-MATCH'])) {
// extracting value from ETag presented format (which may be prepended by Weak validator modifier)
$etag_value = preg_replace('|^(W/)?"(.+)"$|', '$2', $headers['IF-NONE-MATCH']);
header('HTTP/1.1 304 Not Modified');
header('ETag: "' . $etag_value . '"');
echo $etag_value;
}
exit;
}
// set our etag
header('ETag: "' . $_COOKIE[$cookie_name] . '"');
echo $_COOKIE[$cookie_name];
Как видно из примера, сначала проверяется наличие куки. Если куки нет,то идет установка, в противном случае просто ставится заголовок и возвращается кука.
Веб‑хранилище сеансов HTML5
Уставка значения в sessionStorage:
private setSessionStorage(value: string | number) {
try {
if (value !== undefined && this.window?.sessionStorage) {
this.window?.sessionStorage.setItem(this.options.name, value.toString());
}
} catch (e) {
/* empty */
}
}
Обертка try‑catch здесь обязательна, так как некоторые браузеры в режиме инкогнито могут не предоставлять доступ к хранилищу.
Получение данных из sessionStorage:
private getSessionStorage(): Observable<number | undefined> {
return new Observable((observer) => {
try {
if (this.window?.sessionStorage) {
const value = this.window.sessionStorage.getItem(this.options.name);
observer.next(value ? Number(value) : undefined);
} else {
observer.next(undefined);
}
} catch (e) {
observer.next(undefined);
}
observer.complete();
});
}
Локальное веб‑хранилище HTML5
Чтение и запись в LocalStorage ничем не отличается от записи и чтения в SessionStorage.
private setLocalStorage(value: string | number) {
try {
if (value !== undefined && this.window?.localStorage) {
this.window.localStorage.setItem(this.options.name, value.toString());
}
} catch (e) {
/* empty */
}
}
private getLocalStorage(): Observable<number | undefined> {
return new Observable((observer) => {
try {
if (this.window?.localStorage) {
const value = this.window.localStorage.getItem(this.options.name);
observer.next(value ? Number(value) : undefined);
} else {
observer.next(undefined);
}
} catch (e) {
observer.next(undefined);
}
observer.complete();
});
}
База данных HTML5 Web SQL через SQLite
Запись в Web SQL:
private setDatabaseStorage(value: string | number) {
try {
if (typeof this.window?.openDatabase === 'function') {
const database = this.window.openDatabase('sqlite_evercookie', '', 'evercookie', 1024 * 1024);
database.transaction((tx) => {
tx.executeSql(
'CREATE TABLE IF NOT EXISTS cache(' +
'id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, ' +
'name TEXT NOT NULL, ' +
'value TEXT NOT NULL, ' +
'UNIQUE (name)' +
')',
[]
);
tx.executeSql('INSERT OR REPLACE INTO cache(name, value) ' + 'VALUES(?, ?)', [this.options.name, value]);
});
}
} catch (e) {
/* empty */
}
}
Сначала открывается требуемая база данных — sqlite_evercookie.
Затем создается транзакция, в которой создается таблица cache, если ее не существует. В конце идет запись в таблицу требуемого значения.
Получение данных из Web SQL. Алгоритм следующий:
Сначала открываем базу данных.
Затем читаем значение из таблицы.
private getDatabaseStorage(): Observable<number | undefined> {
return new Observable((observer) => {
try {
if (typeof this.window?.openDatabase === 'function') {
const database = this.window?.openDatabase('sqlite_evercookie', '', 'evercookie', 1024 * 1024);
database.transaction((tx) => {
tx.executeSql(
'SELECT value FROM cache WHERE name=?',
[this.options.name],
(tx1, result1) => {
observer.next(result1.rows.length >= 1 ? result1.rows.item(0).value : undefined);
observer.complete();
},
() => {
observer.next(undefined);
observer.complete();
}
);
});
} else {
observer.next(undefined);
observer.complete();
}
} catch (e) {
observer.next(undefined);
observer.complete();
/* empty */
}
});
}
HTML5 IndexedDB
Запись в IndexedDB.
Сначала открывается нужная база.
Затем создается транзакция на чтение и запись.
После этого идет запись в таблицу.
private setIndexedDb(value: string | number) {
try {
const indexedDB = this.window?.indexedDB || this.window?.mozIndexedDB || this.window?.webkitIndexedDB || this.window?.msIndexedDB;
if (indexedDB) {
const request = indexedDB.open('idb_evercookie', 1);
request.onupgradeneeded = (event) => {
const db = request.result || (event as any).target.result;
db.createObjectStore('evercookie', {
keyPath: 'name',
});
};
request.onsuccess = (event) => {
const idb = request.result || (event as any).target.result;
if (idb.objectStoreNames.contains('evercookie')) {
try {
const tx = idb.transaction(['evercookie'], 'readwrite');
const objst = tx.objectStore('evercookie');
objst.put({ name: this.options.name, value: value });
} catch (e) {
/* empty */
}
}
idb.close();
};
}
} catch (e) {
/* empty */
}
}
Получение данных аналогично:
private getIndexedDb(): Observable<number | undefined> {
return new Observable((observer) => {
try {
const indexedDB = this.window?.indexedDB || this.window?.mozIndexedDB || this.window?.webkitIndexedDB || this.window?.msIndexedDB;
if (indexedDB) {
const request = indexedDB.open('idb_evercookie', 1);
request.onupgradeneeded = (event) => {
const db = request.result || (event as any).target.result;
db.createObjectStore('evercookie', {
keyPath: 'name',
});
};
request.onsuccess = (event) => {
const idb = request.result || (event as any).target.result;
if (!idb.objectStoreNames.contains('evercookie')) {
observer.next(undefined);
observer.complete();
} else {
const tx = idb.transaction(['evercookie']);
const objst = tx.objectStore('evercookie');
const qr = objst.get(this.options.name);
qr.onsuccess = () => {
observer.next(qr.result !== undefined ? qr.result.value : undefined);
observer.complete();
};
qr.onerror = () => {
observer.next(undefined);
observer.complete();
};
}
idb.close();
};
request.onerror = () => {
observer.next(undefined);
observer.complete();
};
}
} catch (e) {
observer.next(undefined);
observer.complete();
}
});
}
Теперь, если собрать все методы в месте, то установка:
set(eid: number | undefined): void {
if (eid) {
this.setEtag(eid);
this.setPng(eid);
this.setCache(eid);
this.setIndexedDb(eid);
this.setLocalStorage(eid);
this.setSessionStorage(eid);
this.setDatabaseStorage(eid);
this.setWindowName(eid);
}
}
Метод получения всех кук:
get(): Observable<EvercookieResponse> {
return combineLatest([
this.getEtag(),
this.getPng(),
this.getCache(),
this.getIndexedDb(),
this.getLocalStorage(),
this.getSessionStorage(),
this.getDatabaseStorage(),
this.getWindowName(),
]).pipe(
take(1),
map((response) => {
const result: Record<string, number> = {};
response.forEach((eid) => {
if (eid) {
if (result[eid] === undefined) {
result[eid] = 0;
}
result[eid]++;
}
});
let best: number | undefined = undefined;
let bestCount = 0;
const results = Object.entries(result);
for (const [key, value] of results) {
if (value > bestCount && key !== 'undefined') {
bestCount = value;
best = Number(key);
}
}
return {
best: typeof best !== 'undefined' ? Number(best) : undefined,
all: {
etagData: typeof response[0] !== 'undefined' ? Number(response[0]) : undefined,
pngData: typeof response[1] !== 'undefined' ? Number(response[1]) : undefined,
cookieData: typeof response[2] !== 'undefined' ? Number(response[2]) : undefined,
idbData: typeof response[3] !== 'undefined' ? Number(response[3]) : undefined,
localData: typeof response[4] !== 'undefined' ? Number(response[4]) : undefined,
sessionData: typeof response[5] !== 'undefined' ? Number(response[5]) : undefined,
dbData: typeof response[6] !== 'undefined' ? Number(response[6]) : undefined,
windowData: typeof response[7] !== 'undefined' ? Number(response[7]) : undefined,
},
};
})
);
}
Принцип следующий:
Идет запрос всех кук
Когда каждый метод будет отработан, то combineLatest испустит новое значение
Далее нужно посчитать куку, которая чаще всего встречается.
И в конце вернуть best и all варианты.
Метод для запуска получения и обновления кук при старте приложения:
detect(): void {
if (this.isBrowser) {
this.get()
.pipe(
switchMap((result) => {
if (result !== undefined) {
this.set(result.best);
}
return this.httpClient.post<{ id: number }>('/api/evercookie', result).pipe(
tap((response) => {
if (response.id !== result.best) {
this.set(response.id);
}
})
);
}),
catchError(() => EMPTY),
takeUntilDestroyed(this.destroyRef)
)
.subscribe();
}
}
Получаем куки.
Если куки не пусты, то записываем во все хранилища лучшее значение.
Отправляем на сервер полученные данные.
Если сервер вернет нам другой UID, то заменим значение в хранилищах на новое.
Итоговый сервис в Angular:
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { DestroyRef, Inject, Injectable, PLATFORM_ID } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { catchError, combineLatest, EMPTY, from, map, Observable, switchMap, take, tap } from 'rxjs';
import { EvercookieOptions, EvercookieResponse } from './evercookie.interface';
declare global {
interface Window {
readonly mozIndexedDB?: IDBFactory;
readonly webkitIndexedDB?: IDBFactory;
readonly msIndexedDB?: IDBFactory;
readonly openDatabase?: (
name: string,
version: string,
displayName: string,
estimatedSize: number
) => {
transaction(
callback: (arg: {
executeSql: (sql: string, params?: any[], cb?: (tx: any, cb: any) => void, eh?: (err: any) => void) => void;
}) => void
): void;
};
}
}
export function replace(str: string, key: string, value: string | number): string {
if (str.indexOf('&' + key + '=') > -1 || str.indexOf(key + '=') === 0) {
// find start
let idx = str.indexOf('&' + key + '=');
if (idx === -1) {
idx = str.indexOf(key + '=');
}
// find end
const end = str.indexOf('&', idx + 1);
return end !== -1 ? `${str.substr(0, idx)}${str.substr(end + (idx ? 0 : 1))}&${key}=${value}` : `${str.substr(0, idx)}&${key}=${value}`;
}
return `${str}&${key}=${value}`;
}
export function getFromStr(name: string, text: unknown) {
if (typeof text !== 'string') {
return;
}
const nameEQ = name + '=';
const ca = text.split(/[;&]/);
let c;
for (let i = 0; i < ca.length; i++) {
c = ca[i];
while (c.charAt(0) === ' ') {
c = c.substring(1, c.length);
}
if (c.indexOf(nameEQ) === 0) {
return c.substring(nameEQ.length, c.length);
}
}
return;
}
@Injectable({
providedIn: 'root',
})
export class EvercookieService {
private options: EvercookieOptions = {
cacheCookieName: 'evercookie_cache',
pngCookieName: 'evercookie_png',
etagCookieName: 'evercookie_etag',
etagPath: '/etag.php',
baseurl: 'http://localhost:8080/evercookie',
domain: '.',
phpuri: '/php',
pngPath: '/png.php',
cachePath: '/cache.php',
name: 'uid',
expiresPast: 'Thu, 01 Jun 2000 00:00:00 GMT',
expires: 'Sat, 01 Jun 2030 00:00:00 GMT',
};
readonly isBrowser!: boolean;
constructor(
private readonly destroyRef: DestroyRef,
private readonly httpClient: HttpClient,
@Inject(DOCUMENT) private readonly document: Document,
// eslint-disable-next-line @typescript-eslint/ban-types
@Inject(PLATFORM_ID) private readonly platformId: Object
) {
this.isBrowser = isPlatformBrowser(this.platformId);
if (this.isBrowser && this.window) {
this.options.domain = `.${this.document.defaultView?.location.host.replace(/:\d+/, '')}`;
}
}
get window() {
return this.document.defaultView;
}
detect(): void {
if (this.isBrowser) {
this.get()
.pipe(
switchMap((result) => {
if (result !== undefined) {
this.set(result.best);
}
return this.httpClient.post<{ id: number }>('/api/evercookie', result).pipe(
tap((response) => {
if (response.id !== result.best) {
this.set(response.id);
}
})
);
}),
catchError(() => EMPTY),
takeUntilDestroyed(this.destroyRef)
)
.subscribe();
}
}
set(eid: number | undefined): void {
if (eid) {
this.setEtag(eid);
this.setPng(eid);
this.setCache(eid);
this.setIndexedDb(eid);
this.setLocalStorage(eid);
this.setSessionStorage(eid);
this.setDatabaseStorage(eid);
this.setWindowName(eid);
}
}
get(): Observable<EvercookieResponse> {
return combineLatest([
this.getEtag(),
this.getPng(),
this.getCache(),
this.getIndexedDb(),
this.getLocalStorage(),
this.getSessionStorage(),
this.getDatabaseStorage(),
this.getWindowName(),
]).pipe(
take(1),
map((response) => {
const result: Record<string, number> = {};
response.forEach((eid) => {
if (eid) {
if (result[eid] === undefined) {
result[eid] = 0;
}
result[eid]++;
}
});
let best: number | undefined = undefined;
let bestCount = 0;
const results = Object.entries(result);
for (const [key, value] of results) {
if (value > bestCount && key !== 'undefined') {
bestCount = value;
best = Number(key);
}
}
return {
best: typeof best !== 'undefined' ? Number(best) : undefined,
all: {
etagData: typeof response[0] !== 'undefined' ? Number(response[0]) : undefined,
pngData: typeof response[1] !== 'undefined' ? Number(response[1]) : undefined,
cookieData: typeof response[2] !== 'undefined' ? Number(response[2]) : undefined,
idbData: typeof response[3] !== 'undefined' ? Number(response[3]) : undefined,
localData: typeof response[4] !== 'undefined' ? Number(response[4]) : undefined,
sessionData: typeof response[5] !== 'undefined' ? Number(response[5]) : undefined,
dbData: typeof response[6] !== 'undefined' ? Number(response[6]) : undefined,
windowData: typeof response[7] !== 'undefined' ? Number(response[7]) : undefined,
},
};
})
);
}
private request(url: string): Observable<string> {
return from(
fetch(url, {
method: 'GET',
mode: 'no-cors',
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Accept: 'text/javascript, text/html, application/xml, text/xml, */*',
},
})
).pipe(
switchMap((response) => from(response.text())),
map((result) => result ?? undefined),
takeUntilDestroyed(this.destroyRef)
);
}
private getWindowName(): Observable<number | undefined> {
return new Observable((observer) => {
try {
const eid = getFromStr(this.options.name, this.window?.name);
observer.next(typeof eid !== 'undefined' ? Number(eid) : undefined);
} catch (e) {
observer.next(undefined);
}
observer.complete();
});
}
private setWindowName(value: string | number): void {
try {
if (this.window) {
this.window.name = replace(this.window?.name, this.options.name, value);
}
} catch (e) {
/* empty */
}
}
///
private getCache(): Observable<number | undefined> {
return new Observable((observer) => {
// interestingly enough, we want to erase our evercookie
// http cookie so the php will force a cached response
const origvalue = getFromStr(this.options.cacheCookieName, this.document.cookie);
// eslint-disable-next-line max-len
this.document.cookie = `${this.options.cacheCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;
this.request(
// eslint-disable-next-line max-len
`${this.options.baseurl}${this.options.phpuri}${this.options.cachePath}?name=${this.options.name}&cookie=${this.options.cacheCookieName}`
)
.pipe(
tap((response) => {
// eslint-disable-next-line max-len
this.document.cookie = `${this.options.cacheCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;
observer.next(response !== '' ? Number(response) : undefined);
observer.complete();
}),
catchError(() => {
observer.next(undefined);
observer.complete();
return EMPTY;
})
)
.subscribe();
});
}
private setCache(value: string | number): void {
// eslint-disable-next-line max-len
this.document.cookie = `${this.options.cacheCookieName}=${value}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;
this.request(
// eslint-disable-next-line max-len
`${this.options.baseurl}${this.options.phpuri}${this.options.cachePath}?name=${this.options.name}&cookie=${this.options.cacheCookieName}`
)
.pipe(catchError(() => EMPTY))
.subscribe();
}
private getEtag(): Observable<number | undefined> {
return new Observable((observer) => {
const origvalue = getFromStr(this.options.etagCookieName, this.document.cookie);
this.document.cookie = `${this.options.etagCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;
this.request(
// eslint-disable-next-line max-len
`${this.options.baseurl}${this.options.phpuri}${this.options.etagPath}?name=${this.options.name}&cookie=${this.options.etagCookieName}`
)
.pipe(
tap((response) => {
// eslint-disable-next-line max-len
this.document.cookie = `${this.options.etagCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;
observer.next(response !== '' ? Number(response) : undefined);
observer.complete();
}),
catchError(() => {
observer.next(undefined);
observer.complete();
return EMPTY;
})
)
.subscribe();
});
}
private setEtag(value: string | number): void {
try {
this.document.cookie = `${this.options.etagCookieName}=${value}; path=/; domain=${this.options.domain}`;
this.request(
// eslint-disable-next-line max-len
`${this.options.baseurl}${this.options.phpuri}${this.options.etagPath}?name=${this.options.name}&cookie=${this.options.etagCookieName}`
)
.pipe(catchError(() => EMPTY))
.subscribe();
} catch (e) {
/* empty */
}
}
private getPng(): Observable<number | undefined> {
return new Observable((observer) => {
const canvas = this.document.createElement('canvas');
canvas.style.visibility = 'hidden';
canvas.style.position = 'absolute';
canvas.width = 200;
canvas.height = 1;
if (canvas && canvas.getContext) {
// {{this.options.pngPath}} handles the hard part of generating the image
// based off of the http cookie and returning it cached
const img = new Image();
img.style.visibility = 'hidden';
img.style.position = 'absolute';
const ctx = canvas.getContext('2d');
const origvalue = getFromStr(this.options.pngCookieName, this.document.cookie);
// eslint-disable-next-line max-len
this.document.cookie = `${this.options.pngCookieName}=; expires=${this.options.expiresPast}; path=/; domain=${this.options.domain}`;
img.onload = () => {
// eslint-disable-next-line max-len
this.document.cookie = `${this.options.pngCookieName}=${origvalue}; expires=${this.options.expires}; path=/; domain=${this.options.domain}`;
let png = '';
if (ctx) {
ctx.drawImage(img, 0, 0);
// get CanvasPixelArray from given coordinates and dimensions
const pix = ctx.getImageData(0, 0, 200, 1).data;
// loop over each pixel to get the "RGB" values (ignore alpha)
for (let i = 0, n = pix.length; i < n; i += 4) {
if (pix[i] === 0) {
break;
}
png += String.fromCharCode(pix[i]);
if (pix[i + 1] === 0) {
break;
}
png += String.fromCharCode(pix[i + 1]);
if (pix[i + 2] === 0) {
break;
}
png += String.fromCharCode(pix[i + 2]);
}
}
observer.next(png !== '' ? Number(png) : undefined);
observer.complete();
};
img.onerror = () => {
observer.next(undefined);
observer.complete();
};
// eslint-disable-next-line max-len
img.src = `${this.options.baseurl}${this.options.phpuri}${this.options.pngPath}?name=${this.options.name}&cookie=${this.options.pngCookieName}`;
img.crossOrigin = 'Anonymous';
} else {
observer.next(undefined);
observer.complete();
}
});
}
private setPng(value: string | number): void {
try {
this.document.cookie = this.options.pngCookieName + '=' + value + '; path=/; domain=' + this.options.domain;
const img = new Image();
img.style.visibility = 'hidden';
img.style.position = 'absolute';
// eslint-disable-next-line max-len
img.src = `${this.options.baseurl}${this.options.phpuri}${this.options.pngPath}?name=${this.options.name}&cookie=${this.options.pngCookieName}`;
img.crossOrigin = 'Anonymous';
} catch (e) {
/* empty */
}
}
private getLocalStorage(): Observable<number | undefined> {
return new Observable((observer) => {
try {
if (this.window?.localStorage) {
const value = this.window?.localStorage.getItem(this.options.name);
observer.next(value ? Number(value) : undefined);
} else {
observer.next(undefined);
}
} catch (e) {
observer.next(undefined);
}
observer.complete();
});
}
private setLocalStorage(value: string | number) {
try {
if (value !== undefined && this.window?.localStorage) {
this.window?.localStorage.setItem(this.options.name, value.toString());
}
} catch (e) {
/* empty */
}
}
private getSessionStorage(): Observable<number | undefined> {
return new Observable((observer) => {
try {
if (this.window?.sessionStorage) {
const value = this.window?.sessionStorage.getItem(this.options.name);
observer.next(value ? Number(value) : undefined);
} else {
observer.next(undefined);
}
} catch (e) {
observer.next(undefined);
}
observer.complete();
});
}
private setSessionStorage(value: string | number) {
try {
if (value !== undefined && this.window?.sessionStorage) {
this.window?.sessionStorage.setItem(this.options.name, value.toString());
}
} catch (e) {
/* empty */
}
}
private getIndexedDb(): Observable<number | undefined> {
return new Observable((observer) => {
try {
const indexedDB = this.window?.indexedDB || this.window?.mozIndexedDB || this.window?.webkitIndexedDB || this.window?.msIndexedDB;
if (indexedDB) {
const request = indexedDB.open('idb_evercookie', 1);
request.onupgradeneeded = (event) => {
const db = request.result || (event as any).target.result;
db.createObjectStore('evercookie', {
keyPath: 'name',
});
};
request.onsuccess = (event) => {
const idb = request.result || (event as any).target.result;
if (!idb.objectStoreNames.contains('evercookie')) {
observer.next(undefined);
observer.complete();
} else {
const tx = idb.transaction(['evercookie']);
const objst = tx.objectStore('evercookie');
const qr = objst.get(this.options.name);
qr.onsuccess = () => {
observer.next(qr.result !== undefined ? qr.result.value : undefined);
observer.complete();
};
qr.onerror = () => {
observer.next(undefined);
observer.complete();
};
}
idb.close();
};
request.onerror = () => {
observer.next(undefined);
observer.complete();
};
}
} catch (e) {
observer.next(undefined);
observer.complete();
}
});
}
private setIndexedDb(value: string | number) {
try {
const indexedDB = this.window?.indexedDB || this.window?.mozIndexedDB || this.window?.webkitIndexedDB || this.window?.msIndexedDB;
if (indexedDB) {
const request = indexedDB.open('idb_evercookie', 1);
request.onupgradeneeded = (event) => {
const db = request.result || (event as any).target.result;
db.createObjectStore('evercookie', {
keyPath: 'name',
});
};
request.onsuccess = (event) => {
const idb = request.result || (event as any).target.result;
if (idb.objectStoreNames.contains('evercookie')) {
try {
const tx = idb.transaction(['evercookie'], 'readwrite');
const objst = tx.objectStore('evercookie');
objst.put({ name: this.options.name, value: value });
} catch (e) {
/* empty */
}
}
idb.close();
};
}
} catch (e) {
/* empty */
}
}
private getDatabaseStorage(): Observable<number | undefined> {
return new Observable((observer) => {
try {
if (typeof this.window?.openDatabase === 'function') {
const database = this.window?.openDatabase('sqlite_evercookie', '', 'evercookie', 1024 * 1024);
database.transaction((tx) => {
tx.executeSql(
'SELECT value FROM cache WHERE name=?',
[this.options.name],
(tx1, result1) => {
observer.next(result1.rows.length >= 1 ? result1.rows.item(0).value : undefined);
observer.complete();
},
() => {
observer.next(undefined);
observer.complete();
}
);
});
} else {
observer.next(undefined);
observer.complete();
}
} catch (e) {
observer.next(undefined);
observer.complete();
/* empty */
}
});
}
private setDatabaseStorage(value: string | number) {
try {
if (typeof this.window?.openDatabase === 'function') {
const database = this.window?.openDatabase('sqlite_evercookie', '', 'evercookie', 1024 * 1024);
database.transaction((tx) => {
tx.executeSql(
'CREATE TABLE IF NOT EXISTS cache(' +
'id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, ' +
'name TEXT NOT NULL, ' +
'value TEXT NOT NULL, ' +
'UNIQUE (name)' +
')',
[]
);
tx.executeSql('INSERT OR REPLACE INTO cache(name, value) ' + 'VALUES(?, ?)', [this.options.name, value]);
});
}
} catch (e) {
/* empty */
}
}
}
Пару замечаний по реализации:
Методы дублируют реализацию для большей наглядности
Методы для установки кук и образования путей к API оставлены в неизмененном виде, чтобы быть ближе к основной реализации.
Реализация HSTS убрана в эгоистических целях.
Решение оформлено в виде обычного сервиса Angular с использованием стандартных сервисов и утилит ядра Angular.
Заключение
Статья начинается с описания используемых механизмов еверкуки. Кратко разбирается каждая из технологий и объясняется как она участвует в процессе восстановления идентификатора пользователя.
Приведены описания:
Стандартные cookie‑файлы HTTP
HTTP Strict Transport Security (HSTS)
Локальные общие объекты (Flash cookies)
Изолированное хранилище Silverlight
Хранение cookie‑файлов, закодированных в значениях RGB автоматически сгенерированных, принудительно кэшированных изображений PNG с использованием тега HTML5 Canvas для обратного считывания пикселей (файлов cookie).
Сохранение cookie‑файлов в истории поиска
Хранение cookie‑файлов в HTTP ETag
Сохранение cookie‑файлов в веб‑кэше
Кэширование window.name
Хранилище пользовательских данных Internet Explorer
Веб‑хранилище сеансов HTML5
Локальное веб‑хранилище HTML5
Глобальное хранилище HTML5
База данных HTML5 Web SQL через SQLite
HTML5 IndexedDB
Java JNLP PersistenceService
Эксплойт Java CVE-2013–0422
Затем идет актуализация механизмов кэширования, результатом которого становиться сводная таблица, где указано, какие из механизмов работают в настоящее время.
В результате список рабочих механизмов, которые актуальны сейчас:
Стандартные cookie-файлы HTTP
HSTS
Хранение cookie-файлов, закодированных в значениях RGB автоматически сгенерированных, принудительно кэшированных
Хранение cookie-файлов в HTTP ETag
Кэширование window.name
Веб-хранилище сеансов HTML5
Локальное веб-хранилище HTML5
База данных HTML5 Web SQL через SQLite
HTML5 IndexedDB
После определения рабочих механизмов, приводится реализация evercookie для Angular, где приводятся небольшие комментарии, объясняющие принцип работы. Серверная часть, реализованная на PHP оставлена в неизмененном варианте.
Спасибо за внимание!
Комментарии (9)
NutsUnderline
28.07.2023 07:09Обычный пользователь не будет удалять свои данные, а вот мошенник только и пытается замести следы
Да да, это же все на благо пользователя, как же может быть по другому!
Я не хочу чтобы у меня была свалка кук на компе, в который еще и с большим интересом будут копаться слишком любопытные личности. А уж если меня по результатам гуглинга занесло на помойку с ссылками, то это не значит что мне там даже что то интересно, и тем более не надо делать глубокомысленный вывод что мне там все интересно, и показывать там очень "ревалентную" рекламу.
Поэтому мне непонятен, и крайне неприятен подобный тезис.
fafnur Автор
28.07.2023 07:09+1Это не благо для пользователя, это благо для разработчика.
Вообще, честные компании спрашивают о политике кук.NutsUnderline
28.07.2023 07:09Ага табло на полэкрана и там две кнопки: принять побольше и принять поменьше. Так сказать, по полной или только "на полшишечки". А мне не надо ни то ни другое.
dartraiden
28.07.2023 07:09в который еще и с большим интересом будут копаться слишком любопытные личности.
Одному ресурсу недоступны куки другого. Кроме того, современные браузеры дополнительно изолируют куки с привязкой к домеру: если, скажем, на Хабре есть виджет Фейсбука, который поставил пользователю фейсбучную куку, то при заходе на Фейсбук, тот не сможет прочитать эту куку, т.к. она, хоть и фейсбучная, но поставлена в контексте Хабра.
и показывать там очень "ревалентную" рекламу.
Какая разница, какая там реклама, если блокировщик рекламы её вырежет.
NutsUnderline
28.07.2023 07:09меня не устраивает даже если одного ресурса. а куки тут уже не совсем обычные. ну и не вижу повода ресурсам не договориться об обмене печенек. в конце концов поискал в одном месте: а реклама лезет на других ресурсах
olku
28.07.2023 07:09Пассивный трекинг (без яваскнипта) в несколько строк https://github.com/noleakseu/test/tree/main/tracker
fafnur Автор
28.07.2023 07:09Это решение в котором только оставлены серверные куки. Имеет место на существование.
ri1wing
Я извиняюсь, но по-моему для таких пользователей и обычные куки должны работать. Стоило ли огород городить? Если против более продвинутых пользователей, которые знают про режим "инкогнито", эверкуки не работают. Я уже молчу про параноиков, которые будут при новой регистрации чистую виртуалку разворачивать, в которой гарантированно никаких кук не будет.
fafnur Автор
В целом все верно. Но куки могут протухнуть, а вот данные в localstorage будет вечно.