This is an automated email from the ASF dual-hosted git repository.

pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new bdf3ee57032 Prevent React plugins from inheriting a prior bundle's 
component (#71943)
bdf3ee57032 is described below

commit bdf3ee57032da6934d27ff8302114b730d849c56
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Fri Aug 21 17:35:31 2026 +0200

    Prevent React plugins from inheriting a prior bundle's component (#71943)
    
    A React plugin bundle exports its component by assigning to the shared
    globalThis.AirflowPlugin as a side effect of being imported. When a bundle 
is
    malformed or empty and never makes that assignment, the loader read whatever
    value a previously-loaded plugin had left there, so one plugin silently
    rendered another plugin's component instead of failing. Clear the shared 
global
    before each import so a bundle that assigns nothing surfaces a clear error
    rather than inheriting a stale component.
---
 .../src/airflow/ui/src/pages/ReactPlugin.test.tsx  | 26 +++++++++++++++++++++-
 .../src/airflow/ui/src/pages/ReactPlugin.tsx       | 10 +++++++--
 2 files changed, 33 insertions(+), 3 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
index 7f0fb6d5609..ea27f6c90c8 100644
--- a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
@@ -30,7 +30,7 @@ import type {
 } from "openapi/requests/types.gen";
 import { Wrapper } from "src/utils/Wrapper";
 
-import { ReactPlugin, type PluginProps } from "./ReactPlugin";
+import { loadPlugin, ReactPlugin, type PluginProps } from "./ReactPlugin";
 
 const mockAsset = { id: 1, name: "my_asset", uri: "s3://bucket/key" } as 
AssetResponse;
 const mockDag = { dag_display_name: "My Dag", dag_id: "my_dag" } as 
DAGDetailsResponse;
@@ -142,3 +142,27 @@ describe("ReactPlugin context props", () => {
     expect(capturedProps?.assetUri).toBe(mockAsset.uri);
   });
 });
+
+const pluginA = { bundle_url: "http://localhost/a.js";, name: "PluginA" } as 
ReactAppResponse;
+const pluginB = { bundle_url: "http://localhost/b.js";, name: "PluginB" } as 
ReactAppResponse;
+const componentA = () => null;
+
+describe("loadPlugin", () => {
+  afterEach(() => {
+    for (const key of ["AirflowPlugin", pluginA.name, pluginB.name]) {
+      (globalThis as Record<string, unknown>)[key] = undefined;
+    }
+  });
+
+  it("does not let a malformed bundle inherit a previously-loaded plugin's 
component", async () => {
+    // A well-formed bundle sets globalThis.AirflowPlugin on import; a 
malformed one sets nothing.
+    await loadPlugin(pluginA, () => {
+      (globalThis as Record<string, unknown>).AirflowPlugin = componentA;
+
+      return Promise.resolve();
+    });
+    const { default: component } = await loadPlugin(pluginB, () => 
Promise.resolve());
+
+    expect(component).not.toBe(componentA);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx 
b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
index a14fdcb13d3..9811aa7a85f 100644
--- a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
@@ -51,9 +51,14 @@ export type PluginProps = {
 
 type PluginComponentType = FC<PluginProps>;
 
-const loadPlugin = (reactApp: ReactAppResponse): Promise<{ default: 
PluginComponentType }> =>
+export const loadPlugin = (
+  reactApp: ReactAppResponse,
+  importBundle: (url: string) => Promise<unknown> = (url) => import(/* 
@vite-ignore */ url),
+): Promise<{ default: PluginComponentType }> => {
+  (globalThis as Record<string, unknown>).AirflowPlugin = undefined;
+
   // We are assuming the plugin manager is trusted and the bundle_url is safe
-  import(/* @vite-ignore */ new URL(reactApp.bundle_url, 
document.baseURI).href)
+  return importBundle(new URL(reactApp.bundle_url, document.baseURI).href)
     .then(() => {
       // Store components in globalThis[reactApp.name] to avoid conflicts with 
the shared globalThis.AirflowPlugin
       // global variable.
@@ -78,6 +83,7 @@ const loadPlugin = (reactApp: ReactAppResponse): Promise<{ 
default: PluginCompon
 
       return { default: ErrorPage };
     });
+};
 
 export const ReactPlugin = ({ reactApp }: { readonly reactApp: 
ReactAppResponse }) => {
   const { assetId, dagId, mapIndex, runId, taskId } = useParams();

Reply via email to