Browse Source

feat(ui): Remove crypto-js from gravatar (#69122)

Scott Cooper 10 months ago
parent
commit
7cdb3e5a47

+ 0 - 2
package.json

@@ -69,7 +69,6 @@
     "@tanstack/react-query": "^4.29.7",
     "@tanstack/react-query-devtools": "^4.36.1",
     "@types/color": "^3.0.3",
-    "@types/crypto-js": "^4.1.1",
     "@types/diff": "5.0.2",
     "@types/dompurify": "^3.0.5",
     "@types/invariant": "^2.2.35",
@@ -106,7 +105,6 @@
     "copy-webpack-plugin": "^12.0.2",
     "core-js": "^3.33.0",
     "cronstrue": "^2.26.0",
-    "crypto-js": "4.2.0",
     "css-loader": "^6.10.0",
     "css-minimizer-webpack-plugin": "^6.0.0",
     "diff": "5.1.0",

+ 27 - 0
static/app/components/avatar/gravatar.spec.tsx

@@ -0,0 +1,27 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import Gravatar from './gravatar';
+
+describe('Gravatar', () => {
+  it('renders the image with remote size', async () => {
+    const remoteSize = 100;
+    render(<Gravatar remoteSize={remoteSize} />);
+
+    const imageElement = await screen.findByRole('img');
+    expect(imageElement).toHaveAttribute(
+      'src',
+      expect.stringContaining(`s=${remoteSize}`)
+    );
+  });
+
+  it('hashes the provided gravatarId', async () => {
+    const gravatarId = 'example@example.com';
+    render(<Gravatar remoteSize={100} gravatarId={gravatarId} />);
+
+    const imageElement = await screen.findByRole('img');
+    expect(imageElement).toHaveAttribute(
+      'src',
+      'https://gravatar.com/avatar/31c5543c1734d25c7206f5fd591525d0295bec6fe84ff82f946a34fe970a1e66?d=404&s=100'
+    );
+  });
+});

+ 36 - 19
static/app/components/avatar/gravatar.tsx

@@ -1,14 +1,33 @@
-import {useCallback, useEffect, useState} from 'react';
+import {useEffect, useState} from 'react';
 import styled from '@emotion/styled';
-import type HasherHelper from 'crypto-js/sha256';
 import * as qs from 'query-string';
 
 import ConfigStore from 'sentry/stores/configStore';
-import {useIsMountedRef} from 'sentry/utils/useIsMountedRef';
 
 import type {ImageStyleProps} from './styles';
 import {imageStyle} from './styles';
 
+function isCryptoSubtleDigestAvailable() {
+  return (
+    !!window.crypto &&
+    !!window.crypto.subtle &&
+    typeof window.crypto.subtle.digest === 'function'
+  );
+}
+
+/**
+ * Available only in secure contexts. (https)
+ * Gravatar will not work in http
+ */
+async function hashGravatarId(message = ''): Promise<string> {
+  const encoder = new TextEncoder();
+  const data = encoder.encode(message);
+  const hash = await window.crypto.subtle.digest('SHA-256', data);
+  return Array.from(new Uint8Array(hash))
+    .map(b => b.toString(16).padStart(2, '0'))
+    .join('');
+}
+
 type Props = {
   remoteSize: number;
   gravatarId?: string;
@@ -26,24 +45,23 @@ function Gravatar({
   onLoad,
   suggested,
 }: Props) {
-  const isMountedRef = useIsMountedRef();
-  const [SHA256, setSHA256] = useState<typeof HasherHelper>();
-
-  const loadSHA256Helper = useCallback(async () => {
-    const mod = await import('crypto-js/sha256');
-
-    if (isMountedRef.current) {
-      // XXX: Use function invocation of `useState`s setter since the mod.default
-      // is a function itself.
-      setSHA256(() => mod.default);
+  const [sha256, setSha256] = useState<string | null>(null);
+  useEffect(() => {
+    if (!isCryptoSubtleDigestAvailable()) {
+      return;
     }
-  }, [isMountedRef]);
 
-  useEffect(() => {
-    loadSHA256Helper();
-  }, [loadSHA256Helper]);
+    hashGravatarId((gravatarId ?? '').trim())
+      .then(hash => {
+        setSha256(hash);
+      })
+      .catch(() => {
+        // If there is an error with the hash, we should not render the gravatar
+        setSha256(null);
+      });
+  }, [gravatarId]);
 
-  if (SHA256 === undefined) {
+  if (!sha256) {
     return null;
   }
 
@@ -56,7 +74,6 @@ function Gravatar({
 
   const gravatarBaseUrl = ConfigStore.get('gravatarBaseUrl');
 
-  const sha256 = SHA256((gravatarId ?? '').trim());
   const url = `${gravatarBaseUrl}/avatar/${sha256}?${query}`;
 
   return (

+ 8 - 0
tests/js/setup.ts

@@ -3,6 +3,7 @@ import '@testing-library/jest-dom';
 /* eslint-env node */
 import type {ReactElement} from 'react';
 import {configure as configureRtl} from '@testing-library/react'; // eslint-disable-line no-restricted-imports
+import {webcrypto} from 'node:crypto';
 import {TextDecoder, TextEncoder} from 'node:util';
 import {ConfigFixture} from 'sentry-fixture/config';
 
@@ -230,3 +231,10 @@ window.IntersectionObserver = class IntersectionObserver {
   unobserve() {}
   disconnect() {}
 };
+
+// Mock the crypto.subtle API for Gravatar
+Object.defineProperty(global.self, 'crypto', {
+  value: {
+    subtle: webcrypto.subtle,
+  },
+});

+ 0 - 10
yarn.lock

@@ -3366,11 +3366,6 @@
   dependencies:
     "@types/node" "*"
 
-"@types/crypto-js@^4.1.1":
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-4.1.1.tgz#602859584cecc91894eb23a4892f38cfa927890d"
-  integrity sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA==
-
 "@types/css-font-loading-module@0.0.7":
   version "0.0.7"
   resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz#2f98ede46acc0975de85c0b7b0ebe06041d24601"
@@ -5279,11 +5274,6 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
     shebang-command "^2.0.0"
     which "^2.0.1"
 
-crypto-js@4.2.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
-  integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
-
 css-declaration-sorter@^7.1.1:
   version "7.1.1"
   resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-7.1.1.tgz#9796bcc257b4647c39993bda8d431ce32b666f80"