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

choo121600 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 e2486701a1f Restrict owner-link and extra-link href to safe schemes 
(http, https, mailto, relative) (#66741)
e2486701a1f is described below

commit e2486701a1f97e8fa18ab874b747eceeed542549
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue May 12 02:56:37 2026 +0200

    Restrict owner-link and extra-link href to safe schemes (http, https, 
mailto, relative) (#66741)
---
 .../airflow/ui/src/pages/DagsList/DagOwners.tsx    |  4 +-
 .../ui/src/pages/TaskInstance/ExtraLinks.tsx       | 11 ++++-
 .../src/airflow/ui/src/utils/links.test.ts         | 44 ++++++++++++++++++-
 airflow-core/src/airflow/ui/src/utils/links.ts     | 49 ++++++++++++++++++++++
 4 files changed, 104 insertions(+), 4 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagOwners.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagOwners.tsx
index 5eb12f3d1ea..e4102eb22de 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagOwners.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagOwners.tsx
@@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next";
 import { Link as RouterLink } from "react-router-dom";
 
 import { LimitedItemsList } from "src/components/LimitedItemsList";
+import { getSafeExternalUrl } from "src/utils/links";
 
 const DEFAULT_OWNERS: Array<string> = [];
 const MAX_OWNERS = 3;
@@ -34,7 +35,8 @@ export const DagOwners = ({
 }) => {
   const { t: translate } = useTranslation("dags");
   const items = owners.map((owner) => {
-    const ownerLink = ownerLinks?.[owner];
+    const rawOwnerLink = ownerLinks?.[owner];
+    const ownerLink = rawOwnerLink === undefined ? undefined : 
getSafeExternalUrl(rawOwnerLink);
     const ownerFilterLink = `/dags?owners=${owner}`;
     const hasOwnerLink = ownerLink !== undefined;
 
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx
index b2d72bae130..f0b13b84d80 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx
@@ -22,6 +22,7 @@ import { useParams, useSearchParams } from "react-router-dom";
 
 import { useTaskInstanceServiceGetExtraLinks } from "openapi/queries";
 import { SearchParamsKeys } from "src/constants/searchParams";
+import { getSafeExternalUrl } from "src/utils/links";
 
 type ExtraLinksProps = {
   readonly refetchInterval: number | false;
@@ -67,11 +68,17 @@ export const ExtraLinks = ({ refetchInterval }: 
ExtraLinksProps) => {
             return undefined;
           }
 
-          const target = getTarget(url);
+          const safeUrl = getSafeExternalUrl(url);
+
+          if (safeUrl === undefined) {
+            return undefined;
+          }
+
+          const target = getTarget(safeUrl);
 
           return (
             <Button asChild colorPalette="brand" key={key} variant="surface">
-              <a href={url} rel="noopener noreferrer" target={target}>
+              <a href={safeUrl} rel="noopener noreferrer" target={target}>
                 {key}
               </a>
             </Button>
diff --git a/airflow-core/src/airflow/ui/src/utils/links.test.ts 
b/airflow-core/src/airflow/ui/src/utils/links.test.ts
index 542f508a962..a23711ded54 100644
--- a/airflow-core/src/airflow/ui/src/utils/links.test.ts
+++ b/airflow-core/src/airflow/ui/src/utils/links.test.ts
@@ -20,7 +20,12 @@ import { describe, it, expect } from "vitest";
 
 import type { TaskInstanceResponse } from "openapi/requests/types.gen";
 
-import { buildTaskInstanceUrl, getTaskInstanceAdditionalPath, 
getTaskInstanceLink } from "./links";
+import {
+  buildTaskInstanceUrl,
+  getSafeExternalUrl,
+  getTaskInstanceAdditionalPath,
+  getTaskInstanceLink,
+} from "./links";
 
 describe("getTaskInstanceLink", () => {
   const testCases = [
@@ -284,3 +289,40 @@ describe("buildTaskInstanceUrl", () => {
     ).toBe("/dags/new_dag/runs/new_run/tasks/new_task/mapped");
   });
 });
+
+describe("getSafeExternalUrl", () => {
+  describe("allows", () => {
+    const safeCases = [
+      ["http URL", "http://example.com/path";],
+      ["https URL", "https://example.com/path?q=1#anchor";],
+      ["mailto URL", "mailto:[email protected]";],
+      ["relative absolute path", "/dags/my_dag"],
+      ["relative same-document fragment", "#section"],
+      ["relative query string", "?owner=me"],
+      ["same-origin absolute URL", `${globalThis.location.origin}/dags`],
+      ["URL with surrounding whitespace", "  https://example.com/x  "],
+    ];
+
+    it.each(safeCases)("passes through a %s", (_description, input) => {
+      expect(getSafeExternalUrl(input)).toBe(input.trim());
+    });
+  });
+
+  describe("rejects", () => {
+    const unsafeCases = [
+      ["javascript: URL", "javascript:alert(1)"],
+      ["javascript: URL with mixed case", "JavaScript:alert(1)"],
+      ["javascript: URL with leading whitespace", " javascript:alert(1)"],
+      ["data: URL", "data:text/html,<script>alert(1)</script>"],
+      ["file: URL", "file:///etc/passwd"],
+      ["vbscript: URL", "vbscript:msgbox(1)"],
+      ["ftp: URL", "ftp://example.com/file";],
+      ["empty string", ""],
+      ["whitespace-only string", "   "],
+    ];
+
+    it.each(unsafeCases)("returns undefined for a %s", (_description, input) 
=> {
+      expect(getSafeExternalUrl(input)).toBeUndefined();
+    });
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/utils/links.ts 
b/airflow-core/src/airflow/ui/src/utils/links.ts
index 6770f2e7cbb..403f2801710 100644
--- a/airflow-core/src/airflow/ui/src/utils/links.ts
+++ b/airflow-core/src/airflow/ui/src/utils/links.ts
@@ -73,6 +73,55 @@ export const getTaskInstanceAdditionalPath = (pathname: 
string): string => {
   return "";
 };
 
+const SAFE_EXTERNAL_URL_SCHEMES = new Set(["http:", "https:", "mailto:";]);
+
+/**
+ * Pass-through filter for href values that originate outside the application —
+ * for example DAG-author-supplied `owner_links`, or operator extra-link URLs
+ * read from task-pushed XCom.
+ *
+ * Returns the URL unchanged when it is either a same-origin / relative path or
+ * uses one of the allow-listed schemes (`http:`, `https:`, `mailto:`).
+ * Returns `undefined` for any other scheme (`javascript:`, `data:`, `file:`,
+ * `vbscript:`, etc.) and for unparsable input, matching the scheme-allowlist
+ * policy already applied to markdown links via react-markdown's default
+ * `urlTransform` and to log / XCom linkification (which is `https?://`-only).
+ *
+ * Callers should fall back to plain text or skip rendering when this returns
+ * `undefined`.
+ */
+export const getSafeExternalUrl = (url: string): string | undefined => {
+  const trimmed = url.trim();
+
+  if (trimmed === "") {
+    return undefined;
+  }
+
+  let parsed: URL;
+
+  try {
+    parsed = new URL(trimmed, globalThis.location.origin);
+  } catch {
+    return undefined;
+  }
+
+  // Same-origin URL (relative input, or absolute pointing at our own origin).
+  // We have to compare against `location.origin` rather than just looking at
+  // the protocol because `new URL("/foo", origin)` produces a URL whose
+  // protocol is the origin's protocol (typically `http(s):`), so a
+  // protocol-only check would let through any non-allow-listed scheme that
+  // happens to share the origin's protocol shape.
+  if (parsed.origin === globalThis.location.origin) {
+    return trimmed;
+  }
+
+  if (SAFE_EXTERNAL_URL_SCHEMES.has(parsed.protocol)) {
+    return trimmed;
+  }
+
+  return undefined;
+};
+
 export const buildTaskInstanceUrl = (params: {
   currentPathname: string;
   dagId: string;

Reply via email to