Repository: linode/manager
Pull request #12077: fix: [M3-8289] - Properly encode URL and filenames of files
Author: login: jaalah-akamai
Labels: name: Bug
description: Fixes for regressions or bugs
name: Approved
description: Multiple approvals and ready to merge!
Created: 2025-04-21T21:52:02Z | Merged: 2025-04-23T15:01:56Z

## Description 📝
When an object is uploaded with special char (?) in the file name to a bucket, and clicking on the OBJ URL of the uploaded object results in ‘Access Denied’ error for the user. This is because we're not properly encoding the URL or filename of the object.

## Changes  🔄
- Encode the filename when getting the object from API
- Encode the object name when generating the URL

## Target release date 🗓️
5/6

## Preview 📷

| Before  | After   |
| ------- | ------- |
| ![Screenshot 2025-04-22 at 12 27 00 PM](https://github.com/user-attachments/assets/c1adaa7d-3596-4ee1-969e-76a6864b7d9e) | ![Screenshot 2025-04-22 at 12 27 45 PM](https://github.com/user-attachments/assets/2efeebf2-556f-4a6b-a89c-3dd5b3819aaa) |

## How to test 🧪
### Reproduction steps
- Upload an image with a special character
- Set the access to `public` and then go to URL
- Observe the XML error

### Verification steps
- Follow same steps with preview link or checking out branch
- Observe image shows

<details>
<summary> Author Checklists </summary>

## As an Author, to speed up the review process, I considered 🤔

👀 Doing a self review
❔ Our [contribution guidelines](https://github.com/linode/manager/blob/develop/docs/CONTRIBUTING.md)
🤏 Splitting feature into small PRs
➕ Adding a [changeset](https://github.com/linode/manager/blob/develop/docs/CONTRIBUTING.md#writing-a-changeset)
🧪 Providing/improving test coverage
 🔐 Removing all sensitive information from the code and PR description
🚩 Using a feature flag to protect the release
👣 Providing comprehensive reproduction steps
📑 Providing or updating our documentation
🕛 Scheduling a pair reviewing session
📱 Providing mobile support
♿  Providing accessibility support

<br/>

- [x] I have read and considered all applicable items listed above.

## As an Author, before moving this PR from Draft to Open, I confirmed ✅

- [x] All unit tests are passing
- [x] TypeScript compilation succeeded without errors
- [x] Code passes all linting rules

</details>


Diff:
diff --git a/packages/manager/src/components/Link.tsx b/packages/manager/src/components/Link.tsx
index 73c16ce0648..d1017373757 100644
--- a/packages/manager/src/components/Link.tsx
+++ b/packages/manager/src/components/Link.tsx
@@ -8,12 +8,12 @@ import {
 import * as React from 'react';
 // eslint-disable-next-line no-restricted-imports
 import { Link as RouterLink } from 'react-router-dom';
+import type { LinkProps as _LinkProps } from 'react-router-dom';
 
 import ExternalLinkIcon from 'src/assets/icons/external-link.svg';
 import { useStyles } from 'src/components/Link.styles';
 
 import type { LinkProps as TanStackLinkProps } from '@tanstack/react-router';
-import type { LinkProps as _LinkProps } from 'react-router-dom';
 
 export interface LinkProps extends Omit<_LinkProps, 'to'> {
   /**
@@ -21,6 +21,11 @@ export interface LinkProps extends Omit<_LinkProps, 'to'> {
    * This is useful when the text of the link is unavailable, not descriptive enough, or a single icon is used as the child.
    */
   accessibleAriaLabel?: string;
+  /**
+   * Optional prop to bypass URL sanitization. Use with caution.
+   * @default false
+   */
+  bypassSanitization?: boolean;
   /**
    * Optional prop to render the link as an external link, which features an external link icon, opens in a new tab<br />
    * and provides by default "noopener noreferrer" attributes to prevent security vulnerabilities.
@@ -47,7 +52,7 @@ export interface LinkProps extends Omit<_LinkProps, 'to'> {
    * @example "/profile/display"
    * @example "https://linode.com"
    */
-  to: Exclude<TanStackLinkProps['to'] | (string & {}), null | undefined>;
+  to: Exclude<(string & {}) | TanStackLinkProps['to'], null | undefined>;
 }
 
 /**
@@ -78,14 +83,15 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
       children,
       className,
       external,
+      bypassSanitization,
       forceCopyColor,
       hideIcon,
       onClick,
       to,
     } = props;
     const { classes, cx } = useStyles();
-    const sanitizedUrl = () => sanitizeUrl(to);
-    const shouldOpenInNewTab = opensInNewTab(sanitizedUrl());
+    const processedUrl = () => (bypassSanitization ? to : sanitizeUrl(to));
+    const shouldOpenInNewTab = opensInNewTab(processedUrl());
     const childrenAsAriaLabel = flattenChildrenIntoAriaLabel(children);
     const externalNotice = '- link opens in a new tab';
     const ariaLabel = accessibleAriaLabel
@@ -108,6 +114,7 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
 
     return shouldOpenInNewTab ? (
       <a
+        aria-label={ariaLabel}
         className={cx(
           classes.root,
           {
@@ -115,9 +122,8 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
           },
           className
         )}
-        aria-label={ariaLabel}
         data-testid={external ? 'external-site-link' : 'external-link'}
-        href={sanitizedUrl()}
+        href={processedUrl()}
         onClick={onClick}
         ref={ref}
         rel="noopener noreferrer"
diff --git a/packages/manager/src/components/Uploaders/ObjectUploader/ObjectUploader.tsx b/packages/manager/src/components/Uploaders/ObjectUploader/ObjectUploader.tsx
index cb17876ab02..3017af5f3ac 100644
--- a/packages/manager/src/components/Uploaders/ObjectUploader/ObjectUploader.tsx
+++ b/packages/manager/src/components/Uploaders/ObjectUploader/ObjectUploader.tsx
@@ -300,16 +300,15 @@ export const ObjectUploader = React.memo((props: Props) => {
   );
 });
 
-export const onUploadProgressFactory = (
-  dispatch: (value: ObjectUploaderAction) => void,
-  fileName: string
-) => (progressEvent: AxiosProgressEvent) => {
-  dispatch({
-    data: {
-      percentComplete:
-        (progressEvent.loaded / (progressEvent.total ?? 1)) * 100,
-    },
-    filesToUpdate: [fileName],
-    type: 'UPDATE_FILES',
-  });
-};
+export const onUploadProgressFactory =
+  (dispatch: (value: ObjectUploaderAction) => void, fileName: string) =>
+  (progressEvent: AxiosProgressEvent) => {
+    dispatch({
+      data: {
+        percentComplete:
+          (progressEvent.loaded / (progressEvent.total ?? 1)) * 100,
+      },
+      filesToUpdate: [fileName],
+      type: 'UPDATE_FILES',
+    });
+  };
diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx
index 234563261f1..0e29fd2f3b5 100644
--- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx
+++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx
@@ -86,7 +86,7 @@ export const ObjectDetailsDrawer = React.memo(
 
         {url ? (
           <StyledLinkContainer>
-            <Link external to={url}>
+            <Link bypassSanitization external to={url}>
               {truncateMiddle(url, 50)}
             </Link>
             <StyledCopyTooltip sx={{ marginLeft: 4 }} text={url} />
diff --git a/packages/manager/src/features/ObjectStorage/utilities.test.ts b/packages/manager/src/features/ObjectStorage/utilities.test.ts
index ca05ce65276..7db2198d8d3 100644
--- a/packages/manager/src/features/ObjectStorage/utilities.test.ts
+++ b/packages/manager/src/features/ObjectStorage/utilities.test.ts
@@ -1,17 +1,18 @@
-import { ObjectStorageObject } from '@linode/api-v4/lib/object-storage';
-
 import {
   basename,
   confirmObjectStorage,
   displayName,
   extendObject,
   firstSubfolder,
+  generateObjectUrl,
   isFile,
   isFolder,
   prefixArrayToString,
   tableUpdateAction,
 } from './utilities';
 
+import type { ObjectStorageObject } from '@linode/api-v4/lib/object-storage';
+
 const folder: ObjectStorageObject = {
   etag: null,
   last_modified: null,
@@ -41,7 +42,22 @@ const object3: ObjectStorageObject = {
   size: 0,
 };
 
+const mockHostname = 'my-bucket.linodeobjects.com';
+
 describe('Object Storage utilities', () => {
+  describe('generateObjectUrl', () => {
+    it('returns the correct URL', () => {
+      expect(generateObjectUrl(mockHostname, 'my-object')).toBe(
+        'https://my-bucket.linodeobjects.com/my-object'
+      );
+    });
+    it('encodes the URL for special characters', () => {
+      expect(generateObjectUrl(mockHostname, 'my-object?')).toBe(
+        'https://my-bucket.linodeobjects.com/my-object%3F'
+      );
+    });
+  });
+
   describe('isFolder', () => {
     it('returns `true` if the object has null for fields except `name`', () => {
       expect(isFolder(folder)).toBe(true);
diff --git a/packages/manager/src/features/ObjectStorage/utilities.ts b/packages/manager/src/features/ObjectStorage/utilities.ts
index b624606ef6f..9b2361d1521 100644
--- a/packages/manager/src/features/ObjectStorage/utilities.ts
+++ b/packages/manager/src/features/ObjectStorage/utilities.ts
@@ -6,7 +6,7 @@ import type { ObjectStorageEndpoint } from '@linode/api-v4/lib/object-storage';
 import type { FormikProps } from 'formik';
 
 export const generateObjectUrl = (hostname: string, objectName: string) => {
-  return `https://${hostname}/${objectName}`;
+  return `https://${hostname}/${encodeURIComponent(objectName)}`;
 };
 
 // Objects ending with a / and having a size of 0 are often used to represent


Reviews:
user: login: jaalah-akamai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:42:09Z
user: login: jaalah-akamai
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:42:50Z
user: login: hana-akamai
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T20:52:24Z
user: login: hasyed-akamai
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T09:19:00Z

Review comments:
diff_hunk: @@ -300,16 +300,15 @@ export const ObjectUploader = React.memo((props: Props) => {
   );
 });
 
-export const onUploadProgressFactory = (
-  dispatch: (value: ObjectUploaderAction) => void,
-  fileName: string
-) => (progressEvent: AxiosProgressEvent) => {
-  dispatch({
-    data: {
-      percentComplete:
-        (progressEvent.loaded / (progressEvent.total ?? 1)) * 100,
-    },
-    filesToUpdate: [fileName],
-    type: 'UPDATE_FILES',
-  });
-};
+export const onUploadProgressFactory =
+  (dispatch: (value: ObjectUploaderAction) => void, fileName: string) =>
+  (progressEvent: AxiosProgressEvent) => {
+    dispatch({
+      data: {
+        percentComplete:
+          (progressEvent.loaded / (progressEvent.total ?? 1)) * 100,
+      },
+      filesToUpdate: [fileName],
+      type: 'UPDATE_FILES',
+    });
+  };
path: packages/manager/src/components/Uploaders/ObjectUploader/ObjectUploader.tsx
user: login: jaalah-akamai
body: No changes here, just linting.
created_at: 2025-04-22T17:42:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -8,19 +8,24 @@ import {
 import * as React from 'react';
 // eslint-disable-next-line no-restricted-imports
 import { Link as RouterLink } from 'react-router-dom';
+import type { LinkProps as _LinkProps } from 'react-router-dom';
 
 import ExternalLinkIcon from 'src/assets/icons/external-link.svg';
 import { useStyles } from 'src/components/Link.styles';
 
 import type { LinkProps as TanStackLinkProps } from '@tanstack/react-router';
-import type { LinkProps as _LinkProps } from 'react-router-dom';
 
 export interface LinkProps extends Omit<_LinkProps, 'to'> {
   /**
    * This property can override the value of the copy passed by default to the aria label from the children.
    * This is useful when the text of the link is unavailable, not descriptive enough, or a single icon is used as the child.
    */
   accessibleAriaLabel?: string;
+  /**
+   * Optional prop to bypass URL sanitization. Use with caution.
+   * @default false
+   */
+  bypassSanitization?: boolean;
path: packages/manager/src/components/Link.tsx
user: login: jaalah-akamai
body: `Link` sanitizes the encoding that we want to have in the URL
created_at: 2025-04-22T17:42:50Z
author_association: CONTRIBUTOR

PR comments:
user: login: linode-gh-bot
created_at: 2025-04-22T21:49:38Z
body: <!-- PULL REQUEST COMMENT: cloud-manager-ci-comment -->
<!-- BEGIN COMMENT SECTION: cloud-manager-ui-tests -->
## Cloud Manager UI test results
### :tada: 565 passing tests on [test run #4 ↗︎](https://baker.linode.com/job/manager/job/manager-test/job/PR-12077/4/display/redirect)

<table><thead><tr><td><strong>:x: Failing</strong></td><td><strong>:white_check_mark: Passing</strong></td><td><strong>:arrow_right_hook: Skipped</strong></td><td><strong>:clock1: Duration</strong></td></tr></thead><tbody><tr><td><code>0 Failing</code></td><td><code>565 Passing</code></td><td><code>5 Skipped</code></td><td><code>103m 25s</code></td></tr></tbody></table>
<!-- END COMMENT SECTION: cloud-manager-ui-tests -->
author_association: NONE