@opencloning/utils 1.9.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # @opencloning/utils
2
2
 
3
+ ## 2.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - [#778](https://github.com/OpenCloning/OpenCloning_frontend/pull/778) [`fe1d81c`](https://github.com/OpenCloning/OpenCloning_frontend/commit/fe1d81c6f4b85f6317a2a9e46cc68c571c79c394) Thanks [@manulera](https://github.com/manulera)! - Only send the auth token to the backend, and rename the http client APIs.
8
+
9
+ - `@opencloning/utils`: `@opencloning/utils/httpClients` exports `getBackendHttpClient(backendUrl, { authenticated })`, a client that can only reach the backend (`allowAbsoluteUrls: false`) and is the only one carrying the auth token, and `getExternalHttpClient()` for whitelisted external urls. Replaces `@opencloning/utils/getHttpClient` (`getHttpClient`, `getAuthenticatedHttpClient`). `attachAuthInterceptors` refuses clients that could send the token elsewhere.
10
+ - `@opencloning/ui` (breaking): `useHttpClient` is renamed `useBackendHttpClient` and takes urls relative to `backendUrl`; `useBackendRoute` is removed; `useBackendAPI` is renamed `useSourceRequest` and `sendPostRequest` is renamed `sendSourceRequest`.
11
+ - `@opencloning/opencloningdb`, `@opencloning/opencloning-elabftw`: clients can no longer be pointed at other hosts.
12
+
13
+ ### Minor Changes
14
+
15
+ - [#778](https://github.com/OpenCloning/OpenCloning_frontend/pull/778) [`fe1d81c`](https://github.com/OpenCloning/OpenCloning_frontend/commit/fe1d81c6f4b85f6317a2a9e46cc68c571c79c394) Thanks [@manulera](https://github.com/manulera)! - Encode values interpolated into request URL paths to prevent client-side path traversal. A sample UID such as `../workspaces/1/users/5` could make a user's own request hit a different endpoint, e.g. "delete UID" removing a workspace member.
16
+
17
+ - `@opencloning/utils`: new `encodePathSegment` (`@opencloning/utils/urlUtils`), which applies `encodeURIComponent` and rejects `.` and `..`.
18
+ - `@opencloning/opencloningdb`: all path parameters in `endpoints.js` are encoded.
19
+ - `@opencloning/opencloning-elabftw`: eLabFTW item and upload paths are encoded.
20
+ - `@opencloning/ui`: the `example`, `key` and `template` URL parameters are encoded when loading examples and templates.
21
+
22
+ ### Patch Changes
23
+
24
+ - [#778](https://github.com/OpenCloning/OpenCloning_frontend/pull/778) [`fe1d81c`](https://github.com/OpenCloning/OpenCloning_frontend/commit/fe1d81c6f4b85f6317a2a9e46cc68c571c79c394) Thanks [@manulera](https://github.com/manulera)! - Safer white-list url checking
25
+
26
+ - Updated dependencies []:
27
+ - @opencloning/store@2.0.0
28
+
29
+ ## 1.10.0
30
+
31
+ ### Patch Changes
32
+
33
+ - Updated dependencies []:
34
+ - @opencloning/store@1.10.0
35
+
36
+ ## 1.9.5
37
+
38
+ ### Patch Changes
39
+
40
+ - [#771](https://github.com/OpenCloning/OpenCloning_frontend/pull/771) [`73363e7`](https://github.com/OpenCloning/OpenCloning_frontend/commit/73363e7a7f7e494e20a54bd87e5dba97f8a9197e) Thanks [@manulera](https://github.com/manulera)! - Keep OIDC sessions signed in by requesting a token on each API call instead of reusing a stored JWT
41
+
42
+ - Updated dependencies []:
43
+ - @opencloning/store@1.9.5
44
+
45
+ ## 1.9.4
46
+
47
+ ### Patch Changes
48
+
49
+ - Updated dependencies []:
50
+ - @opencloning/store@1.9.4
51
+
52
+ ## 1.9.3
53
+
54
+ ### Patch Changes
55
+
56
+ - [#762](https://github.com/OpenCloning/OpenCloning_frontend/pull/762) [`df69a1b`](https://github.com/OpenCloning/OpenCloning_frontend/commit/df69a1bfb3b3128e93d89eef48a29f81a54ebb82) Thanks [@manulera](https://github.com/manulera)! - Fix QuotaExceededError when adding more than ~10 AB1/sequencing files (OpenCloning/OpenCloning#82). The raw contents of the verification files used to be persisted in Web Storage (localStorage, later sessionStorage), which has a quota of ~5 MB, so submitting around 10+ AB1 files failed with `QuotaExceededError` and the files/alignments disappeared. The contents are now kept in an in-memory store for the duration of the tab session, which has no quota.
57
+
58
+ - Updated dependencies [[`df69a1b`](https://github.com/OpenCloning/OpenCloning_frontend/commit/df69a1bfb3b3128e93d89eef48a29f81a54ebb82)]:
59
+ - @opencloning/store@1.9.3
60
+
61
+ ## 1.9.2
62
+
63
+ ### Patch Changes
64
+
65
+ - Updated dependencies [[`bc045f9`](https://github.com/OpenCloning/OpenCloning_frontend/commit/bc045f9e74b4d5b84250dcb5a77b93de99e4d684)]:
66
+ - @opencloning/store@1.9.2
67
+
3
68
  ## 1.9.1
4
69
 
5
70
  ### Patch Changes
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "license": "MIT",
3
3
  "name": "@opencloning/utils",
4
- "version": "1.9.1",
4
+ "version": "2.0.0",
5
5
  "type": "module",
6
6
  "main": "./src/utils/index.js",
7
7
  "exports": {
8
8
  ".": "./src/utils/index.js",
9
9
  "./readNwrite": "./src/utils/readNwrite.js",
10
+ "./verificationFileStore": "./src/utils/verificationFileStore.js",
10
11
  "./network": "./src/utils/network.js",
11
- "./getHttpClient": "./src/utils/getHttpClient.js",
12
+ "./httpClients": "./src/utils/httpClients.js",
12
13
  "./httpClientAuth": "./src/utils/httpClientAuth.js",
14
+ "./urlUtils": "./src/utils/urlUtils.js",
13
15
  "./sourceFunctions": "./src/utils/sourceFunctions.js",
14
16
  "./error2String": "./src/utils/error2String.js",
15
17
  "./enzyme_utils": "./src/utils/enzyme_utils.js",
@@ -31,7 +33,7 @@
31
33
  "directory": "packages/utils"
32
34
  },
33
35
  "dependencies": {
34
- "@opencloning/store": "1.9.1",
36
+ "@opencloning/store": "2.0.0",
35
37
  "@teselagen/bio-parsers": "^0.4.38",
36
38
  "@teselagen/sequence-utils": "^0.3.42",
37
39
  "@zip.js/zip.js": "^2.8.26",
@@ -1,20 +1,33 @@
1
1
  const AUTH_INTERCEPTORS_ATTACHED = '__opencloningHttpClientAuthAttached';
2
2
 
3
3
  let unauthorizedHandler = null;
4
+ let tokenGetter = null;
4
5
 
5
6
  export function setHttpClientUnauthorizedHandler(fn) {
6
7
  unauthorizedHandler = fn;
7
8
  }
8
9
 
9
- export function attachAuthInterceptors(client, { tokenStorageKey = 'token' } = {}) {
10
+ export function setHttpClientTokenGetter(fn) {
11
+ tokenGetter = fn;
12
+ }
13
+
14
+ /**
15
+ * Attach the bearer token to requests, and call the unauthorized handler on 401s.
16
+ * The client must have a baseURL and allowAbsoluteUrls: false, so that every request
17
+ * goes to the backend and the token can never be sent to a third party.
18
+ */
19
+ export function attachAuthInterceptors(client) {
10
20
  if (client[AUTH_INTERCEPTORS_ATTACHED]) {
11
21
  return client;
12
22
  }
23
+ if (!client.defaults.baseURL || client.defaults.allowAbsoluteUrls !== false) {
24
+ throw new Error('Auth interceptors require a client with baseURL and allowAbsoluteUrls: false');
25
+ }
13
26
 
14
27
  client[AUTH_INTERCEPTORS_ATTACHED] = true;
15
28
 
16
- client.interceptors.request.use((config) => {
17
- const token = localStorage.getItem(tokenStorageKey);
29
+ client.interceptors.request.use(async (config) => {
30
+ const token = await tokenGetter?.();
18
31
  if (token) {
19
32
  config.headers = config.headers ?? {};
20
33
  config.headers.Authorization = `Bearer ${token}`;
@@ -1,34 +1,60 @@
1
1
  import axios from 'axios';
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
3
 
4
- import { attachAuthInterceptors, setHttpClientUnauthorizedHandler } from './httpClientAuth';
4
+ import { attachAuthInterceptors, setHttpClientTokenGetter, setHttpClientUnauthorizedHandler } from './httpClientAuth';
5
+
6
+ const createClient = () => axios.create({ baseURL: 'http://localhost:8000/db', allowAbsoluteUrls: false });
5
7
 
6
8
  describe('httpClientAuth', () => {
7
9
  beforeEach(() => {
8
- localStorage.clear();
9
10
  setHttpClientUnauthorizedHandler(null);
11
+ setHttpClientTokenGetter(null);
10
12
  });
11
13
 
12
14
  afterEach(() => {
13
- localStorage.clear();
14
15
  setHttpClientUnauthorizedHandler(null);
16
+ setHttpClientTokenGetter(null);
15
17
  });
16
18
 
17
- it('adds the bearer token to authenticated requests', () => {
18
- const client = attachAuthInterceptors(axios.create());
19
+ it('leaves request headers unchanged when no token getter is set', async () => {
20
+ const client = attachAuthInterceptors(createClient());
19
21
  const requestFulfilled = client.interceptors.request.handlers[0].fulfilled;
20
22
  const config = { headers: {} };
21
23
 
22
- localStorage.setItem('token', '__TEST_TOKEN__');
24
+ const result = await requestFulfilled(config);
23
25
 
24
- const result = requestFulfilled(config);
26
+ expect(result).toBe(config);
27
+ expect(config.headers.Authorization).toBeUndefined();
28
+ });
29
+
30
+ it('adds the bearer token from a sync token getter', async () => {
31
+ const client = attachAuthInterceptors(createClient());
32
+ const requestFulfilled = client.interceptors.request.handlers[0].fulfilled;
33
+ const config = { headers: {} };
34
+
35
+ setHttpClientTokenGetter(() => '__TEST_TOKEN__');
36
+
37
+ const result = await requestFulfilled(config);
25
38
 
26
39
  expect(result).toBe(config);
27
40
  expect(config.headers.Authorization).toBe('Bearer __TEST_TOKEN__');
28
41
  });
29
42
 
43
+ it('adds the bearer token from an async token getter', async () => {
44
+ const client = attachAuthInterceptors(createClient());
45
+ const requestFulfilled = client.interceptors.request.handlers[0].fulfilled;
46
+ const config = { headers: {} };
47
+
48
+ setHttpClientTokenGetter(async () => 'fresh-token');
49
+
50
+ const result = await requestFulfilled(config);
51
+
52
+ expect(result).toBe(config);
53
+ expect(config.headers.Authorization).toBe('Bearer fresh-token');
54
+ });
55
+
30
56
  it('calls the shared unauthorized handler for 401 responses', async () => {
31
- const client = attachAuthInterceptors(axios.create());
57
+ const client = attachAuthInterceptors(createClient());
32
58
  const responseRejected = client.interceptors.response.handlers[0].rejected;
33
59
  const handler = vi.fn();
34
60
  const error = { response: { status: 401 } };
@@ -40,8 +66,14 @@ describe('httpClientAuth', () => {
40
66
  expect(handler).toHaveBeenCalledTimes(1);
41
67
  });
42
68
 
69
+ it('refuses clients that could send the token to other hosts', () => {
70
+ expect(() => attachAuthInterceptors(axios.create())).toThrow('allowAbsoluteUrls');
71
+ expect(() => attachAuthInterceptors(axios.create({ baseURL: 'http://localhost:8000' }))).toThrow('allowAbsoluteUrls');
72
+ expect(() => attachAuthInterceptors(axios.create({ allowAbsoluteUrls: false }))).toThrow('allowAbsoluteUrls');
73
+ });
74
+
43
75
  it('does not attach duplicate interceptors to the same client', () => {
44
- const client = axios.create();
76
+ const client = createClient();
45
77
 
46
78
  attachAuthInterceptors(client);
47
79
  attachAuthInterceptors(client);
@@ -0,0 +1,48 @@
1
+ import axios from 'axios';
2
+ import { attachAuthInterceptors } from './httpClientAuth';
3
+ import { urlMatchesPrefix } from './urlUtils';
4
+ import urlWhitelist from '../config/urlWhitelist';
5
+
6
+ /**
7
+ * Client for requests that do not go to the backend (external services, static files):
8
+ * no auth token, and only whitelisted urls (plus the current origin) are allowed.
9
+ * For the backend, use getBackendHttpClient.
10
+ */
11
+ export function getExternalHttpClient() {
12
+ const baseURL = import.meta.env.BASE_URL || null;
13
+ const whitelist = [...urlWhitelist, window.location.origin];
14
+ if (baseURL) {
15
+ whitelist.push(baseURL);
16
+ }
17
+
18
+ const client = axios.create();
19
+
20
+ client.interceptors.request.use((config) => {
21
+ const url = new URL(config.url, config.baseURL || window.location.origin);
22
+
23
+ if (!whitelist.some((whitelistedUrl) => urlMatchesPrefix(url.href, whitelistedUrl))) {
24
+ return Promise.reject(new Error(`Request blocked: URL not in whitelist ${url.href}`));
25
+ }
26
+
27
+ return config;
28
+ }, (error) => Promise.reject(error));
29
+
30
+ return client;
31
+ }
32
+
33
+ /**
34
+ * Client that can only reach the backend: urls are relative to backendUrl, and
35
+ * allowAbsoluteUrls: false makes axios append even absolute urls to backendUrl.
36
+ * Only these clients carry the auth token. Use getExternalHttpClient() for external urls.
37
+ */
38
+ export function getBackendHttpClient(backendUrl, { authenticated = false } = {}) {
39
+ if (!backendUrl) {
40
+ throw new Error('backendUrl is required to create a backend http client');
41
+ }
42
+ // Handles both absolute and relative urls
43
+ const client = axios.create({ baseURL: new URL(backendUrl, window.location.origin).href, allowAbsoluteUrls: false });
44
+ if (authenticated) {
45
+ attachAuthInterceptors(client);
46
+ }
47
+ return client;
48
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { getExternalHttpClient, getBackendHttpClient } from './httpClients';
4
+
5
+ describe('getExternalHttpClient', () => {
6
+ it('creates a generic client without auth interceptors', () => {
7
+ const client = getExternalHttpClient();
8
+
9
+ expect(client.interceptors.request.handlers.filter(Boolean)).toHaveLength(1);
10
+ expect(client.interceptors.response.handlers.filter(Boolean)).toHaveLength(0);
11
+ });
12
+
13
+
14
+ it('blocks urls that only look like whitelisted ones', async () => {
15
+ const client = getExternalHttpClient();
16
+ const requestFulfilled = client.interceptors.request.handlers[0].fulfilled;
17
+
18
+ const allowed = { url: 'https://assets.opencloning.org/OpenCloning-submission/index.json' };
19
+ expect(await requestFulfilled(allowed)).toBe(allowed);
20
+
21
+ for (const url of [
22
+ 'https://assets.opencloning.org.evil.org/OpenCloning-submission/index.json',
23
+ 'https://assets.opencloning.org@evil.org/OpenCloning-submission/index.json',
24
+ 'https://assets-opencloning.org/OpenCloning-submission/index.json',
25
+ ]) {
26
+ await expect(requestFulfilled({ url })).rejects.toThrow('Request blocked');
27
+ }
28
+ });
29
+ });
30
+
31
+ describe('getBackendHttpClient', () => {
32
+ it('creates an authenticated backend client locked to the backend url', () => {
33
+ const client = getBackendHttpClient('http://localhost:8000/cloning', { authenticated: true });
34
+
35
+ expect(client.defaults.baseURL).toBe('http://localhost:8000/cloning');
36
+ expect(client.defaults.allowAbsoluteUrls).toBe(false);
37
+ expect(client.interceptors.request.handlers.filter(Boolean)).toHaveLength(1);
38
+ expect(client.interceptors.response.handlers.filter(Boolean)).toHaveLength(1);
39
+ });
40
+
41
+ it('creates an unauthenticated backend client without auth interceptors', () => {
42
+ const client = getBackendHttpClient('http://localhost:8000/cloning');
43
+
44
+ expect(client.interceptors.request.handlers.filter(Boolean)).toHaveLength(0);
45
+ expect(client.interceptors.response.handlers.filter(Boolean)).toHaveLength(0);
46
+ });
47
+
48
+ it('keeps every request under the backend url, even absolute ones', () => {
49
+ const client = getBackendHttpClient('http://localhost:8000/cloning', { authenticated: true });
50
+
51
+ expect(client.getUri({ url: 'version' })).toBe('http://localhost:8000/cloning/version');
52
+ expect(client.getUri({ url: 'https://evil.org/x' })).toBe('http://localhost:8000/cloning/https://evil.org/x');
53
+ });
54
+
55
+ it('throws when the backend url is not set', () => {
56
+ expect(() => getBackendHttpClient(undefined)).toThrow('backendUrl is required');
57
+ expect(() => getBackendHttpClient('', { authenticated: true })).toThrow('backendUrl is required');
58
+ });
59
+
60
+ it('resolves relative backend urls against the current origin and keeps absolute ones', () => {
61
+ const { origin } = window.location;
62
+
63
+ const relative = getBackendHttpClient('/cloning');
64
+ expect(relative.defaults.baseURL).toBe(`${origin}/cloning`);
65
+ expect(relative.getUri({ url: 'version' })).toBe(`${origin}/cloning/version`);
66
+ expect(relative.getUri({ url: 'https://evil.org/x' })).toBe(`${origin}/cloning/https://evil.org/x`);
67
+
68
+ const absolute = getBackendHttpClient('https://api.example.com/cloning');
69
+ expect(absolute.defaults.baseURL).toBe('https://api.example.com/cloning');
70
+ });
71
+ });
@@ -3,7 +3,7 @@
3
3
 
4
4
  export * from './readNwrite.js';
5
5
  export * from './network.js';
6
- export * from './getHttpClient.js';
6
+ export * from './httpClients.js';
7
7
  export * from './httpClientAuth.js';
8
8
  export * from './sourceFunctions.js';
9
9
  export { default as error2String } from './error2String.js';
@@ -1,6 +1,6 @@
1
- import getHttpClient from './getHttpClient';
1
+ import { getExternalHttpClient } from './httpClients';
2
2
 
3
- const httpClient = getHttpClient();
3
+ const httpClient = getExternalHttpClient();
4
4
 
5
5
 
6
6
  export async function taxonSuggest(userInput) {
@@ -10,6 +10,10 @@ import {
10
10
  } from '@zip.js/zip.js';
11
11
  import { tidyUpSequenceData } from '@teselagen/sequence-utils';
12
12
  import { isEqual } from 'lodash-es';
13
+ import {
14
+ getVerificationFileContent,
15
+ setVerificationFileContent,
16
+ } from './verificationFileStore';
13
17
 
14
18
  configure({
15
19
  useWebWorkers: false,
@@ -95,10 +99,10 @@ export const downloadStateAsZip = async (cloningState, zipFileName = 'cloning_st
95
99
  const files2write = [
96
100
  { name: 'cloning_strategy.json', reader: new TextReader(prettyPrintJson(output)) },
97
101
  ...fileNames.map((fileName) => {
98
- const base64Content = sessionStorage.getItem(fileName);
102
+ const base64Content = getVerificationFileContent(fileName);
99
103
  if (!base64Content) {
100
104
  const nameOnly = fileName.replace(/verification-\d+-/, '');
101
- throw new Error(`File ${nameOnly} not found in session storage`);
105
+ throw new Error(`File ${nameOnly} not found in the verification file store`);
102
106
  }
103
107
  return { name: fileName, reader: new BlobReader(base64ToBlob(base64Content)) };
104
108
  }),
@@ -154,7 +158,7 @@ export const downloadCloningStrategyAsSvg = async (fileName) => {
154
158
  downloadTextFile(svgString, fileName);
155
159
  };
156
160
 
157
- export async function loadHistoryFile(file) {
161
+ export async function parseHistoryFile(file) {
158
162
  const isZipFile = file.name.endsWith('.zip');
159
163
  const isJsonFile = file.name.endsWith('.json');
160
164
 
@@ -233,14 +237,19 @@ export async function loadHistoryFile(file) {
233
237
  return { cloningStrategy: newCloningStrategy, verificationFiles };
234
238
  }
235
239
 
236
- export const loadFilesToSessionStorage = async (files, idShift = 0) => {
240
+ export const loadFilesToVerificationStore = async (files, idShift = 0) => {
237
241
  await Promise.all(files.map(async (file) => {
238
242
  const fileContent = await file2base64(file);
239
243
  const filename = file.name.replace(/verification-(\d+)-/, (match, num) => `verification-${parseInt(num, 10) + idShift}-`);
240
- sessionStorage.setItem(filename, fileContent);
244
+ setVerificationFileContent(filename, fileContent);
241
245
  }));
242
246
  };
243
247
 
248
+ // Deprecated alias kept for backwards compatibility with the published
249
+ // @opencloning/utils package. The contents are now kept in an in-memory store
250
+ // instead of sessionStorage (see verificationFileStore.js).
251
+ export const loadFilesToSessionStorage = loadFilesToVerificationStore;
252
+
244
253
  export function getVerificationFileName({ sequence_id, file_name }) {
245
254
  return `verification-${sequence_id}-${file_name}`;
246
255
  }
@@ -275,7 +284,7 @@ export function formatTemplate(data, url) {
275
284
  const segments = url.split('/');
276
285
  const kitUrl = segments[segments.length - 3];
277
286
  const rootGithubUrl = 'https://assets.opencloning.org/OpenCloning-submission/submissions';
278
- newData.sources = newData.sources.map((s) => ((!Boolean(s.image) || s.image[0] === null) ? s : {
287
+ newData.sources = newData.sources.map((s) => ((!s.image || s.image[0] === null) ? s : {
279
288
  ...s, image: [`${rootGithubUrl}/${kitUrl}/${s.image[0]}`, s.image[1]],
280
289
  }));
281
290
  return newData;
@@ -1,8 +1,9 @@
1
1
  import { cloneDeep } from 'lodash-es';
2
- import { base64ToBlob, downloadStateAsJson, downloadStateAsZip, loadFilesToSessionStorage } from './readNwrite';
2
+ import { base64ToBlob, downloadStateAsJson, downloadStateAsZip, loadFilesToVerificationStore } from './readNwrite';
3
3
  import { cloningActions } from '@opencloning/store/cloning';
4
4
  import { collectParentSequencesAndSources, getSubState, mergeStates } from './network';
5
5
  import { getVerificationFileName } from './readNwrite';
6
+ import { getVerificationFileContent } from './verificationFileStore';
6
7
  import { getUsedPrimerIds } from '@opencloning/store/cloning_utils';
7
8
 
8
9
  const { setState: setCloningState } = cloningActions;
@@ -39,11 +40,11 @@ export const CopySequenceThunk = (sequenceId) => async (dispatch, getState) => {
39
40
  files: filesToCopy,
40
41
  });
41
42
  const files = filesToCopy.map((f) => new File(
42
- [base64ToBlob(sessionStorage.getItem(getVerificationFileName(f)))],
43
+ [base64ToBlob(getVerificationFileContent(getVerificationFileName(f)))],
43
44
  getVerificationFileName(f),
44
45
  { type: 'application/octet-stream' },
45
46
  ));
46
47
  const { mergedState, idShift } = mergeStates(newState, state.cloning, false);
47
48
  dispatch(setCloningState(mergedState));
48
- await loadFilesToSessionStorage(files, idShift);
49
+ await loadFilesToVerificationStore(files, idShift);
49
50
  };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Whether `url` starts with `prefix`, comparing the normalized urls (relative urls are
3
+ * resolved against the current origin). Normalizing makes a host-only prefix end in "/",
4
+ * so https://example.com matches https://example.com/path but not
5
+ * https://example.com.evil.org or https://example.com@evil.org.
6
+ */
7
+ export function urlMatchesPrefix(url, prefix) {
8
+ return new URL(url, window.location.origin).href.startsWith(new URL(prefix, window.location.origin).href);
9
+ }
10
+
11
+ /**
12
+ * Encode a value to be inserted as a single segment of a URL path, e.g.
13
+ * `/sequences/${encodePathSegment(id)}`. Prevents client-side path traversal (CSPT):
14
+ * "/", "?" and "#" are encoded, and "." / ".." are rejected because
15
+ * encodeURIComponent leaves them unchanged and the browser would resolve them.
16
+ */
17
+ export function encodePathSegment(value) {
18
+ const segment = String(value);
19
+ if (segment === '.' || segment === '..') {
20
+ throw new Error(`Invalid URL path segment: "${segment}"`);
21
+ }
22
+ return encodeURIComponent(segment);
23
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { encodePathSegment, urlMatchesPrefix } from './urlUtils';
4
+
5
+ describe('urlMatchesPrefix', () => {
6
+ it('matches urls under the prefix', () => {
7
+ expect(urlMatchesPrefix('https://example.com/a/b', 'https://example.com/a')).toBe(true);
8
+ expect(urlMatchesPrefix('https://example.com/a', 'https://example.com/a')).toBe(true);
9
+ expect(urlMatchesPrefix('https://example.com/a?x=1', 'https://example.com/a')).toBe(true);
10
+ expect(urlMatchesPrefix('https://example.com/a/b', 'https://example.com')).toBe(true);
11
+ expect(urlMatchesPrefix('https://example.com/esearch.fcgi?db=x', 'https://example.com/esearch.fcgi')).toBe(true);
12
+ });
13
+
14
+ it('does not match lookalike hosts or paths', () => {
15
+ expect(urlMatchesPrefix('https://example.com.evil.org/a', 'https://example.com')).toBe(false);
16
+ expect(urlMatchesPrefix('https://example.com@evil.org/a', 'https://example.com')).toBe(false);
17
+ expect(urlMatchesPrefix('https://exampleXcom/a', 'https://example.com')).toBe(false);
18
+ expect(urlMatchesPrefix('https://other.com/https://example.com/a', 'https://example.com/a')).toBe(false);
19
+ });
20
+
21
+ it('resolves relative urls and prefixes against the current origin', () => {
22
+ const { origin } = window.location;
23
+ expect(urlMatchesPrefix('/backend/db/sequences', '/backend/db')).toBe(true);
24
+ expect(urlMatchesPrefix(`${origin}/backend/db/sequences`, '/backend/db')).toBe(true);
25
+ expect(urlMatchesPrefix('https://evil.org/backend/db/sequences', '/backend/db')).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe('encodePathSegment', () => {
30
+ it('encodes characters that would change the path', () => {
31
+ expect(encodePathSegment('../workspaces/1/users/5')).toBe('..%2Fworkspaces%2F1%2Fusers%2F5');
32
+ expect(encodePathSegment('a?b#c')).toBe('a%3Fb%23c');
33
+ expect(encodePathSegment(12)).toBe('12');
34
+ });
35
+
36
+ it('rejects dot segments', () => {
37
+ expect(() => encodePathSegment('.')).toThrow();
38
+ expect(() => encodePathSegment('..')).toThrow();
39
+ });
40
+
41
+ it('keeps the request path when resolved by the browser', () => {
42
+ for (const value of ['../workspaces/1', '..%2F..', 'a/../../b', '...']) {
43
+ const url = new URL(`/db/sequence_samples/${encodePathSegment(value)}`, 'https://example.com');
44
+ expect(url.pathname.startsWith('/db/sequence_samples/')).toBe(true);
45
+ // Only has 3 "/" > "/db/sequence_samples/"
46
+ expect(url.pathname.split('/')).toHaveLength(4);
47
+ }
48
+ });
49
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * In-memory storage for the contents of verification (sequencing) files.
3
+ *
4
+ * The raw contents of uploaded sequencing files (e.g. AB1 files) are kept in
5
+ * memory as base64 strings, keyed by `verification-${sequence_id}-${file_name}`
6
+ * (see getVerificationFileName in readNwrite.js).
7
+ *
8
+ * These contents used to be persisted in Web Storage (localStorage in early
9
+ * versions, sessionStorage since the monorepo switch). Both have a quota of
10
+ * roughly 5 MB per origin, so submitting around 10+ AB1 files exceeded the
11
+ * quota, failed with a QuotaExceededError and made the files/alignments
12
+ * silently disappear:
13
+ * https://github.com/OpenCloning/OpenCloning/issues/82
14
+ *
15
+ * The contents are only needed while the tab is open (to display alignments,
16
+ * download files, export zips or realign when new files are submitted). After
17
+ * a page reload the cloning strategy has to be loaded from a file or the
18
+ * database again, and the verification files are loaded along with it, so an
19
+ * in-memory store with the same tab-lifetime semantics but no quota is
20
+ * sufficient.
21
+ */
22
+
23
+ const fileContents = new Map();
24
+
25
+ /**
26
+ * Store the base64 content of a verification file.
27
+ * @param {string} key verification file key (see getVerificationFileName)
28
+ * @param {string} base64Content base64-encoded file content
29
+ */
30
+ export function setVerificationFileContent(key, base64Content) {
31
+ fileContents.set(key, base64Content);
32
+ }
33
+
34
+ /**
35
+ * Get the base64 content of a verification file, or null if it is not stored.
36
+ * @param {string} key verification file key (see getVerificationFileName)
37
+ */
38
+ export function getVerificationFileContent(key) {
39
+ return fileContents.has(key) ? fileContents.get(key) : null;
40
+ }
41
+
42
+ /**
43
+ * Remove the content of a single verification file.
44
+ * @param {string} key verification file key (see getVerificationFileName)
45
+ */
46
+ export function removeVerificationFileContent(key) {
47
+ fileContents.delete(key);
48
+ }
49
+
50
+ /**
51
+ * Remove the contents of all verification files associated to a sequence.
52
+ * @param {number} sequenceId id of the sequence
53
+ * @param {string} [fileName] if given, only files whose key starts with
54
+ * `verification-${sequenceId}-${fileName}` are removed (matches the previous
55
+ * sessionStorage-based behaviour)
56
+ */
57
+ export function removeVerificationFileContents(sequenceId, fileName = null) {
58
+ const prefix = `verification-${sequenceId}-`;
59
+ const query = fileName ? prefix + fileName : prefix;
60
+ for (const key of [...fileContents.keys()]) {
61
+ if (key.startsWith(query)) {
62
+ fileContents.delete(key);
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Remove all stored verification file contents.
69
+ */
70
+ export function clearVerificationFileContents() {
71
+ fileContents.clear();
72
+ }
73
+
74
+ // Expose the store when running in Cypress, following the existing pattern of
75
+ // exposing window.store in apps/opencloning/src/index.jsx. This lets e2e tests
76
+ // assert on the stored file contents.
77
+ if (typeof window !== 'undefined' && window.Cypress) {
78
+ window.verificationFileStore = {
79
+ get: getVerificationFileContent,
80
+ set: setVerificationFileContent,
81
+ remove: removeVerificationFileContent,
82
+ removeBySequence: removeVerificationFileContents,
83
+ clear: clearVerificationFileContents,
84
+ size: () => fileContents.size,
85
+ };
86
+ }
@@ -0,0 +1,101 @@
1
+ import {
2
+ setVerificationFileContent,
3
+ getVerificationFileContent,
4
+ removeVerificationFileContent,
5
+ removeVerificationFileContents,
6
+ clearVerificationFileContents,
7
+ } from './verificationFileStore';
8
+
9
+ describe('verificationFileStore (in-memory verification file storage)', () => {
10
+ beforeEach(() => {
11
+ clearVerificationFileContents();
12
+ });
13
+
14
+ it('stores and retrieves a file content', () => {
15
+ setVerificationFileContent('verification-1-file.ab1', 'aGVsbG8=');
16
+ expect(getVerificationFileContent('verification-1-file.ab1')).toBe('aGVsbG8=');
17
+ });
18
+
19
+ it('returns null for a missing key', () => {
20
+ expect(getVerificationFileContent('verification-1-missing.ab1')).toBeNull();
21
+ });
22
+
23
+ it('overwrites an existing key', () => {
24
+ setVerificationFileContent('verification-1-file.ab1', 'first');
25
+ setVerificationFileContent('verification-1-file.ab1', 'second');
26
+ expect(getVerificationFileContent('verification-1-file.ab1')).toBe('second');
27
+ });
28
+
29
+ it('keeps different sequences with the same file name separate', () => {
30
+ setVerificationFileContent('verification-1-file.ab1', 'seq1');
31
+ setVerificationFileContent('verification-2-file.ab1', 'seq2');
32
+ expect(getVerificationFileContent('verification-1-file.ab1')).toBe('seq1');
33
+ expect(getVerificationFileContent('verification-2-file.ab1')).toBe('seq2');
34
+ });
35
+
36
+ it('removes a single file content', () => {
37
+ setVerificationFileContent('verification-1-file.ab1', 'a');
38
+ setVerificationFileContent('verification-1-other.ab1', 'b');
39
+ removeVerificationFileContent('verification-1-file.ab1');
40
+ expect(getVerificationFileContent('verification-1-file.ab1')).toBeNull();
41
+ expect(getVerificationFileContent('verification-1-other.ab1')).toBe('b');
42
+ });
43
+
44
+ it('removes all file contents for a sequence', () => {
45
+ setVerificationFileContent('verification-1-file.ab1', 'a');
46
+ setVerificationFileContent('verification-1-other.ab1', 'b');
47
+ // We use 10 because of the startswith deletion logic in removeVerificationFileContents
48
+ setVerificationFileContent('verification-10-file.ab1', 'c');
49
+ removeVerificationFileContents(1);
50
+ expect(getVerificationFileContent('verification-1-file.ab1')).toBeNull();
51
+ expect(getVerificationFileContent('verification-1-other.ab1')).toBeNull();
52
+ expect(getVerificationFileContent('verification-10-file.ab1')).toBe('c');
53
+ });
54
+
55
+ it('removes a specific file within a sequence when fileName is given', () => {
56
+ setVerificationFileContent('verification-1-file.ab1', 'a');
57
+ setVerificationFileContent('verification-1-file2.ab1', 'b');
58
+ removeVerificationFileContents(1, 'file.ab1');
59
+ expect(getVerificationFileContent('verification-1-file.ab1')).toBeNull();
60
+ expect(getVerificationFileContent('verification-1-file2.ab1')).toBe('b');
61
+ });
62
+
63
+ it('clears all contents', () => {
64
+ setVerificationFileContent('verification-1-file.ab1', 'a');
65
+ setVerificationFileContent('verification-2-file.ab1', 'b');
66
+ clearVerificationFileContents();
67
+ expect(getVerificationFileContent('verification-1-file.ab1')).toBeNull();
68
+ expect(getVerificationFileContent('verification-2-file.ab1')).toBeNull();
69
+ });
70
+
71
+ // Regression test for https://github.com/OpenCloning/OpenCloning/issues/82
72
+ // Storing ~12 AB1 files of ~350 KB each (3.9 MiB total, ~5.2 MB in base64)
73
+ // exceeded the ~5 MB Web Storage quota. The in-memory store has no quota, so
74
+ // all files must be stored and retrievable.
75
+ it('stores many large files without any quota limit (issue #82)', () => {
76
+ const nFiles = 12;
77
+ const totalBytes = 3.9 * 1024 * 1024; // 3.9 MiB as reported in the issue
78
+ const perFile = Math.floor(totalBytes / nFiles);
79
+ const base64Length = Math.ceil((perFile * 4) / 3);
80
+ const content = 'A'.repeat(base64Length);
81
+
82
+ const keys = [];
83
+ for (let i = 1; i <= nFiles; i++) {
84
+ const key = `verification-17-KKF080_${70250800 + i}_70250801.ab1`;
85
+ keys.push(key);
86
+ setVerificationFileContent(key, content);
87
+ }
88
+ // Total stored in base64 is ~5.2 MB, well above the 5 MB Web Storage quota
89
+ expect(keys.reduce((sum, k) => sum + getVerificationFileContent(k).length, 0))
90
+ .toBeGreaterThan(5 * 1024 * 1024);
91
+ keys.forEach((key) => {
92
+ expect(getVerificationFileContent(key)).toBe(content);
93
+ });
94
+
95
+ // And a sequence-level cleanup still empties the store for that sequence
96
+ removeVerificationFileContents(17);
97
+ keys.forEach((key) => {
98
+ expect(getVerificationFileContent(key)).toBeNull();
99
+ });
100
+ });
101
+ });
@@ -1,31 +0,0 @@
1
- import axios from 'axios';
2
- import { attachAuthInterceptors } from './httpClientAuth';
3
- import urlWhitelist from '../config/urlWhitelist';
4
-
5
- export default function getHttpClient(extraUrls = []) {
6
- const baseURL = import.meta.env.BASE_URL || null;
7
- const whitelist = [...urlWhitelist, window.location.origin, ...extraUrls];
8
- if (baseURL) {
9
- whitelist.push(baseURL);
10
- }
11
-
12
- const client = axios.create();
13
-
14
- client.interceptors.request.use((config) => {
15
- const url = new URL(config.url, config.baseURL || window.location.origin);
16
-
17
- if (!whitelist.some((whitelistedUrl) => url.href.match(new RegExp(`^${whitelistedUrl}`)))) {
18
- return Promise.reject(new Error(`Request blocked: URL not in whitelist ${url.href}`));
19
- }
20
-
21
- return config;
22
- }, (error) => Promise.reject(error));
23
-
24
- return client;
25
- }
26
-
27
- export function getAuthenticatedHttpClient(extraUrls = []) {
28
- const client = getHttpClient(extraUrls);
29
- attachAuthInterceptors(client);
30
- return client;
31
- }
@@ -1,19 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import getHttpClient, { getAuthenticatedHttpClient } from './getHttpClient';
4
-
5
- describe('getHttpClient', () => {
6
- it('creates a generic client without auth interceptors', () => {
7
- const client = getHttpClient();
8
-
9
- expect(client.interceptors.request.handlers.filter(Boolean)).toHaveLength(1);
10
- expect(client.interceptors.response.handlers.filter(Boolean)).toHaveLength(0);
11
- });
12
-
13
- it('creates an authenticated client with auth interceptors', () => {
14
- const client = getAuthenticatedHttpClient();
15
-
16
- expect(client.interceptors.request.handlers.filter(Boolean)).toHaveLength(2);
17
- expect(client.interceptors.response.handlers.filter(Boolean)).toHaveLength(1);
18
- });
19
- });