diff --git a/package-lock.json b/package-lock.json index dc0a0ca..5acedb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fullfabric/public-api", - "version": "1.7.0", + "version": "1.7.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@fullfabric/public-api", - "version": "1.7.0", + "version": "1.7.1", "license": "MIT", "dependencies": { "assert": "^2.0.0", diff --git a/package.json b/package.json index 2bb97e0..1041de3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fullfabric/public-api", - "version": "1.7.0", + "version": "1.7.1", "description": "Function wrappers for the FullFabric public API.", "type": "module", "browser": "dist/index.js", diff --git a/spec/utils/getPageDigest.spec.js b/spec/utils/getPageDigest.spec.js new file mode 100644 index 0000000..29e170d --- /dev/null +++ b/spec/utils/getPageDigest.spec.js @@ -0,0 +1,64 @@ +import getPageDigest from '../../src/utils/getPageDigest' + +describe('utils.getPageDigest(opts)', () => { + let oldUserAgent + + beforeAll(() => { + oldUserAgent = navigator.userAgent + + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: + 'Mozilla/5.0 (darwin) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/20.0.3' + }) + }) + + afterAll(() => { + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: oldUserAgent + }) + }) + + describe('when no request-id meta element is available', () => { + it('returns null', async () => { + expect(await getPageDigest()).toBeNull() + }) + + it('can use a given requestId', async () => { + expect(await getPageDigest({ requestId: '12345' })).toEqual( + '7f1c12a5c58c6f29bcaeaf5c6382fef305660da38f2d0919deac9fc4a58705d4' + ) + }) + }) + + describe('when a request-id meta element is available', () => { + beforeEach(() => { + document.head.innerHTML = ` + + ` + }) + + it("uses the request-id meta element's content", async () => { + expect(await getPageDigest()).toEqual( + '7f1c12a5c58c6f29bcaeaf5c6382fef305660da38f2d0919deac9fc4a58705d4' + ) + }) + + it('can use a given requestId instead', async () => { + expect(await getPageDigest({ requestId: '54321' })).toEqual( + '0b3adfbef9370498eb8a03f99fba9e86a41b89d1beef6499646d20090db0739b' + ) + }) + + it("can suffix the digest with another digest of a source's type and ID", async () => { + expect( + await getPageDigest({ sourceType: 'form', sourceId: '12345' }) + ).toEqual( + '7f1c12a5c58c6f29bcaeaf5c6382fef305660da38f2d0919deac9fc4a58705d4' + + ':' + + '0daba4f5e1a46e9e2be8bd4aac8651a020267a7da81690be4f093abe635b6aa1' + ) + }) + }) +}) diff --git a/src/utils/getPageDigest.js b/src/utils/getPageDigest.js new file mode 100644 index 0000000..e8c8a2b --- /dev/null +++ b/src/utils/getPageDigest.js @@ -0,0 +1,44 @@ +/** + * Generates a digest for anti-spam purposes. + * + * This algorithm should match the backend's exactly, otherwise the requests + * will be unauthorized. + * + * By default, each digest is unique to a page load. When there are multiple + * submissions possible in the same page, e.g. via multiple forms, a source can + * be passed to generate a digest unique to it. + * + * @param {String} [requestId] ID of the request, pass if originating from an API request. + * @param {String} [sourceType] Type of source for the digest, should match backend. + * @param {String} [sourceId] ID of source for the digest, should match backend. + * @returns {String} The generated digest. + */ +export default async function getPageDigest({ + requestId, + sourceType, + sourceId +} = {}) { + requestId ||= document.querySelector("meta[name='request-id']")?.content || '' + + // If there's no requestId, no point generating a digest, won't ever match + // the backend. Same if we can't access the crypto API. + if (!requestId || !crypto?.subtle?.digest) return null + + let digest = await makeDigest(`${navigator.userAgent}:${requestId}`) + + if (sourceType && sourceId) { + const suffix = `${sourceType}:${sourceId}` + digest += `:${await makeDigest(suffix)}` + } + + return digest +} + +async function makeDigest(str) { + const message = new TextEncoder().encode(str) + const digestRaw = await crypto.subtle.digest('SHA-256', message) + + return Array.from(new Uint8Array(digestRaw)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} diff --git a/src/utils/index.js b/src/utils/index.js index e0c3f89..087a944 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,3 +1,4 @@ +export { default as getPageDigest } from './getPageDigest' export { default as checkResponse } from './checkResponse' export { default as extractHeaders } from './extractHeaders' export { default as url } from './url'