diff --git a/.github/workflows/coverage-diff.yml b/.github/workflows/coverage-diff.yml
new file mode 100644
index 00000000..7d25c5f3
--- /dev/null
+++ b/.github/workflows/coverage-diff.yml
@@ -0,0 +1,31 @@
+name: Check coverage for PR
+
+on:
+ pull_request:
+
+jobs:
+ run-tests-check-coverage:
+ runs-on: ubuntu-20.04
+ name: Run tests & check coverage
+ steps:
+ - uses: actions/checkout@v3
+ - name: Jest coverage comment
+ id: coverage
+ uses: ArtiomTr/jest-coverage-report-action@9f733792c44d05327cb371766bf78a5261e43936
+ with:
+ package-manager: yarn
+ output: report-markdown
+ - name: Read coverage text report
+ uses: fingerprintjs/action-coverage-report-md@v1
+ id: coverage-md
+ with:
+ srcBasePath: './'
+ - uses: marocchino/sticky-pull-request-comment@adca94abcaf73c10466a71cc83ae561fd66d1a56
+ with:
+ message: |
+ ${{ steps.coverage.outputs.report }}
+
+ Show full coverage report
+
+ ${{ steps.coverage-md.outputs.markdownReport }}
+
diff --git a/.github/workflows/coverage-report.yml b/.github/workflows/coverage-report.yml
new file mode 100644
index 00000000..1745fcd5
--- /dev/null
+++ b/.github/workflows/coverage-report.yml
@@ -0,0 +1,46 @@
+name: Coverage
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ build-and-run-tests:
+ runs-on: ubuntu-20.04
+ name: Build & run tests & publish coverage
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Install node
+ uses: actions/setup-node@v3
+ with:
+ node-version-file: '.node-version'
+ - name: Get yarn cache directory path
+ id: yarn-cache-dir-path
+ run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
+ - uses: actions/cache@v3
+ id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
+ with:
+ path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-yarn-
+ - name: Install Dependencies and prepare packages
+ run: yarn install
+ env:
+ CI: true
+ - name: Run test
+ run: yarn test
+
+ - name: Create Coverage Badges
+ uses: jaywcjlove/coverage-badges-cli@e07f25709cd25486855c1ba1b26da53576ff3620
+ with:
+ source: coverage/coverage-summary.json
+ output: coverage/lcov-report/badges.svg
+
+ - name: Deploy
+ uses: JamesIves/github-pages-deploy-action@8817a56e5bfec6e2b08345c81f4d422db53a2cdc
+ with:
+ branch: gh-pages
+ folder: ./coverage/lcov-report/
diff --git a/.gitignore b/.gitignore
index 34882af7..3c7283c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,7 +69,7 @@ typings/
.yarn-integrity
# dotenv environment variables file
-.env.test
+.env
# parcel-bundler cache (https://parceljs.org/)
.cache
diff --git a/jest.config.js b/jest.config.js
index f073e589..6c732556 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -2,8 +2,16 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
- testRegex: '/functions/.+test.tsx?$',
+ testRegex: '/proxy/.+test.tsx?$',
passWithNoTests: true,
- collectCoverageFrom: ['./functions/**/**.ts', '!**/index.ts'],
+ collectCoverageFrom: ['./proxy/**/**.ts', '!**/index.ts', '!**/config.ts', './management/**/**.ts'],
coverageReporters: ['lcov', 'json-summary', ['text', { file: 'coverage.txt', path: './' }]],
+ transform: {
+ '^.+\\.[tj]sx?$': [
+ 'ts-jest',
+ {
+ tsconfig: '/tsconfig.test.json',
+ },
+ ],
+ },
}
diff --git a/package.json b/package.json
index d0123dad..a5877cfb 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"lint": "eslint --ext .js,.ts --ignore-path .gitignore --max-warnings 0 .",
"lint:fix": "yarn lint --fix",
"test": "jest --coverage",
- "test:dts": "tsc --noEmit --isolatedModules dist/*.d.ts",
+ "test:dts": "tsc --noEmit --isolatedModules dist/**/*.d.ts",
"emulate-storage": "azurite -l ./storage --silent",
"start": "func start"
},
@@ -31,6 +31,7 @@
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-replace": "^5.0.1",
+ "@types/jest": "^29.4.1",
"@types/node": "^16.x",
"@typescript-eslint/eslint-plugin": "^5.44.0",
"@typescript-eslint/parser": "^5.44.0",
diff --git a/proxy/config.ts b/proxy/config.ts
deleted file mode 100644
index e91e68c3..00000000
--- a/proxy/config.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-// TODO - Extract these into ENV variables
-export const config = {
- fpdcdn: 'fpcdn.io',
- ingressApi: 'api.fpjs.io',
-}
diff --git a/proxy/agent.ts b/proxy/handlers/agent.ts
similarity index 62%
rename from proxy/agent.ts
rename to proxy/handlers/agent.ts
index 61f28e17..9997a6cf 100644
--- a/proxy/agent.ts
+++ b/proxy/handlers/agent.ts
@@ -1,15 +1,15 @@
-import { HttpRequest } from '@azure/functions'
-import { config } from './config'
+import { HttpRequest, Logger } from '@azure/functions'
+import { config } from '../utils/config'
import * as https from 'https'
-import { updateResponseHeaders } from './headers'
+import { filterRequestHeaders, updateResponseHeaders } from '../utils/headers'
import { HttpResponseSimple } from '@azure/functions/types/http'
-function getEndpoint(apiKey: string | undefined, version: string, loaderVersion: string | undefined): string {
- const lv: string = loaderVersion !== undefined && loaderVersion !== '' ? `/loader_v${loaderVersion}.js` : ''
- return `/v${version}/${apiKey}${lv}`
+export interface DownloadAgentParams {
+ httpRequest: HttpRequest
+ logger: Logger
}
-export async function downloadAgent(httpRequest: HttpRequest) {
+export async function downloadAgent({ httpRequest, logger }: DownloadAgentParams) {
const apiKey = httpRequest.query.apiKey
const version = httpRequest.query.version
const loaderVersion = httpRequest.query.loaderVersion
@@ -19,26 +19,17 @@ export async function downloadAgent(httpRequest: HttpRequest) {
const url = new URL(`https://${config.fpdcdn}`)
url.pathname = getEndpoint(apiKey, version, loaderVersion)
- const headers = {
- ...httpRequest.headers,
- }
+ logger.verbose('Downloading agent from', url.toString())
- // TODO - Extract this into separate function
- delete headers['host']
- delete headers['content-length']
- delete headers['transfer-encoding']
- delete headers['via']
+ const headers = filterRequestHeaders(httpRequest.headers)
- return new Promise((resolve) => {
+ return new Promise((resolve) => {
const data: any[] = []
- console.debug('Downloading agent from', url.toString())
-
const request = https.request(
url,
{
method: 'GET',
- // TODO Filter headers
headers,
},
(response) => {
@@ -53,31 +44,34 @@ export async function downloadAgent(httpRequest: HttpRequest) {
response.on('end', () => {
const body = Buffer.concat(data)
+ const responseHeaders = updateResponseHeaders(response.headers, domain)
resolve({
status: response.statusCode ? response.statusCode.toString() : '500',
- // TODO Filter headers
- headers: updateResponseHeaders(response.headers, domain),
+ headers: responseHeaders,
body: new Uint8Array(body),
- isRaw: true,
})
})
},
)
request.on('error', (error) => {
- console.error('unable to download agent', { error })
+ logger.error('unable to download agent', { error })
resolve({
status: '500',
headers: {
- 'Content-Type': 'application/json',
+ 'Content-Type': 'text/plain',
},
- // TODO Generate error response with our integrations format
- body: error,
+ body: 'error',
})
})
request.end()
})
}
+
+function getEndpoint(apiKey: string | undefined, version: string, loaderVersion: string | undefined): string {
+ const lv: string = loaderVersion !== undefined && loaderVersion !== '' ? `/loader_v${loaderVersion}.js` : ''
+ return `/v${version}/${apiKey}${lv}`
+}
diff --git a/proxy/ingress.ts b/proxy/handlers/ingress.ts
similarity index 95%
rename from proxy/ingress.ts
rename to proxy/handlers/ingress.ts
index 0116ea9f..7ace9f1f 100644
--- a/proxy/ingress.ts
+++ b/proxy/handlers/ingress.ts
@@ -1,7 +1,7 @@
import { HttpRequest } from '@azure/functions'
-import { config } from './config'
+import { config } from '../utils/config'
import * as https from 'https'
-import { updateResponseHeaders } from './headers'
+import { updateResponseHeaders } from '../utils/headers'
import { HttpResponseSimple } from '@azure/functions/types/http'
export function handleIngress(httpRequest: HttpRequest) {
diff --git a/proxy/headers.ts b/proxy/headers.ts
deleted file mode 100644
index 211b37b7..00000000
--- a/proxy/headers.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import * as http from 'http'
-import { HttpResponseHeaders } from '@azure/functions/types/http'
-
-const COOKIE_HEADER_NAME = 'set-cookie'
-const ALLOWED_RESPONSE_HEADERS = [
- 'access-control-allow-credentials',
- 'access-control-allow-origin',
- 'access-control-expose-headers',
- 'content-encoding',
- 'content-type',
- 'cross-origin-resource-policy',
- 'etag',
- 'vary',
-]
-
-export function updateResponseHeaders(headers: http.IncomingHttpHeaders, domain: string): HttpResponseHeaders {
- const result: HttpResponseHeaders = {}
-
- for (const key in headers) {
- if (ALLOWED_RESPONSE_HEADERS.includes(key)) {
- const headerValue = headers[key]
-
- if (headerValue) {
- result[key] = Array.isArray(headerValue) ? headerValue.join(' ') : headerValue
- }
- }
- }
-
- if (headers[COOKIE_HEADER_NAME] !== undefined) {
- result[COOKIE_HEADER_NAME] = adjustCookies(headers[COOKIE_HEADER_NAME], domain)
- }
-
- return result
-}
-
-export function adjustCookies(cookies: string[], domainName: string): string {
- const newCookies: string[] = []
- cookies.forEach((it) => {
- const parts: string[] = it.split(';')
-
- parts.map((v: string) => {
- const s = v.trim()
- const ind = s.indexOf('=')
- if (ind !== -1) {
- const key = s.substring(0, ind)
- let value = s.substring(ind + 1)
- if (key.toLowerCase() === 'domain') {
- value = domainName
- }
- newCookies.push(`${key}=${value}`)
- } else {
- newCookies.push(s)
- }
- })
- })
-
- return newCookies.join('; ').trim()
-}
diff --git a/proxy/index.ts b/proxy/index.ts
index e4afd165..ed61d76a 100644
--- a/proxy/index.ts
+++ b/proxy/index.ts
@@ -1,13 +1,19 @@
import { AzureFunction, Context, HttpRequest } from '@azure/functions'
-import { downloadAgent } from './agent'
-import { handleIngress } from './ingress'
+import { downloadAgent } from './handlers/agent'
+import { handleIngress } from './handlers/ingress'
const httpTrigger: AzureFunction = async (context: Context, req: HttpRequest): Promise => {
+ context.log.verbose('Handling request', {
+ req,
+ context,
+ })
+
const path = req.params?.restOfPath
+ // TODO Resolve paths using customer variables
switch (path) {
case 'client': {
- context.res = await downloadAgent(req)
+ context.res = await downloadAgent({ httpRequest: req, logger: context.log })
break
}
diff --git a/proxy/utils/cacheControl.test.ts b/proxy/utils/cacheControl.test.ts
new file mode 100644
index 00000000..4ac97898
--- /dev/null
+++ b/proxy/utils/cacheControl.test.ts
@@ -0,0 +1,15 @@
+import { updateCacheControlHeader } from './cacheControl'
+
+describe('updateCacheControlHeader', () => {
+ test('adjust max-age to lower value', () => {
+ expect(updateCacheControlHeader('public, max-age=36000, s-maxage=36000')).toBe('public, max-age=3600, s-maxage=60')
+ })
+
+ test('keep existing smaller value', () => {
+ expect(updateCacheControlHeader('public, max-age=600, s-maxage=600')).toBe('public, max-age=600, s-maxage=60')
+ })
+
+ test('add max age if not exist', () => {
+ expect(updateCacheControlHeader('no-cache')).toBe('no-cache, max-age=3600, s-maxage=60')
+ })
+})
diff --git a/proxy/utils/cacheControl.ts b/proxy/utils/cacheControl.ts
new file mode 100644
index 00000000..c746ecc4
--- /dev/null
+++ b/proxy/utils/cacheControl.ts
@@ -0,0 +1,26 @@
+const CACHE_MAX_AGE = 3600
+const SHARED_CACHE_MAX_AGE = 60
+
+export function updateCacheControlHeader(headerValue: string): string {
+ let result = updateCacheControlAge(headerValue, 'max-age', CACHE_MAX_AGE)
+ result = updateCacheControlAge(result, 's-maxage', SHARED_CACHE_MAX_AGE)
+
+ return result
+}
+
+function updateCacheControlAge(headerValue: string, type: 'max-age' | 's-maxage', cacheMaxAge: number): string {
+ const cacheControlDirectives = headerValue.split(', ')
+ const maxAgeIndex = cacheControlDirectives.findIndex(
+ (directive) => directive.split('=')[0].trim().toLowerCase() === type,
+ )
+
+ if (maxAgeIndex === -1) {
+ cacheControlDirectives.push(`${type}=${cacheMaxAge}`)
+ } else {
+ const oldMaxAge = Number(cacheControlDirectives[maxAgeIndex].split('=')[1])
+ const newMaxAge = Math.min(cacheMaxAge, oldMaxAge)
+ cacheControlDirectives[maxAgeIndex] = `${type}=${newMaxAge}`
+ }
+
+ return cacheControlDirectives.join(', ')
+}
diff --git a/proxy/utils/config.ts b/proxy/utils/config.ts
new file mode 100644
index 00000000..50ed0a95
--- /dev/null
+++ b/proxy/utils/config.ts
@@ -0,0 +1,4 @@
+export const config = {
+ fpdcdn: '__FPCDN__',
+ ingressApi: '__INGRESS_API__',
+}
diff --git a/proxy/utils/cookies.test.ts b/proxy/utils/cookies.test.ts
new file mode 100644
index 00000000..d36bf848
--- /dev/null
+++ b/proxy/utils/cookies.test.ts
@@ -0,0 +1,73 @@
+import { adjustCookies, filterCookie } from './cookies'
+
+const domain = 'fingerprint.com'
+
+describe('updateCookie', () => {
+ test('empty cookie', () => {
+ expect(adjustCookies([''], domain)).toBe('')
+ })
+
+ test('simple', () => {
+ const value = '_iidf'
+ expect(adjustCookies([value], domain)).toBe(value)
+ })
+
+ test('some non domain with =', () => {
+ const value = '_iidf; Value=x;'
+ expect(adjustCookies([value], domain)).toBe(value)
+ })
+
+ test('key=value=with=equal=sign', () => {
+ const value = 'key=value=with=equal=sign'
+ expect(adjustCookies([value], domain)).toBe(value)
+ })
+
+ test('with equal signs', () => {
+ const value = ['_iidt=7A03Gwg==', '_vid_t=gEFRuIQlzYmv692/UL4GLA==']
+ expect(adjustCookies(value, domain)).toBe('_iidt=7A03Gwg==; _vid_t=gEFRuIQlzYmv692/UL4GLA==')
+ })
+
+ test('without domain', () => {
+ const initialValue =
+ 'iidt,dEbSJkkvz8Yiv4eFAGbOJWPL69y0Z8Z8vnpEk/mJkaZ4hXmM+zb+8iWRy1j6IuqK5Fq1BnLRi2BC/Q,,; ' +
+ 'Path,/; Expires,Fri, 27 Oct 2023 19:17:51 GMT; HttpOnly; Secure; SameSite,None'
+ expect(adjustCookies([initialValue], domain)).toBe(initialValue)
+ })
+
+ test('update domain', () => {
+ const initialValue =
+ 'iidt,dEbSJkkvz8Yiv4eFAGbOJWPL69y0Z8Z8vnpEk/mJkaZ4hXmM+zb+8iWRy1j6IuqK5Fq1BnLRi2BC/Q,,; ' +
+ 'Path,/; Domain=hfdgjkjds.azure.net; Expires,Fri, 27 Oct 2023 19:17:51 GMT; HttpOnly; Secure; SameSite,None'
+ const expectedValue =
+ 'iidt,dEbSJkkvz8Yiv4eFAGbOJWPL69y0Z8Z8vnpEk/mJkaZ4hXmM+zb+8iWRy1j6IuqK5Fq1BnLRi2BC/Q,,; ' +
+ 'Path,/; Domain=fingerprint.com; Expires,Fri, 27 Oct 2023 19:17:51 GMT; HttpOnly; Secure; SameSite,None'
+ expect(adjustCookies([initialValue], domain)).toBe(expectedValue)
+ })
+})
+
+describe('filterCookies', () => {
+ const predicate = (key: string) => key === '_iidt'
+
+ test('the same result', () => {
+ const value = '_iidt=sfdsafdasf'
+ expect(filterCookie(value, predicate)).toBe(value)
+ })
+
+ test('reduce', () => {
+ const value = '_iidt=aass; vid_t=xcvbnm'
+ expect(filterCookie(value, predicate)).toBe('_iidt=aass')
+ })
+
+ test('empty', () => {
+ expect(filterCookie('', predicate)).toBe('')
+ })
+
+ test('no value', () => {
+ expect(filterCookie('_iidt', predicate)).toBe('')
+ })
+
+ test('with equal signs', () => {
+ const value = '_iidt=7A03Gwg==; _vid_t=gEFRuIQlzYmv692/UL4GLA=='
+ expect(filterCookie(value, predicate)).toBe('_iidt=7A03Gwg==')
+ })
+})
diff --git a/proxy/utils/cookies.ts b/proxy/utils/cookies.ts
new file mode 100644
index 00000000..2fdf4455
--- /dev/null
+++ b/proxy/utils/cookies.ts
@@ -0,0 +1,45 @@
+export function filterCookie(cookie: string, filterPredicate: (key: string) => boolean): string {
+ const newCookie: string[] = []
+ const parts = cookie.split(';')
+
+ parts.forEach((cookie) => {
+ const trimmedCookie = cookie.trim()
+ const index = trimmedCookie.indexOf('=')
+
+ if (index !== -1) {
+ const key = trimmedCookie.substring(0, index)
+ const value = trimmedCookie.substring(index + 1)
+ if (filterPredicate(key)) {
+ newCookie.push(`${key}=${value}`)
+ }
+ }
+ })
+
+ return newCookie.join('; ').trim()
+}
+
+export function adjustCookies(cookies: string[], domainName: string): string {
+ const newCookies: string[] = []
+
+ cookies.forEach((cookie) => {
+ const parts: string[] = cookie.split(';')
+
+ parts.map((rawValue: string) => {
+ const trimmedValue = rawValue.trim()
+ const index = trimmedValue.indexOf('=')
+
+ if (index !== -1) {
+ const key = trimmedValue.substring(0, index)
+ let value = trimmedValue.substring(index + 1)
+ if (key.toLowerCase() === 'domain') {
+ value = domainName
+ }
+ newCookies.push(`${key}=${value}`)
+ } else {
+ newCookies.push(trimmedValue)
+ }
+ })
+ })
+
+ return newCookies.join('; ').trim()
+}
diff --git a/proxy/utils/headers.test.ts b/proxy/utils/headers.test.ts
new file mode 100644
index 00000000..81946ec2
--- /dev/null
+++ b/proxy/utils/headers.test.ts
@@ -0,0 +1,125 @@
+import { HttpRequest } from '@azure/functions'
+import { filterRequestHeaders, getHost, updateResponseHeaders } from './headers'
+import { IncomingHttpHeaders } from 'http'
+
+const mockReq = {
+ method: 'GET',
+ url: 'https://example.org/fpjs/client',
+ query: {
+ apiKey: 'ujKG34hUYKLJKJ1F',
+ version: '3',
+ loaderVersion: '3.6.2',
+ },
+ headers: {
+ 'content-type': 'application/json',
+ 'content-length': '24354',
+ host: 'fpjs.sh',
+ 'transfer-encoding': 'br',
+ via: 'azure.com',
+ cookie: '_iidt=7A03Gwg; _vid_t=gEFRuIQlzYmv692/UL4GLA==',
+ 'x-custom-header': 'value123899',
+ 'x-edge-qqq': 'x-edge-qqq',
+ 'strict-transport-security': 'max-age=600',
+ 'x-azure-requestchain': 'hops=1',
+ 'x-azure-socketip': '46.204.4.119',
+ },
+ user: null,
+ params: {},
+ get: jest.fn(),
+ parseFormBody: jest.fn(),
+} satisfies HttpRequest
+
+describe('filterRequestHeaders', () => {
+ test('test filtering blackilisted headers', () => {
+ const headers = filterRequestHeaders(mockReq.headers)
+
+ expect(headers.hasOwnProperty('content-length')).toBe(true)
+ expect(headers.hasOwnProperty('host')).toBe(false)
+ expect(headers.hasOwnProperty('transfer-encoding')).toBe(true)
+ expect(headers.hasOwnProperty('via')).toBe(true)
+ expect(headers['content-type']).toBe('application/json')
+ expect(headers['cookie']).toBe('_iidt=7A03Gwg')
+ expect(headers['x-custom-header']).toBe('value123899')
+ expect(headers.hasOwnProperty('x-edge-qqq')).toBe(false)
+ expect(headers.hasOwnProperty('strict-transport-security')).toBe(false)
+ expect(headers.hasOwnProperty('x-azure-requestchain')).toBe(false)
+ expect(headers.hasOwnProperty('x-azure-socketip')).toBe(false)
+ })
+})
+
+describe('updateResponseHeaders', () => {
+ test('correctly updates response headers', () => {
+ const headers: IncomingHttpHeaders = {
+ 'access-control-allow-credentials': 'true',
+ 'access-control-allow-origin': 'true',
+ 'access-control-expose-headers': 'true',
+ 'cache-control': 'public, max-age=40000, s-maxage=40000',
+ 'content-encoding': 'br',
+ 'content-length': '73892',
+ 'content-type': 'application/json',
+ 'cross-origin-resource-policy': 'cross-origin',
+ etag: 'dskjhfadsjk',
+ 'set-cookie': ['_iidf', 'HttpOnly', 'Domain=azure.net'],
+ vary: 'Accept-Encoding',
+ 'custom-header-1': 'gdfddfd',
+ 'x-edge-xxx': 'ery8u',
+ 'strict-transport-security': 'max-age=1000',
+ }
+
+ const resultHeaders = updateResponseHeaders(headers, 'fpjs.sh')
+
+ expect(resultHeaders.hasOwnProperty('custom-header-1')).toBe(true)
+ expect(resultHeaders.hasOwnProperty('content-length')).toBe(true)
+ expect(resultHeaders.hasOwnProperty('x-edge-xxx')).toBe(false)
+ expect(resultHeaders['cache-control']).toBe('public, max-age=3600, s-maxage=60')
+ expect(resultHeaders['set-cookie']).toBe('_iidf; HttpOnly; Domain=fpjs.sh')
+ expect(resultHeaders.hasOwnProperty('strict-transport-security')).toBe(false)
+ })
+
+ test('updates cache policy', () => {
+ const headers: IncomingHttpHeaders = {
+ 'access-control-allow-credentials': 'true',
+ 'access-control-allow-origin': 'true',
+ 'access-control-expose-headers': 'true',
+ 'cache-control': 'no-cache',
+ 'content-encoding': 'br',
+ 'content-length': '73892',
+ 'content-type': 'application/json',
+ 'cross-origin-resource-policy': 'cross-origin',
+ etag: 'dskjhfadsjk',
+ 'set-cookie': ['_iidf', 'HttpOnly', 'Domain=azure.net'],
+ vary: 'Accept-Encoding',
+ 'custom-header-1': 'gdfddfd',
+ }
+
+ const resultHeaders = updateResponseHeaders(headers, 'fpjs.sh')
+
+ expect(resultHeaders.hasOwnProperty('custom-header-1')).toBe(true)
+ expect(resultHeaders.hasOwnProperty('content-length')).toBe(true)
+ expect(resultHeaders['cache-control']).toBe('no-cache, max-age=3600, s-maxage=60')
+ expect(resultHeaders['set-cookie']).toBe('_iidf; HttpOnly; Domain=fpjs.sh')
+ })
+})
+
+describe('getHost', () => {
+ it.each([
+ [
+ {
+ headers: {
+ 'x-forwarded-host': 'fpjs.sh',
+ },
+ },
+ 'fpjs.sh',
+ ],
+ [
+ {
+ headers: {
+ host: 'fpjs.sh',
+ },
+ },
+ 'fpjs.sh',
+ ],
+ ])('returns correct host', (request, expectedHost) => {
+ expect(getHost(request)).toBe(expectedHost)
+ })
+})
diff --git a/proxy/utils/headers.ts b/proxy/utils/headers.ts
new file mode 100644
index 00000000..2e40fdae
--- /dev/null
+++ b/proxy/utils/headers.ts
@@ -0,0 +1,80 @@
+import * as http from 'http'
+import { HttpRequest, HttpRequestHeaders, HttpResponseHeaders } from '@azure/functions'
+import { updateCacheControlHeader } from './cacheControl'
+import { adjustCookies, filterCookie } from './cookies'
+
+const COOKIE_HEADER_NAME = 'set-cookie'
+const CACHE_CONTROL_HEADER_NAME = 'cache-control'
+
+const FPJS_COOKIE_NAME = '_iidt'
+
+// Azure specific headers
+const BLACKLISTED_HEADERS_PREFIXES = ['x-edge-', 'x-arr-', 'x-site', 'x-azure-']
+
+const BLACKLISTED_REQUEST_HEADERS = new Set(['host', 'strict-transport-security'])
+const BLACKLISTED_RESPONSE_HEADERS = new Set(['strict-transport-security', 'transfer-encoding'])
+
+export function filterRequestHeaders(headers: HttpRequestHeaders) {
+ return Object.entries(headers).reduce((result: { [key: string]: string }, [name, value]) => {
+ const headerName = name.toLowerCase()
+
+ if (isHeaderAllowedForRequest(headerName)) {
+ let headerValue = value
+
+ if (headerName === 'cookie') {
+ headerValue = headerValue.split(/; */).join('; ')
+
+ headerValue = filterCookie(headerValue, (key) => key === FPJS_COOKIE_NAME)
+ }
+
+ result[headerName] = headerValue
+ }
+
+ return result
+ }, {})
+}
+
+export function updateResponseHeaders(headers: http.IncomingHttpHeaders, domain: string): HttpResponseHeaders {
+ const result: HttpResponseHeaders = {}
+
+ for (const [key, value] of Object.entries(headers)) {
+ if (!isHeaderAllowedForResponse(key) || !value) {
+ continue
+ }
+
+ switch (key) {
+ case COOKIE_HEADER_NAME: {
+ result[COOKIE_HEADER_NAME] = adjustCookies(Array.isArray(value) ? value : [value], domain)
+
+ break
+ }
+
+ case CACHE_CONTROL_HEADER_NAME: {
+ result[CACHE_CONTROL_HEADER_NAME] = updateCacheControlHeader(value.toString())
+
+ break
+ }
+
+ default:
+ result[key] = value.toString()
+ }
+ }
+
+ return result
+}
+
+export function getHost(request: Pick) {
+ return request.headers['x-forwarded-host'] || request.headers.host
+}
+
+function isHeaderAllowedForResponse(headerName: string) {
+ return !BLACKLISTED_RESPONSE_HEADERS.has(headerName) && !matchesBlacklistedHeaderPrefix(headerName)
+}
+
+function isHeaderAllowedForRequest(headerName: string) {
+ return !BLACKLISTED_REQUEST_HEADERS.has(headerName) && !matchesBlacklistedHeaderPrefix(headerName)
+}
+
+function matchesBlacklistedHeaderPrefix(headerName: string) {
+ return BLACKLISTED_HEADERS_PREFIXES.some((prefix) => headerName.startsWith(prefix))
+}
diff --git a/proxy/utils/traffic.test.ts b/proxy/utils/traffic.test.ts
new file mode 100644
index 00000000..663f4000
--- /dev/null
+++ b/proxy/utils/traffic.test.ts
@@ -0,0 +1,24 @@
+import {
+ addTrafficMonitoringSearchParamsForProCDN,
+ addTrafficMonitoringSearchParamsForVisitorIdRequest,
+} from './traffic'
+
+describe('test procdn call', () => {
+ test('test', () => {
+ const url = new URL('https://fpjs.sh/agent?smth')
+ addTrafficMonitoringSearchParamsForProCDN(url)
+
+ const param = url.searchParams.get('ii')
+ expect(param).toBe('fingerprintjs-pro-azure/__lambda_func_version__/procdn')
+ })
+})
+
+describe('test visitor call', () => {
+ test('test', () => {
+ const url = new URL('https://fpjs.sh/visitorId?smth')
+ addTrafficMonitoringSearchParamsForVisitorIdRequest(url)
+
+ const param = url.searchParams.get('ii')
+ expect(param).toBe('fingerprintjs-pro-azure/__lambda_func_version__/ingress')
+ })
+})
diff --git a/proxy/utils/traffic.ts b/proxy/utils/traffic.ts
new file mode 100644
index 00000000..35bbcc9b
--- /dev/null
+++ b/proxy/utils/traffic.ts
@@ -0,0 +1,14 @@
+const LAMBDA_FUNC_VERSION = '__lambda_func_version__'
+const PARAM_NAME = 'ii'
+
+export function addTrafficMonitoringSearchParamsForProCDN(url: URL) {
+ url.searchParams.append(PARAM_NAME, getTrafficMonitoringValue('procdn'))
+}
+
+export function addTrafficMonitoringSearchParamsForVisitorIdRequest(url: URL) {
+ url.searchParams.append(PARAM_NAME, getTrafficMonitoringValue('ingress'))
+}
+
+function getTrafficMonitoringValue(type: 'procdn' | 'ingress'): string {
+ return `fingerprintjs-pro-azure/${LAMBDA_FUNC_VERSION}/${type}`
+}
diff --git a/rollup.config.mjs b/rollup.config.mjs
index e712eb8b..380dabca 100644
--- a/rollup.config.mjs
+++ b/rollup.config.mjs
@@ -45,7 +45,9 @@ function makeConfig(entryFile, artifactName, functionJsonPath) {
],
}),
jsonPlugin(),
- typescript(),
+ typescript({
+ tsconfig: 'tsconfig.app.json',
+ }),
commonjs(),
nodeResolve({ preferBuiltins: false, modulesOnly: true }),
replace({
@@ -60,6 +62,7 @@ function makeConfig(entryFile, artifactName, functionJsonPath) {
const commonOutput = {
exports: 'named',
+ sourcemap: true,
}
/**
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 00000000..88becbc7
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "types": ["node"]
+ },
+ "exclude": [
+ "dist",
+ "node_modules",
+ "**/*.test.ts"
+ ],
+ "include": [
+ "**/*.ts"
+ ]
+}
diff --git a/tsconfig.json b/tsconfig.json
index 9432b61a..165bce56 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,11 +8,17 @@
"noImplicitReturns": true,
"noImplicitAny": true,
"resolveJsonModule": true,
- "moduleResolution": "node"
+ "moduleResolution": "node",
+ "esModuleInterop": true,
+ "sourceMap": true
},
- "exclude": [
- "dist",
- "node_modules",
- "**/*.test.ts"
+ "include": ["proxy", "management"],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.test.json"
+ }
]
}
diff --git a/tsconfig.test.json b/tsconfig.test.json
new file mode 100644
index 00000000..eda9ef31
--- /dev/null
+++ b/tsconfig.test.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "types": ["node", "jest"]
+ },
+ "extends": "./tsconfig.json",
+ "exclude": [
+ "dist",
+ "node_modules",
+ "**/*.test.ts"
+ ],
+ "include": [
+ "**/*.test.ts"
+ ]
+}
diff --git a/yarn.lock b/yarn.lock
index dc2b53d1..4be17dbe 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1022,14 +1022,6 @@
"@rollup/pluginutils" "^5.0.1"
magic-string "^0.27.0"
-"@rollup/plugin-typescript@^9.0.2":
- version "9.0.2"
- resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-9.0.2.tgz#c0cdfa39e267f306ff7316405a35406d5821eaa7"
- integrity sha512-/sS93vmHUMjzDUsl5scNQr1mUlNE1QjBBvOhmRwJCH8k2RRhDIm3c977B3wdu3t3Ap17W6dDeXP3hj1P1Un1bA==
- dependencies:
- "@rollup/pluginutils" "^5.0.1"
- resolve "^1.22.1"
-
"@rollup/pluginutils@^3.0.8":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
@@ -1171,6 +1163,14 @@
dependencies:
"@types/istanbul-lib-report" "*"
+"@types/jest@^29.4.1":
+ version "29.4.1"
+ resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.4.1.tgz#d76cbd07e5a24c0f4e86c2c0f9ed9e4ed33af3c8"
+ integrity sha512-zDQSWXG+ZkEvs2zFFMszePhx4euKz+Yt3Gg1P+RHjfJBinTTr6L2DEyovO4V/WrKXuF0Dgn56GWGZPDa6TW9eQ==
+ dependencies:
+ expect "^29.0.0"
+ pretty-format "^29.0.0"
+
"@types/json-schema@^7.0.9":
version "7.0.11"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
@@ -2298,7 +2298,7 @@ exit@^0.1.2:
resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==
-expect@^29.5.0:
+expect@^29.0.0, expect@^29.5.0:
version "29.5.0"
resolved "https://registry.yarnpkg.com/expect/-/expect-29.5.0.tgz#68c0509156cb2a0adb8865d413b137eeaae682f7"
integrity sha512-yM7xqUrCO2JdpFo4XpM82t+PJBFybdqoQuJLDGeDX2ij8NZzqRHyu3Hp188/JX7SWqud+7t4MUdvcgGBICMHZg==
@@ -4204,7 +4204,7 @@ prettier@^2.8.0:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.4.tgz#34dd2595629bfbb79d344ac4a91ff948694463c3"
integrity sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw==
-pretty-format@^29.5.0:
+pretty-format@^29.0.0, pretty-format@^29.5.0:
version "29.5.0"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.5.0.tgz#283134e74f70e2e3e7229336de0e4fce94ccde5a"
integrity sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw==