🔣 fix: Escape SPA Language Attribute (#15248)

This commit is contained in:
Danny Avila 2026-08-26 07:37:36 -04:00 committed by GitHub
parent 6d8b1cb013
commit f34a49007d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 34 additions and 4 deletions

View file

@ -195,6 +195,14 @@ describe('Content Security Policy', () => {
expect(response.headers['content-security-policy']).toContain("script-src 'nonce-");
});
it('keeps replacement patterns in the language cookie as literal attribute text', async () => {
const response = await request(app).get('/').set('Cookie', 'lang=$&');
expect(response.status).toBe(200);
expect(response.text).toContain('<html lang="$&amp;">');
expect(response.text).not.toContain('<html lang="lang="en-US"">');
});
it('carries deployment-specific sources and the clickjacking default', async () => {
const csp = (await request(app).get('/')).headers['content-security-policy'];

View file

@ -19,6 +19,7 @@ const {
applyCspNonce,
createCspPolicy,
shellCacheHeaders,
escapeHtmlAttribute,
ErrorController,
QUERY_DEVTOOLS_HEADER,
createSecurityHeaders,
@ -434,8 +435,8 @@ if (cluster.isMaster) {
res.vary(QUERY_DEVTOOLS_HEADER);
const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
const saneLang = lang.replace(/"/g, '&quot;');
let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`);
const saneLang = escapeHtmlAttribute(lang);
let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, () => `lang="${saneLang}"`);
updatedIndexHtml = maybeInjectQueryDevtoolsBootstrap(updatedIndexHtml, req);
/* Nonce last: every injected script above must be stamped too. */

View file

@ -20,6 +20,7 @@ const {
applyCspNonce,
createCspPolicy,
shellCacheHeaders,
escapeHtmlAttribute,
ErrorController,
memoryDiagnostics,
createSecurityHeaders,
@ -252,8 +253,8 @@ const startServer = async () => {
res.vary(QUERY_DEVTOOLS_HEADER);
const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
const saneLang = lang.replace(/"/g, '&quot;');
let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`);
const saneLang = escapeHtmlAttribute(lang);
let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, () => `lang="${saneLang}"`);
updatedIndexHtml = maybeInjectQueryDevtoolsBootstrap(updatedIndexHtml, req);
/* Nonce last: every injected script above must be stamped too. */

View file

@ -0,0 +1,7 @@
import { escapeHtmlAttribute } from './html';
describe('escapeHtmlAttribute', () => {
it('escapes all characters that can alter a quoted attribute', () => {
expect(escapeHtmlAttribute(`$&<>'"`)).toBe('$&amp;&lt;&gt;&#39;&quot;');
});
});

View file

@ -0,0 +1,12 @@
const HTML_ATTRIBUTE_ENTITIES: Readonly<Record<string, string>> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
/** Escapes text before interpolating it into a quoted HTML attribute. */
export function escapeHtmlAttribute(value: string): string {
return value.replace(/[&<>"']/g, (character) => HTML_ATTRIBUTE_ENTITIES[character]);
}

View file

@ -1,3 +1,4 @@
export * from './env';
export * from './headers';
export * from './csp';
export * from './html';