diff --git a/lib/core/src/lib/common/services/url.service.spec.ts b/lib/core/src/lib/common/services/url.service.spec.ts new file mode 100644 index 0000000000..eb8980a992 --- /dev/null +++ b/lib/core/src/lib/common/services/url.service.spec.ts @@ -0,0 +1,87 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * 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 { TestBed } from '@angular/core/testing'; +import { DomSanitizer } from '@angular/platform-browser'; +import { UrlService } from './url.service'; + +describe('UrlService', () => { + const fakeObjectUrl = 'blob:http://localhost/fake-object-url'; + const fakeSafeUrl = { __safe: 'safe-url' } as unknown as string; + + let service: UrlService; + let sanitizer: DomSanitizer; + let createObjectURLSpy: jasmine.Spy; + + beforeEach(() => { + const mockSanitizer = { + bypassSecurityTrustUrl: jasmine.createSpy('bypassSecurityTrustUrl').and.returnValue(fakeSafeUrl) + }; + + TestBed.configureTestingModule({ + providers: [{ provide: DomSanitizer, useValue: mockSanitizer }] + }); + + service = TestBed.inject(UrlService); + sanitizer = TestBed.inject(DomSanitizer); + createObjectURLSpy = spyOn(window.URL, 'createObjectURL').and.returnValue(fakeObjectUrl); + }); + + describe('createObjectUrl', () => { + it('should delegate to window.URL.createObjectURL and return the raw url', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + + const result = service.createObjectUrl(blob); + + expect(createObjectURLSpy).toHaveBeenCalledOnceWith(blob); + expect(result).toBe(fakeObjectUrl); + }); + + it('should not invoke the sanitizer', () => { + service.createObjectUrl(new Blob(['test'])); + + expect(sanitizer.bypassSecurityTrustUrl).not.toHaveBeenCalled(); + }); + }); + + describe('trustUrl', () => { + it('should wrap the url with bypassSecurityTrustUrl', () => { + const result = service.trustUrl(fakeObjectUrl); + + expect(sanitizer.bypassSecurityTrustUrl).toHaveBeenCalledOnceWith(fakeObjectUrl); + expect(result).toBe(fakeSafeUrl); + }); + + it('should not create an object url', () => { + service.trustUrl(fakeObjectUrl); + + expect(createObjectURLSpy).not.toHaveBeenCalled(); + }); + }); + + describe('createTrustedUrl', () => { + it('should create an object url from the blob and pass it to the sanitizer', () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + + const result = service.createTrustedUrl(blob); + + expect(createObjectURLSpy).toHaveBeenCalledOnceWith(blob); + expect(sanitizer.bypassSecurityTrustUrl).toHaveBeenCalledOnceWith(fakeObjectUrl); + expect(result).toBe(fakeSafeUrl); + }); + }); +}); diff --git a/lib/core/src/lib/common/services/url.service.ts b/lib/core/src/lib/common/services/url.service.ts index 3507e40e8a..0ce1a058bb 100644 --- a/lib/core/src/lib/common/services/url.service.ts +++ b/lib/core/src/lib/common/services/url.service.ts @@ -32,7 +32,28 @@ export class UrlService { * @returns URL string */ createTrustedUrl(blob: Blob): string { - const url = window.URL.createObjectURL(blob); + return this.trustUrl(this.createObjectUrl(blob)); + } + + /** + * Creates a raw object URL from the Blob without Angular sanitization. Use this for + * non-Angular consumers (third-party DOM APIs, libraries) that need a real URL string. + * + * @param blob Data to wrap into object URL + * @returns Raw object URL string + */ + createObjectUrl(blob: Blob): string { + return window.URL.createObjectURL(blob); + } + + /** + * Wraps a URL string with Angular's bypassSecurityTrustUrl so it can be used in template bindings. + * WARNING: calling this method with untrusted user data exposes your application to XSS security risks! + * + * @param url URL to mark as safe + * @returns SafeUrl wrapper cast to string for template binding + */ + trustUrl(url: string): string { return this.sanitizer.bypassSecurityTrustUrl(url) as string; } } diff --git a/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.spec.ts b/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.spec.ts index 2e2f018a71..0d792abf56 100644 --- a/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.spec.ts +++ b/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.spec.ts @@ -173,12 +173,41 @@ describe('Test Img viewer component ', () => { it('should not thrown an error if blob is passed ', () => { const blob = createFakeBlob(); - spyOn(urlService, 'createTrustedUrl').and.returnValue('fake-blob-url'); + spyOn(urlService, 'createObjectUrl').and.returnValue('fake-blob-url'); + spyOn(urlService, 'trustUrl').and.returnValue('safe-fake-blob-url'); const change = new SimpleChange(null, blob, true); expect(() => { component.ngOnChanges({ blobFile: change }); }).not.toThrow(new Error('Attribute urlFile or blobFile is required')); - expect(component.urlFile).toEqual('fake-blob-url'); + expect(component.urlFile).toEqual('safe-fake-blob-url'); + }); + + it('should call replace on cropper with the raw blob url when blobFile changes after init', () => { + component.urlFile = 'fake-url'; + fixture.detectChanges(); + spyOn(component.cropper, 'replace').and.stub(); + spyOn(urlService, 'createObjectUrl').and.returnValue('fake-blob-url-2'); + spyOn(urlService, 'trustUrl').and.returnValue('safe-fake-blob-url-2'); + + const blobFile = new SimpleChange(createFakeBlob(), createFakeBlob(), false); + component.ngOnChanges({ blobFile }); + + expect(component.cropper.replace).toHaveBeenCalledWith('fake-blob-url-2'); + expect(component.urlFile).toEqual('safe-fake-blob-url-2'); + }); + + it('should not call replace on cropper on the first blobFile change', () => { + component.urlFile = 'fake-url'; + fixture.detectChanges(); + spyOn(component.cropper, 'replace').and.stub(); + spyOn(urlService, 'createObjectUrl').and.returnValue('fake-blob-url'); + spyOn(urlService, 'trustUrl').and.returnValue('safe-fake-blob-url'); + + const blobFile = new SimpleChange(null, createFakeBlob(), true); + component.ngOnChanges({ blobFile }); + + expect(component.cropper.replace).not.toHaveBeenCalled(); + expect(component.urlFile).toEqual('safe-fake-blob-url'); }); }); diff --git a/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.ts b/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.ts index 110190aba3..2ebd35a1a4 100644 --- a/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.ts +++ b/lib/core/src/lib/viewer/components/img-viewer/img-viewer.component.ts @@ -150,7 +150,11 @@ export class ImgViewerComponent implements AfterViewInit, OnChanges, OnDestroy { ngOnChanges(changes: SimpleChanges) { const blobFile = changes['blobFile']; if (blobFile?.currentValue) { - this.urlFile = this.urlService.createTrustedUrl(this.blobFile); + const rawUrl = this.urlService.createObjectUrl(this.blobFile); + if (!blobFile.firstChange && this.cropper) { + this.cropper.replace(rawUrl); + } + this.urlFile = this.urlService.trustUrl(rawUrl); return; }