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