diff --git a/zeppelin-web-angular/src/app/share/external-links/external-link.directive.spec.ts b/zeppelin-web-angular/src/app/share/external-links/external-link.directive.spec.ts
new file mode 100644
index 000000000000..f63c28d4325a
--- /dev/null
+++ b/zeppelin-web-angular/src/app/share/external-links/external-link.directive.spec.ts
@@ -0,0 +1,90 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ExternalLinkDirective } from './external-link.directive';
+
+@Component({
+ standalone: false,
+ template: `
+ link
+ `
+})
+class HostComponent {
+ url = '';
+}
+
+/**
+ * The directive declares `@Input() href`, so a `[href]` binding goes to the directive
+ * instead of the anchor's DOM property and skips Angular's built-in URL sanitization.
+ * These tests drive the directive through a real `[href]` binding to cover that path.
+ */
+describe('ExternalLinkDirective', () => {
+ let fixture: ComponentFixture;
+
+ const anchor = (): HTMLAnchorElement => fixture.nativeElement.querySelector('a');
+
+ const render = (url: string) => {
+ fixture.componentInstance.url = url;
+ // TestBed is zoneless by default, so flag the host dirty before re-rendering.
+ fixture.componentRef.changeDetectorRef.markForCheck();
+ fixture.detectChanges();
+ };
+
+ beforeEach(() => {
+ // Angular logs a warning whenever it sanitizes a URL; keep the test output clean.
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+
+ TestBed.configureTestingModule({
+ declarations: [HostComponent, ExternalLinkDirective]
+ });
+
+ fixture = TestBed.createComponent(HostComponent);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('neutralizes javascript: URLs', () => {
+ render('javascript:alert(document.domain)');
+
+ expect(anchor().getAttribute('href')).toMatch(/^unsafe:/);
+ expect(anchor().protocol).not.toBe('javascript:');
+ });
+
+ it('neutralizes javascript: URLs set after an initial safe URL', () => {
+ render('https://zeppelin.apache.org/');
+ render('javascript:alert(document.domain)');
+
+ expect(anchor().getAttribute('href')).toMatch(/^unsafe:/);
+ });
+
+ it('keeps safe external URLs and opens them in a new tab', () => {
+ render('https://zeppelin.apache.org/');
+
+ expect(anchor().href).toBe('https://zeppelin.apache.org/');
+ expect(anchor().getAttribute('rel')).toBe('noopener noreferrer');
+ expect(anchor().getAttribute('target')).toBe('_blank');
+ });
+
+ it('keeps same-origin URLs without rel/target', () => {
+ render(`${location.origin}/#/notebook/abc`);
+
+ expect(anchor().href).toBe(`${location.origin}/#/notebook/abc`);
+ expect(anchor().hasAttribute('rel')).toBe(false);
+ expect(anchor().hasAttribute('target')).toBe(false);
+ });
+});
diff --git a/zeppelin-web-angular/src/app/share/external-links/external-link.directive.ts b/zeppelin-web-angular/src/app/share/external-links/external-link.directive.ts
index f1a5da244f9f..59b209e38dab 100644
--- a/zeppelin-web-angular/src/app/share/external-links/external-link.directive.ts
+++ b/zeppelin-web-angular/src/app/share/external-links/external-link.directive.ts
@@ -10,7 +10,8 @@
* limitations under the License.
*/
-import { Directive, ElementRef, HostBinding, Input, OnChanges } from '@angular/core';
+import { Directive, ElementRef, HostBinding, Input, OnChanges, SecurityContext } from '@angular/core';
+import { DomSanitizer } from '@angular/platform-browser';
@Directive({
// eslint-disable-next-line
@@ -22,10 +23,15 @@ export class ExternalLinkDirective implements OnChanges {
@HostBinding('attr.target') targetAttr: HTMLAnchorElement['target'] | null = null;
@Input() href?: string;
- constructor(private elementRef: ElementRef) {}
+ constructor(
+ private elementRef: ElementRef,
+ private sanitizer: DomSanitizer
+ ) {}
ngOnChanges() {
- this.elementRef.nativeElement.href = this.href;
+ // This directive captures the `href` input, so Angular's built-in URL sanitization
+ // for `[href]` bindings is skipped. Sanitize explicitly before writing to the DOM.
+ this.elementRef.nativeElement.href = this.sanitizer.sanitize(SecurityContext.URL, this.href ?? null) ?? '';
if (this.isLinkExternal()) {
// https://developers.google.com/web/tools/lighthouse/audits/noopener