Skip to content

Commit a0c202c

Browse files
WaelAbuSeadaWaelAbuSeada
andauthored
[Security] [Hardeneing] Check integration urls (#5432)
#### Summary Introduce a method to validate urls stored in a table to hardcoded one. This is to make sure integration urls are not hijacked to a malicious endpoint #### Work Item(s) <!-- Add the issue number here after the #. The issue needs to be open and approved. Submitting PRs with no linked issues or unapproved issues is highly discouraged. --> [AB#568120](https://dynamicssmb2.visualstudio.com/1fcb79e7-ab07-432a-a3c6-6cf5a88ba4a5/_workitems/edit/568120) --------- Co-authored-by: WaelAbuSeada <[email protected]>
1 parent 3559df6 commit a0c202c

File tree

6 files changed

+158
-15
lines changed

6 files changed

+158
-15
lines changed

src/Apps/W1/EDocumentConnectors/Avalara/App/src/Authenticator.Codeunit.al

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Microsoft.EServices.EDocumentConnector.Avalara;
66

77
using System.Security.Authentication;
8+
using System.Utilities;
89
codeunit 6374 "Authenticator"
910
{
1011
Access = Internal;
@@ -129,6 +130,42 @@ codeunit 6374 "Authenticator"
129130
exit(IsolatedStorage.Contains(TokenKey, TokenDataScope));
130131
end;
131132

133+
internal procedure GetAuthURL(): Text
134+
var
135+
ConnectionSetup: Record "Connection Setup";
136+
URI: Codeunit Uri;
137+
begin
138+
if ConnectionSetup.Get() then
139+
exit(URI.ValidateIntegrationURL(ConnectionSetup."Authentication URL", AuthURLTxt));
140+
end;
141+
142+
internal procedure GetSandboxAuthURL(): Text
143+
var
144+
ConnectionSetup: Record "Connection Setup";
145+
URI: Codeunit Uri;
146+
begin
147+
if ConnectionSetup.Get() then
148+
exit(URI.ValidateIntegrationURL(ConnectionSetup."Sandbox Authentication URL", SandboxAuthURLTxt));
149+
end;
150+
151+
internal procedure GetAPIURL(): Text
152+
var
153+
ConnectionSetup: Record "Connection Setup";
154+
URI: Codeunit Uri;
155+
begin
156+
if ConnectionSetup.Get() then
157+
exit(URI.ValidateIntegrationURL(ConnectionSetup."API URL", APIURLTxt));
158+
end;
159+
160+
internal procedure GetSandboxAPIURL(): Text
161+
var
162+
ConnectionSetup: Record "Connection Setup";
163+
URI: Codeunit Uri;
164+
begin
165+
if ConnectionSetup.Get() then
166+
exit(URI.ValidateIntegrationURL(ConnectionSetup."Sandbox API URL", SandboxAPIURLTxt));
167+
end;
168+
132169
var
133170
AuthURLTxt: Label 'https://identity.avalara.com', Locked = true;
134171
APIURLTxt: Label 'https://api.avalara.com', Locked = true;

src/Apps/W1/EDocumentConnectors/Avalara/App/src/Requests.Codeunit.al

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,14 +235,15 @@ codeunit 6376 Requests
235235
procedure GetBaseUrl(): Text
236236
var
237237
ConnectionSetup: Record "Connection Setup";
238+
Authenticator: Codeunit "Authenticator";
238239
begin
239240
ConnectionSetup.Get();
240241

241242
case ConnectionSetup."Avalara Send Mode" of
242243
"Avalara Send Mode"::Production:
243-
exit(ConnectionSetup."API URL");
244+
exit(Authenticator.GetAPIURL());
244245
"Avalara Send Mode"::Test:
245-
exit(ConnectionSetup."Sandbox API URL");
246+
exit(Authenticator.GetSandboxAPIURL());
246247
else
247248
Error('Unsupported %1 in %2', ConnectionSetup.FieldCaption("Avalara Send Mode"), ConnectionSetup.TableCaption);
248249
end;
@@ -251,14 +252,15 @@ codeunit 6376 Requests
251252
local procedure GetAuthUrl(): Text
252253
var
253254
ConnectionSetup: Record "Connection Setup";
255+
Authenticator: Codeunit "Authenticator";
254256
begin
255257
ConnectionSetup.Get();
256258

257259
case ConnectionSetup."Avalara Send Mode" of
258260
"Avalara Send Mode"::Production:
259-
exit(ConnectionSetup."Authentication URL");
261+
exit(Authenticator.GetAuthURL());
260262
"Avalara Send Mode"::Test:
261-
exit(ConnectionSetup."Sandbox Authentication URL");
263+
exit(Authenticator.GetSandboxAuthURL());
262264
else
263265
Error('Unsupported %1 in %2', ConnectionSetup.FieldCaption("Avalara Send Mode"), ConnectionSetup.TableCaption);
264266
end;

src/Apps/W1/Shopify/App/src/Integration/Codeunits/ShpfyAuthenticationMgt.Codeunit.al

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -174,25 +174,20 @@ codeunit 30199 "Shpfy Authentication Mgt."
174174
end;
175175

176176
internal procedure AssertValidShopUrl(ShopUrl: Text)
177-
begin
178-
if not IsValidShopUrl(ShopUrl) then
179-
Error(InvalidShopUrlErr);
180-
end;
181-
182-
local procedure IsValidShopUrl(ShopUrl: Text): Boolean
183177
var
184-
Regex: Codeunit Regex;
178+
URI: Codeunit Uri;
185179
PatternLbl: Label '^(https)\:\/\/[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com[\/]*$', Locked = true;
186180
begin
187-
exit(Regex.IsMatch(ShopUrl, PatternLbl))
181+
if not URI.IsValidURIPattern(ShopUrl, PatternLbl) then
182+
Error(InvalidShopUrlErr);
188183
end;
189184

190185
procedure IsValidHostName(Hostname: Text): Boolean
191186
var
192-
Regex: Codeunit Regex;
187+
URI: Codeunit Uri;
193188
PatternLbl: Label '^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$', Locked = true;
194189
begin
195-
exit(Regex.IsMatch(Hostname, PatternLbl))
190+
exit(URI.IsValidURIPattern(Hostname, PatternLbl));
196191
end;
197192

198193
procedure CorrectShopUrl(var ShopUrl: Text[250])

src/System Application/App/URI/app.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
"name": "DotNet Aliases",
1717
"publisher": "Microsoft",
1818
"version": "28.0.0.0"
19+
},
20+
{
21+
"id": "b185fd4a-677b-48d3-a701-768de7563df0",
22+
"name": "Regex",
23+
"publisher": "Microsoft",
24+
"version": "28.0.0.0"
1925
}
2026
],
2127
"screenshots": [],
@@ -28,4 +34,4 @@
2834
"platform": "28.0.0.0",
2935
"target": "OnPrem",
3036
"contextSensitiveHelpUrl": "https://learn.microsoft.com/dynamics365/business-central/"
31-
}
37+
}

src/System Application/App/URI/src/Uri.Codeunit.al

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,49 @@ codeunit 3060 Uri
192192
exit(Uri.IsBaseOf(LocalUri));
193193
end;
194194

195+
/// <summary>
196+
/// Validates integration URI host from setup table field is the same as the hardcoded integration URI host, returns the hardcoded integration URI if validation fails.
197+
/// </summary>
198+
/// <param name="FieldValueURL">The integration URL from the setup table field.</param>
199+
/// <param name="HardcodedIntegrationURL">The hardcoded integration URL to validate.</param>
200+
/// <returns>The integration URL from the setup table if it has the same host; otherwise, the provided integration URL.</returns>
201+
procedure ValidateIntegrationURL(FieldValueURL: Text; HardcodedIntegrationURL: Text): Text
202+
begin
203+
if AreURIsHaveSameHost(FieldValueURL, HardcodedIntegrationURL) then
204+
exit(FieldValueURL)
205+
else
206+
exit(HardcodedIntegrationURL);
207+
end;
208+
209+
/// <summary>
210+
/// Verifies whether two URIs have the same host (e.g., both subdomain.example.com\test1 and subdomain.example.com\test2 have the same host subdomain.example.com).
211+
/// </summary>
212+
/// <param name="UriString1">The first URI string.</param>
213+
/// <param name="UriString2">The second URI string.</param>
214+
/// <returns>True if both URIs have the same host; otherwise, false.</returns>
215+
procedure AreURIsHaveSameHost(UriString1: Text; UriString2: Text): Boolean
216+
var
217+
Uri1, Uri2 : DotNet Uri;
218+
begin
219+
Uri1 := Uri1.Uri(UriString1.ToLower());
220+
Uri2 := Uri2.Uri(UriString2.ToLower());
221+
222+
exit(Uri1.Host = Uri2.Host);
223+
end;
224+
225+
/// <summary>
226+
/// Verifies whether the URI is in a valid pattern.
227+
/// </summary>
228+
/// <param name="UriString">The URI string to validate.</param>
229+
/// <param name="Pattern">The regular expression pattern to match against.</param>
230+
/// <returns>True if the URI string is in a valid pattern; otherwise, false.</returns>
231+
procedure IsValidURIPattern(UriString: Text; Pattern: Text): Boolean
232+
var
233+
RegEx: Codeunit Regex;
234+
begin
235+
exit(Regex.IsMatch(UriString, Pattern));
236+
end;
237+
195238
/// <summary>
196239
/// Gets the underlying .Net Uri variable.
197240
/// </summary>

src/System Application/Test/URI/src/UriTest.Codeunit.al

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,66 @@ codeunit 135070 "Uri Test"
229229
TestUriWellformed('http:\\\host/path/file', UriKind::Absolute, false);
230230
end;
231231

232+
[Test]
233+
[Scope('OnPrem')]
234+
procedure AreURIsHaveSameHostTest()
235+
var
236+
Uri: Codeunit Uri;
237+
begin
238+
// [Given] Two URIs with the same host
239+
// [Then] AreURIsHaveSameHost should return true
240+
LibraryAssert.IsTrue(Uri.AreURIsHaveSameHost('http://microsoft.com/path1', 'http://microsoft.com/path2'), 'URIs with same host should return true');
241+
LibraryAssert.IsTrue(Uri.AreURIsHaveSameHost('https://contoso.com/test', 'https://contoso.com/other'), 'URIs with same scheme should return true');
242+
LibraryAssert.IsTrue(Uri.AreURIsHaveSameHost('http://subdomain.example.com/test1', 'http://subdomain.example.com/test2'), 'URIs with same subdomain host should return true');
243+
244+
// [Given] URIs with different hosts
245+
// [Then] AreURIsHaveSameHost should return false
246+
LibraryAssert.IsFalse(Uri.AreURIsHaveSameHost('http://microsoft.com/path1', 'http://contoso.com/path2'), 'URIs with different hosts should return false');
247+
LibraryAssert.IsFalse(Uri.AreURIsHaveSameHost('https://example.com/test', 'https://different.com/test'), 'URIs with different hosts should return false');
248+
LibraryAssert.IsFalse(Uri.AreURIsHaveSameHost('http://subdomain1.example.com/test1', 'http://subdomain2.example.com/test2'), 'URIs with different subdomain host should return false');
249+
250+
// [Given] URIs with case differences in host
251+
// [Then] AreURIsHaveSameHost should return true (case insensitive)
252+
LibraryAssert.IsTrue(Uri.AreURIsHaveSameHost('http://Microsoft.com/path1', 'http://MICROSOFT.COM/path2'), 'URIs with case differences in host should return true');
253+
end;
254+
255+
[Test]
256+
[Scope('OnPrem')]
257+
procedure IsValidURIPatternTest()
258+
var
259+
Uri: Codeunit Uri;
260+
begin
261+
// [Given] URIs and patterns that should match
262+
// [Then] IsValidURIPattern should return true
263+
LibraryAssert.IsTrue(Uri.IsValidURIPattern('http://microsoft.com', '^https?://.*'), 'HTTP URI should match HTTP(S) pattern');
264+
LibraryAssert.IsTrue(Uri.IsValidURIPattern('https://microsoft.com', '^https?://.*'), 'HTTPS URI should match HTTP(S) pattern');
265+
LibraryAssert.IsTrue(Uri.IsValidURIPattern('ftp://files.example.com', '^ftp://.*'), 'FTP URI should match FTP pattern');
266+
LibraryAssert.IsTrue(Uri.IsValidURIPattern('https://api.contoso.com/v1/users', '.*api\..*'), 'API URI should match API pattern');
267+
LibraryAssert.IsTrue(Uri.IsValidURIPattern('http://subdomain.example.com', '.*\.example\.com$'), 'Subdomain URI should match domain pattern');
268+
LibraryAssert.IsTrue(Uri.IsValidURIPattern('https://shop1.myshopify.com/admin/dashboard', '^(https)\:\/\/[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com[\/].*$'), 'URL should match provided pattern');
269+
270+
// [Given] URIs and patterns that should not match
271+
// [Then] IsValidURIPattern should return false
272+
LibraryAssert.IsFalse(Uri.IsValidURIPattern('http://microsoft.com', '^https://.*'), 'HTTP URI should not match HTTPS-only pattern');
273+
LibraryAssert.IsFalse(Uri.IsValidURIPattern('ftp://files.example.com', '^https?://.*'), 'FTP URI should not match HTTP(S) pattern');
274+
LibraryAssert.IsFalse(Uri.IsValidURIPattern('https://contoso.com', '.*microsoft\.com$'), 'Contoso URI should not match Microsoft domain pattern');
275+
LibraryAssert.IsFalse(Uri.IsValidURIPattern('invalid-uri', '^https?://.*'), 'Invalid URI should not match valid URI pattern');
276+
end;
277+
278+
[Test]
279+
[Scope('OnPrem')]
280+
procedure ValidateIntegrationURLTest()
281+
var
282+
Uri: Codeunit Uri;
283+
begin
284+
// [Given] Integration URLs with the same host and scheme stored in the setup table
285+
LibraryAssert.AreEqual(Uri.ValidateIntegrationURL('https://valid-integration.com/api', 'https://valid-integration.com/api2'), 'https://valid-integration.com/api', 'Integration URLs should have the same host and scheme');
286+
LibraryAssert.AreEqual(Uri.ValidateIntegrationURL('https://valid-integration.com/API1', 'https://valid-integration.com/API2'), 'https://valid-integration.com/API1', 'Integration URLs should have the same host and scheme');
287+
288+
// [Given] Invalid Integration URLs with different hosts or schemes
289+
LibraryAssert.AreNotEqual(Uri.ValidateIntegrationURL('https://subdomain1.example.com/api', 'https://subdomain2.example.com/api'), 'https://subdomain1.example.com/api', 'Invalid integration URL should return false');
290+
end;
291+
232292
local procedure TestUriWellformed(UriString: Text; UriKind: Enum UriKind; ExpectedResult: Boolean)
233293
var
234294
LocalUri: Codeunit Uri;

0 commit comments

Comments
 (0)