Skip to content

Commit 60eb045

Browse files
authored
Unify some of the appid code, improve script tag detection and add validation (#2519)
* Some unification * Put a few things back * Put back * Remove unused import * changefile * More test fixes * More test fixes * Tests passing * Update domains * Update changelog
1 parent 0f935e3 commit 60eb045

12 files changed

+234
-208
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Updated internal app id validation",
4+
"packageName": "@microsoft/teams-js",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

packages/teams-js/src/internal/appIdValidation.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { hasScriptTags } from './utils';
2+
13
/**
24
* This function can be used to validate if a string is a "valid" app id.
35
* Valid is a relative term, in this case. Truly valid app ids are UUIDs as documented in the schema:
@@ -10,7 +12,7 @@
1012
* @throws Error with a message describing the exact validation violation
1113
*/
1214
export function validateStringAsAppId(potentialAppId: string): void {
13-
if (doesStringContainScriptTags(potentialAppId)) {
15+
if (hasScriptTags(potentialAppId)) {
1416
throw new Error(`Potential app id (${potentialAppId}) is invalid; it contains script tags.`);
1517
}
1618

@@ -25,11 +27,6 @@ export function validateStringAsAppId(potentialAppId: string): void {
2527
}
2628
}
2729

28-
export function doesStringContainScriptTags(str: string): boolean {
29-
const scriptRegex = /<script[^>]*>[\s\S]*?<\/script[^>]*>/gi;
30-
return scriptRegex.test(str);
31-
}
32-
3330
export const minimumValidAppIdLength = 4;
3431
export const maximumValidAppIdLength = 256;
3532

packages/teams-js/src/internal/utils.ts

Lines changed: 10 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -460,43 +460,19 @@ export function fullyQualifyUrlString(fullOrRelativePath: string): URL {
460460
}
461461

462462
/**
463-
* The hasScriptTags function first decodes any HTML entities in the input string using the decodeHTMLEntities function.
464-
* It then tries to decode the result as a URI component. If the URI decoding fails (which would throw an error), it assumes that the input was not encoded and uses the original input.
465-
* Next, it defines a regular expression scriptRegex that matches any string that starts with <script (followed by any characters), then has any characters (including newlines),
466-
* and ends with </script> (preceded by any characters).
467-
* Finally, it uses the test method to check if the decoded input matches this regular expression. The function returns true if a match is found and false otherwise.
468-
* @param input URL converted to string to pattern match
463+
* Detects if there are any script tags in a given string, even if they are Uri encoded or encoded as HTML entities.
464+
* @param input string to test for script tags
469465
* @returns true if the input string contains a script tag, false otherwise
470466
*/
471-
function hasScriptTags(input: string): boolean {
472-
let decodedInput;
473-
try {
474-
const decodedHTMLInput = decodeHTMLEntities(input);
475-
decodedInput = decodeURIComponent(decodedHTMLInput);
476-
} catch (e) {
477-
// input was not encoded, use it as is
478-
decodedInput = input;
479-
}
480-
const scriptRegex = /<script[^>]*>[\s\S]*?<\/script[^>]*>/gi;
481-
return scriptRegex.test(decodedInput);
482-
}
467+
export function hasScriptTags(input: string): boolean {
468+
const openingScriptTagRegex = /<script[^>]*>|&lt;script[^&]*&gt;|%3Cscript[^%]*%3E/gi;
469+
const closingScriptTagRegex = /<\/script[^>]*>|&lt;\/script[^&]*&gt;|%3C\/script[^%]*%3E/gi;
483470

484-
/**
485-
* The decodeHTMLEntities function replaces HTML entities in the input string with their corresponding characters.
486-
*/
487-
function decodeHTMLEntities(input: string): string {
488-
const entityMap = new Map<string, string>([
489-
['&lt;', '<'],
490-
['&gt;', '>'],
491-
['&amp;', '&'],
492-
['&quot;', '"'],
493-
['&#39;', "'"],
494-
['&#x2F;', '/'],
495-
]);
496-
entityMap.forEach((value, key) => {
497-
input = input.replace(new RegExp(key, 'gi'), value);
498-
});
499-
return input;
471+
const openingOrClosingScriptTagRegex = new RegExp(
472+
`${openingScriptTagRegex.source}|${closingScriptTagRegex.source}`,
473+
'gi',
474+
);
475+
return openingOrClosingScriptTagRegex.test(input);
500476
}
501477

502478
function isIdLengthValid(id: string): boolean {

packages/teams-js/src/private/externalAppAuthentication.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { sendMessageToParentAsync } from '../internal/communication';
22
import { ensureInitialized } from '../internal/internalAPIs';
33
import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemetry';
44
import { validateId, validateUrl } from '../internal/utils';
5+
import { AppId } from '../public';
56
import { errorNotSupportedOnPlatform, FrameContexts } from '../public/constants';
67
import { runtime } from '../public/runtime';
78

@@ -351,7 +352,7 @@ export namespace externalAppAuthentication {
351352
if (!isSupported()) {
352353
throw errorNotSupportedOnPlatform;
353354
}
354-
validateId(appId, new Error('App id is not valid.'));
355+
const typeSafeAppId: AppId = new AppId(appId);
355356
validateOriginalRequestInfo(originalRequestInfo);
356357

357358
// Ask the parent window to open an authentication window with the parameters provided by the caller.
@@ -362,7 +363,7 @@ export namespace externalAppAuthentication {
362363
),
363364
'externalAppAuthentication.authenticateAndResendRequest',
364365
[
365-
appId,
366+
typeSafeAppId.toString(),
366367
originalRequestInfo,
367368
authenticateParameters.url.href,
368369
authenticateParameters.width,
@@ -395,14 +396,15 @@ export namespace externalAppAuthentication {
395396
if (!isSupported()) {
396397
throw errorNotSupportedOnPlatform;
397398
}
398-
validateId(appId, new Error('App id is not valid.'));
399+
const typeSafeAppId: AppId = new AppId(appId);
400+
399401
return sendMessageToParentAsync(
400402
getApiVersionTag(
401403
externalAppAuthenticationTelemetryVersionNumber,
402404
ApiName.ExternalAppAuthentication_AuthenticateWithSSO,
403405
),
404406
'externalAppAuthentication.authenticateWithSSO',
405-
[appId, authTokenRequest.claims, authTokenRequest.silent],
407+
[typeSafeAppId.toString(), authTokenRequest.claims, authTokenRequest.silent],
406408
).then(([wasSuccessful, error]: [boolean, InvokeError]) => {
407409
if (!wasSuccessful) {
408410
throw error;
@@ -431,7 +433,7 @@ export namespace externalAppAuthentication {
431433
if (!isSupported()) {
432434
throw errorNotSupportedOnPlatform;
433435
}
434-
validateId(appId, new Error('App id is not valid.'));
436+
const typeSafeAppId: AppId = new AppId(appId);
435437

436438
validateOriginalRequestInfo(originalRequestInfo);
437439

@@ -441,7 +443,7 @@ export namespace externalAppAuthentication {
441443
ApiName.ExternalAppAuthentication_AuthenticateWithSSOAndResendRequest,
442444
),
443445
'externalAppAuthentication.authenticateWithSSOAndResendRequest',
444-
[appId, originalRequestInfo, authTokenRequest.claims, authTokenRequest.silent],
446+
[typeSafeAppId.toString(), originalRequestInfo, authTokenRequest.claims, authTokenRequest.silent],
445447
).then(([wasSuccessful, response]: [boolean, IInvokeResponse | InvokeErrorWrapper]) => {
446448
if (wasSuccessful && response.responseType != null) {
447449
return response as IInvokeResponse;

packages/teams-js/src/private/externalAppCardActions.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { sendMessageToParentAsync } from '../internal/communication';
22
import { ensureInitialized } from '../internal/internalAPIs';
33
import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemetry';
4-
import { validateId } from '../internal/utils';
4+
import { AppId } from '../public';
55
import { errorNotSupportedOnPlatform, FrameContexts } from '../public/constants';
66
import { runtime } from '../public/runtime';
77
import { ExternalAppErrorCode } from './constants';
@@ -96,15 +96,15 @@ export namespace externalAppCardActions {
9696
if (!isSupported()) {
9797
throw errorNotSupportedOnPlatform;
9898
}
99-
validateId(appId, new Error('App id is not valid.'));
99+
const typeSafeAppId: AppId = new AppId(appId);
100100

101101
return sendMessageToParentAsync<[boolean, ActionSubmitError]>(
102102
getApiVersionTag(
103103
externalAppCardActionsTelemetryVersionNumber,
104104
ApiName.ExternalAppCardActions_ProcessActionSubmit,
105105
),
106106
'externalAppCardActions.processActionSubmit',
107-
[appId, actionSubmitPayload],
107+
[typeSafeAppId.toString(), actionSubmitPayload],
108108
).then(([wasSuccessful, error]: [boolean, ActionSubmitError]) => {
109109
if (!wasSuccessful) {
110110
throw error;
@@ -135,14 +135,14 @@ export namespace externalAppCardActions {
135135
if (!isSupported()) {
136136
throw errorNotSupportedOnPlatform;
137137
}
138-
validateId(appId, new Error('App id is not valid.'));
138+
const typeSafeAppId: AppId = new AppId(appId);
139139
return sendMessageToParentAsync<[ActionOpenUrlError, ActionOpenUrlType]>(
140140
getApiVersionTag(
141141
externalAppCardActionsTelemetryVersionNumber,
142142
ApiName.ExternalAppCardActions_ProcessActionOpenUrl,
143143
),
144144
'externalAppCardActions.processActionOpenUrl',
145-
[appId, url.href, fromElement],
145+
[typeSafeAppId.toString(), url.href, fromElement],
146146
).then(([error, response]: [ActionOpenUrlError, ActionOpenUrlType]) => {
147147
if (error) {
148148
throw error;

packages/teams-js/src/private/externalAppCommands.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { sendMessageToParentAsync } from '../internal/communication';
22
import { ensureInitialized } from '../internal/internalAPIs';
33
import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemetry';
4-
import { validateId } from '../internal/utils';
4+
import { AppId } from '../public';
55
import { errorNotSupportedOnPlatform, FrameContexts } from '../public/constants';
66
import { runtime } from '../public/runtime';
77
import { ExternalAppErrorCode } from './constants';
@@ -135,12 +135,12 @@ export namespace externalAppCommands {
135135
if (!isSupported()) {
136136
throw errorNotSupportedOnPlatform;
137137
}
138-
validateId(appId, new Error('App id is not valid.'));
138+
const typeSafeAppId: AppId = new AppId(appId);
139139

140140
const [error, response] = await sendMessageToParentAsync<[ActionCommandError, IActionCommandResponse]>(
141141
getApiVersionTag(externalAppCommandsTelemetryVersionNumber, ApiName.ExternalAppCommands_ProcessActionCommands),
142142
ApiName.ExternalAppCommands_ProcessActionCommands,
143-
[appId, commandId, extractedParameters],
143+
[typeSafeAppId.toString(), commandId, extractedParameters],
144144
);
145145
if (error) {
146146
throw error;

packages/teams-js/test/internal/appIdValidation.spec.ts

Lines changed: 4 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
doesStringContainNonPrintableCharacters,
3-
doesStringContainScriptTags,
43
isStringWithinAppIdLengthLimits,
54
maximumValidAppIdLength,
65
minimumValidAppIdLength,
@@ -58,37 +57,6 @@ describe('isStringWithinAppIdLengthLimits', () => {
5857
});
5958
});
6059

61-
describe('doesStringContainScriptTags', () => {
62-
test('should return true for strings containing script tags', () => {
63-
expect(doesStringContainScriptTags('<script>alert("Hello")</script>')).toBe(true);
64-
expect(doesStringContainScriptTags('<script src="example.js"></script>')).toBe(true);
65-
expect(doesStringContainScriptTags('<script type="text/javascript">console.log("test")</script>')).toBe(true);
66-
});
67-
68-
test('should return false for strings without script tags', () => {
69-
expect(doesStringContainScriptTags('This is a test string')).toBe(false);
70-
expect(doesStringContainScriptTags('8e6523aa-97f9-49ad-8614-75cae22f6597')).toBe(false);
71-
expect(doesStringContainScriptTags('com.microsoft.teamspace.tab.youtube')).toBe(false);
72-
expect(doesStringContainScriptTags('<div>This is a div</div>')).toBe(false);
73-
expect(doesStringContainScriptTags('<a href="example.com">Link</a>')).toBe(false);
74-
});
75-
76-
test('should return true for strings with script tags containing newlines and spaces', () => {
77-
expect(doesStringContainScriptTags('<script>\nalert("Hello")\n</script>')).toBe(true);
78-
expect(doesStringContainScriptTags('<script> \n console.log("test") \n </script>')).toBe(true);
79-
});
80-
81-
test('should return false for empty string', () => {
82-
expect(doesStringContainScriptTags('')).toBe(false);
83-
});
84-
85-
test('should return true for strings with multiple script tags', () => {
86-
expect(doesStringContainScriptTags('<script>alert("Hello")</script><script>console.log("test")</script>')).toBe(
87-
true,
88-
);
89-
});
90-
});
91-
9260
// Since there are plenty of tests validating the individual validation functions, these tests are intentionally not as
9361
// comprehensive as those. It executes a few basic tests and also validates that the error messages thrown are as expected.
9462
describe('validateStringAsAppId', () => {
@@ -98,15 +66,15 @@ describe('validateStringAsAppId', () => {
9866
});
9967

10068
test('should throw error with "script" in message for app id containing script tag', () => {
101-
expect(() => validateStringAsAppId('<script>alert("Hello")</script>')).toThrowError(/script/);
69+
expect(() => validateStringAsAppId('<script>alert("Hello")</script>')).toThrowError(/script/i);
10270
});
10371

10472
test('should throw error with "length" in message for app id too long or too short', () => {
105-
expect(() => validateStringAsAppId('a')).toThrowError(/length/);
106-
expect(() => validateStringAsAppId('a'.repeat(maximumValidAppIdLength))).toThrowError(/length/);
73+
expect(() => validateStringAsAppId('a')).toThrowError(/length/i);
74+
expect(() => validateStringAsAppId('a'.repeat(maximumValidAppIdLength))).toThrowError(/length/i);
10775
});
10876

10977
test('should throw error with "printable" in message for app id containing non-printable characters', () => {
110-
expect(() => validateStringAsAppId('hello\u0080world')).toThrowError(/printable/);
78+
expect(() => validateStringAsAppId('hello\u0080world')).toThrowError(/printable/i);
11179
});
11280
});

packages/teams-js/test/internal/utils.spec.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
compareSDKVersions,
44
createTeamsAppLink,
55
getBase64StringFromBlob,
6+
hasScriptTags,
67
validateId,
78
validateUrl,
89
validateUuid,
@@ -367,6 +368,105 @@ describe('utils', () => {
367368
});
368369
});
369370

371+
describe('hasScriptTags', () => {
372+
test('detects plain opening <script> tag', () => {
373+
expect(hasScriptTags('<script>alert("XSS")</script>')).toBe(true);
374+
});
375+
376+
test('detects HTML entity encoded opening <script> tag', () => {
377+
expect(hasScriptTags('&lt;script&gt;alert("XSS")&lt;/script&gt;')).toBe(true);
378+
});
379+
380+
test('detects URI encoded opening <script> tag', () => {
381+
expect(hasScriptTags('%3Cscript%3Ealert("XSS")%3C/script%3E')).toBe(true);
382+
});
383+
384+
test('detects plain closing </script> tag', () => {
385+
expect(hasScriptTags('</script>')).toBe(true);
386+
});
387+
388+
test('detects HTML entity encoded closing </script> tag', () => {
389+
expect(hasScriptTags('&lt;/script&gt;')).toBe(true);
390+
});
391+
392+
test('detects URI encoded closing </script> tag', () => {
393+
expect(hasScriptTags('%3C/script%3E')).toBe(true);
394+
});
395+
396+
test('returns false for strings without <script> tags', () => {
397+
expect(hasScriptTags('<div>no script here</div>')).toBe(false);
398+
});
399+
400+
test('detects mixed content with <script> tags', () => {
401+
expect(hasScriptTags('<div><script>alert("XSS")</script></div>')).toBe(true);
402+
});
403+
404+
test('returns false for empty string', () => {
405+
expect(hasScriptTags('')).toBe(false);
406+
});
407+
408+
test('detects multiple <script> tags', () => {
409+
expect(hasScriptTags('<script>alert("XSS")</script><script>alert("XSS2")</script>')).toBe(true);
410+
});
411+
412+
test('detects <script> tags with attributes', () => {
413+
expect(hasScriptTags('<script type="text/javascript">alert("XSS")</script>')).toBe(true);
414+
expect(hasScriptTags('<script src="example.js"></script>')).toBe(true);
415+
expect(hasScriptTags('<script async defer>alert("XSS")</script>')).toBe(true);
416+
});
417+
418+
test('detects HTML entity encoded <script> tag with attributes', () => {
419+
expect(hasScriptTags('&lt;script type="text/javascript"&gt;alert("XSS")&lt;/script&gt;')).toBe(true);
420+
expect(hasScriptTags('&lt;script src="example.js"&gt;&lt;/script&gt;')).toBe(true);
421+
});
422+
423+
test('detects URI encoded <script> tag with attributes', () => {
424+
expect(hasScriptTags('%3Cscript%20type=%22text/javascript%22%3Ealert("XSS")%3C/script%3E')).toBe(true);
425+
expect(hasScriptTags('%3Cscript%20src=%22example.js%22%3E%3C/script%3E')).toBe(true);
426+
});
427+
428+
test('detects <script> tags with spaces', () => {
429+
expect(hasScriptTags('<script >alert("XSS")</script >')).toBe(true);
430+
});
431+
432+
test('detects plain opening <script> tag with URI encoded closing tag', () => {
433+
expect(hasScriptTags('<script>alert("XSS")%3C/script%3E')).toBe(true);
434+
});
435+
436+
test('detects URI encoded opening <script> tag with plain closing tag', () => {
437+
expect(hasScriptTags('%3Cscript%3Ealert("XSS")</script>')).toBe(true);
438+
});
439+
440+
test('detects plain opening <script> tag with HTML entity encoded closing tag', () => {
441+
expect(hasScriptTags('<script>alert("XSS")&lt;/script&gt;')).toBe(true);
442+
});
443+
444+
test('detects HTML entity encoded opening <script> tag with plain closing tag', () => {
445+
expect(hasScriptTags('&lt;script&gt;alert("XSS")</script>')).toBe(true);
446+
});
447+
448+
test('detects nested <script> tags', () => {
449+
expect(hasScriptTags('<script><script>alert("nested")</script></script>')).toBe(true);
450+
});
451+
452+
test('detects <script> tags with unusual but valid attributes', () => {
453+
expect(hasScriptTags('<script data-custom="value">alert("XSS")</script>')).toBe(true);
454+
expect(hasScriptTags('<script nonce="random">alert("XSS")</script>')).toBe(true);
455+
});
456+
457+
test('detects <script> tags with different casing', () => {
458+
expect(hasScriptTags('<SCRIPT>alert("XSS")</SCRIPT>')).toBe(true);
459+
expect(hasScriptTags('&lt;SCRIPT&gt;alert("XSS")&lt;/SCRIPT&gt;')).toBe(true);
460+
expect(hasScriptTags('%3CSCRIPT%3Ealert("XSS")%3C/SCRIPT%3E')).toBe(true);
461+
});
462+
463+
test('detects mixed casing <script> tags', () => {
464+
expect(hasScriptTags('<sCRipT>alert("XSS")</sCRipT>')).toBe(true);
465+
expect(hasScriptTags('&lt;sCRipT&gt;alert("XSS")&lt;/sCRipT&gt;')).toBe(true);
466+
expect(hasScriptTags('%3CsCRipT%3Ealert("XSS")%3C/sCRipT%3E')).toBe(true);
467+
});
468+
});
469+
370470
describe('UUID class tests', () => {
371471
describe('validateUuid', () => {
372472
it('should throw error when id is undefined', async () => {

0 commit comments

Comments
 (0)