diff --git a/src/lg-utils.ts b/src/lg-utils.ts index ccedbcf71..dad6d9c88 100644 --- a/src/lg-utils.ts +++ b/src/lg-utils.ts @@ -388,6 +388,20 @@ const utils = { return transform; }, + /** + * Escapes HTML attribute values to prevent XSS attacks + * @param {string} value - The attribute value to escape + * @returns {string} The escaped attribute value + */ + escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }, + getIframeMarkup( iframeWidth: string, iframeHeight: string, @@ -396,7 +410,9 @@ const utils = { src?: string, iframeTitle?: string, ): string { - const title = iframeTitle ? 'title="' + iframeTitle + '"' : ''; + const title = iframeTitle + ? 'title="' + this.escapeHtml(iframeTitle) + '"' + : ''; return `
`; @@ -410,8 +426,8 @@ const utils = { sizes?: string, sources?: ImageSources[], ): string { - const srcsetAttr = srcset ? `srcset="${srcset}"` : ''; - const sizesAttr = sizes ? `sizes="${sizes}"` : ''; + const srcsetAttr = srcset ? `srcset="${this.escapeHtml(srcset)}"` : ''; + const sizesAttr = sizes ? `sizes="${this.escapeHtml(sizes)}"` : ''; const imgMarkup = ``; let sourceTag = ''; if (sources) { @@ -574,7 +590,11 @@ const utils = { dynamicEl.thumb = thumb; if (getCaptionFromTitleOrAlt && !dynamicEl.subHtml) { - dynamicEl.subHtml = title || alt || ''; + // Escape HTML when using title/alt as caption to prevent XSS + const captionText = title || alt || ''; + dynamicEl.subHtml = captionText + ? this.escapeHtml(captionText) + : ''; } dynamicEl.alt = alt || title || ''; dynamicElements.push(dynamicEl); diff --git a/src/lightgallery.ts b/src/lightgallery.ts index c36fd5fcb..02973e004 100644 --- a/src/lightgallery.ts +++ b/src/lightgallery.ts @@ -1004,13 +1004,13 @@ export class LightGallery { // Use the thumbnail as dummy image which will be resized to actual image size and // displayed on top of actual image let imgContent: string | HTMLImageElement = ''; - const altAttr = alt ? 'alt="' + alt + '"' : ''; + const altAttr = alt ? 'alt="' + utils.escapeHtml(alt) + '"' : ''; if (this.isFirstSlideWithZoomAnimation()) { imgContent = this.getDummyImageContent( $currentSlide, index, - altAttr, + alt || '', ); } else { imgContent = utils.getImgMarkup( diff --git a/test/lightgallery.test.ts b/test/lightgallery.test.ts index f40186826..84f8a0643 100644 --- a/test/lightgallery.test.ts +++ b/test/lightgallery.test.ts @@ -302,3 +302,222 @@ describe('Plugins', () => { expect(LG.galleryItems[0].poster).toBeUndefined(); }); }); + +describe('Security - XSS Prevention', () => { + it('Should escape double quotes in alt attribute to prevent attribute breakout', async () => { + // Test: Double quote to break out of attribute and inject malicious HTML + document.body.innerHTML = `
+ + "><img src=x onerror=alert(1)> + +
`; + + const LG = lightGallery( + document.getElementById('lightGallery') as HTMLElement, + ); + LG.openGallery(0); + + await waitFor(() => { + const lgObject = document.querySelector('.lg-object'); + expect(lgObject).toBeInTheDocument(); + + if (lgObject) { + const altValue = lgObject.getAttribute('alt'); + expect(altValue).toBe('">'); + } + + // Verify no malicious img was injected + const xssImg = document.querySelector('img[src="x"]'); + expect(xssImg).toBeNull(); + }); + + LG.destroy(); + }); + + it('Should escape double quotes in alt to prevent event handler injection', async () => { + // Test: Inject onload event handler + document.body.innerHTML = `
+ + " onload="alert(1)" x=" + +
`; + + const LG = lightGallery( + document.getElementById('lightGallery') as HTMLElement, + ); + LG.openGallery(0); + + await waitFor(() => { + const lgObject = document.querySelector('.lg-object'); + expect(lgObject).toBeInTheDocument(); + + if (lgObject) { + const altValue = lgObject.getAttribute('alt'); + expect(altValue).toBe('" onload="alert(1)" x="'); + + // The onload should not be an actual attribute + expect(lgObject.hasAttribute('onload')).toBe(false); + } + }); + + LG.destroy(); + }); + + it('Should escape double quotes in alt to prevent script injection', async () => { + // Test: Script tag injection + document.body.innerHTML = `
+ + "><script>alert("XSS")</script><img x=" + +
`; + + const LG = lightGallery( + document.getElementById('lightGallery') as HTMLElement, + ); + LG.openGallery(0); + + await waitFor(() => { + const lgObject = document.querySelector('.lg-object'); + expect(lgObject).toBeInTheDocument(); + + // No script tags should be injected + const scripts = document.querySelectorAll('.lg-item script'); + expect(scripts.length).toBe(0); + }); + + LG.destroy(); + }); + + it('Should handle ampersands in alt without double-encoding', async () => { + // Test: Special HTML characters + document.body.innerHTML = `
+ + Testing & signs < and > symbols + +
`; + + const LG = lightGallery( + document.getElementById('lightGallery') as HTMLElement, + ); + LG.openGallery(0); + + await waitFor(() => { + const lgObject = document.querySelector('.lg-object'); + expect(lgObject).toBeInTheDocument(); + + if (lgObject) { + const altValue = lgObject.getAttribute('alt'); + // Browser automatically decodes HTML entities in attributes + expect(altValue).toBe('Testing & signs < and > symbols'); + } + }); + + LG.destroy(); + }); + + it('Should handle single quotes in alt attribute', async () => { + // Test: Single quotes (less dangerous but should still work) + document.body.innerHTML = `
+ + It's a nice image with 'quotes' + +
`; + + const LG = lightGallery( + document.getElementById('lightGallery') as HTMLElement, + ); + LG.openGallery(0); + + await waitFor(() => { + const lgObject = document.querySelector('.lg-object'); + expect(lgObject).toBeInTheDocument(); + + if (lgObject) { + const altValue = lgObject.getAttribute('alt'); + expect(altValue).toBe("It's a nice image with 'quotes'"); + } + }); + + LG.destroy(); + }); + + it('Should escape double quotes in srcset attribute', async () => { + // Test: Srcset attribute vulnerability + document.body.innerHTML = `
+ + + +
`; + + const LG = lightGallery( + document.getElementById('lightGallery') as HTMLElement, + ); + LG.openGallery(0); + + await waitFor(() => { + const lgObject = document.querySelector('.lg-object'); + expect(lgObject).toBeInTheDocument(); + + if (lgObject) { + // Should not have onload as an actual attribute + expect(lgObject.hasAttribute('onload')).toBe(false); + } + }); + + LG.destroy(); + }); + + it('Should escape double quotes in sizes attribute', async () => { + // Test: Sizes attribute vulnerability + document.body.innerHTML = `
+ + + +
`; + + const LG = lightGallery( + document.getElementById('lightGallery') as HTMLElement, + ); + LG.openGallery(0); + + await waitFor(() => { + const lgObject = document.querySelector('.lg-object'); + expect(lgObject).toBeInTheDocument(); + + if (lgObject) { + // Should not have onload as an actual attribute + expect(lgObject.hasAttribute('onload')).toBe(false); + } + }); + + LG.destroy(); + }); + + it('Should handle backslashes and special characters in alt', async () => { + // Test: Various escape characters + document.body.innerHTML = `
+ + Test \\ backslash and "quotes" and <tags> + +
`; + + const LG = lightGallery( + document.getElementById('lightGallery') as HTMLElement, + ); + LG.openGallery(0); + + await waitFor(() => { + const lgObject = document.querySelector('.lg-object'); + expect(lgObject).toBeInTheDocument(); + + if (lgObject) { + const altValue = lgObject.getAttribute('alt'); + // Should preserve the content as-is + expect(altValue).toContain('backslash'); + expect(altValue).toContain('quotes'); + } + }); + + LG.destroy(); + }); +});