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

voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new 95b25f25fb [ZEPPELIN-6523] Sanitize href in ExternalLinkDirective
95b25f25fb is described below

commit 95b25f25fb8e19425867e04b080763adfb7ed147
Author: JangAyeon <[email protected]>
AuthorDate: Thu Oct 1 00:58:47 2026 +0900

    [ZEPPELIN-6523] Sanitize href in ExternalLinkDirective
    
    ### What is this PR for?
    `ExternalLinkDirective` (selector `a[href]`) declares `<at>Input() href`. 
Because the directive claims the `href` input, `[href]` bindings on anchors are 
delivered to the directive rather than to the DOM property, so Angular's 
built-in URL sanitization (`ɵɵsanitizeUrl`) never runs. The directive then 
assigned the raw value via `nativeElement.href = this.href`, which keeps 
`javascript:` URLs executable on click.
    
    One reachable path is the interpreter setting page, where a `url`-type 
property value is rendered as `<a [href]="...">` 
(`interpreter/item/item.component.html`). Interpreter properties are usually 
admin-controlled, so the practical risk is limited, but this is still a 
sanitization bypass and a defense-in-depth fix for any current or future 
dynamic `[href]` binding.
    
    This PR sanitizes the value with 
`DomSanitizer.sanitize(SecurityContext.URL, ...)` before assigning it, so 
unsafe URLs become `unsafe:...` just like a normal Angular `[href]` binding. 
The existing `rel="noopener noreferrer"` / `target="_blank"` handling for 
external links is unchanged.
    
    
    ### What type of PR is it?
    Bug Fix
    
    ### Todos
    * [x] Sanitize href in `ExternalLinkDirective`
    * [x] Add unit tests
    
    ### What is the Jira issue?
    
    [ZEPPELIN-6523](https://issues.apache.org/jira/browse/ZEPPELIN-6523)
    
    ### How should this be tested?
    * Unit tests: `cd zeppelin-web-angular && npx vitest run --config 
vitest.shell.config.mts src/app/share/external-links/`
    
    
    ### Questions:
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    
    Closes #5508 from JangAyeon/ZEPPELIN-6523.
    
    Signed-off-by: YONGJAE LEE <[email protected]>
---
 .../external-links/external-link.directive.spec.ts | 90 ++++++++++++++++++++++
 .../external-links/external-link.directive.ts      | 12 ++-
 2 files changed, 99 insertions(+), 3 deletions(-)

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 0000000000..f63c28d432
--- /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: `
+    <a [href]="url">link</a>
+  `
+})
+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<HostComponent>;
+
+  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 f1a5da244f..59b209e38d 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

Reply via email to