mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 09:26:52 +01:00
Sync knowledge articles with review agent instructions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
f562fba837
commit
5bcdc55df9
62 changed files with 768 additions and 58 deletions
|
|
@ -17,13 +17,13 @@ Events in AL are extensibility contracts. Every subscriber — third-party, inte
|
|||
|
||||
## Best Practice
|
||||
|
||||
Design event signatures to carry only the data a subscriber legitimately needs. Do not pass SecretText, credential material, or flags the publisher depends on for access control. If a subscriber needs to veto an action, model it as a separate OnBefore event whose Handled pattern is documented — not as a general-purpose var Boolean callers can flip.
|
||||
Design event signatures to carry only the data a subscriber legitimately needs. Do not pass SecretText, credential material, or flags the publisher depends on for access control. Guard variables such as `HasAccess`, `SkipValidation`, or `CanExport` must not be `var` parameters on an OnBefore event; notify subscribers after the internal check with value parameters they cannot mutate.
|
||||
|
||||
See sample: `do-not-expose-sensitive-data-in-event-publishers.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
An OnBeforeElevateAccess publisher that exposes `var CanAccess: Boolean` — any subscriber installed on the tenant can flip it to true and escalate. Or a publisher that passes a SecretText parameter it obtained internally, handing it to every subscriber.
|
||||
An OnBeforeElevateAccess publisher that exposes `var CanAccess: Boolean` or `var SkipValidation: Boolean` — any subscriber installed on the tenant can flip it to true and bypass the check. Or a publisher that passes a SecretText parameter it obtained internally, handing it to every subscriber.
|
||||
|
||||
See sample: `do-not-expose-sensitive-data-in-event-publishers.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50243 "Sec Sample RecordRef Bad"
|
||||
{
|
||||
procedure ArchiveRecord(RecId: RecordId)
|
||||
var
|
||||
RecRef: RecordRef;
|
||||
begin
|
||||
RecRef.Open(RecId.TableNo);
|
||||
RecRef.Get(RecId);
|
||||
RecRef.Delete();
|
||||
RecRef.Close();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50242 "Sec Sample RecordRef Good"
|
||||
{
|
||||
internal procedure ArchiveRecord(RecId: RecordId)
|
||||
var
|
||||
RecRef: RecordRef;
|
||||
begin
|
||||
RecRef.Open(RecId.TableNo);
|
||||
RecRef.Get(RecId);
|
||||
RecRef.Delete();
|
||||
RecRef.Close();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: security
|
||||
keywords: [recordref, recordid, table-no, scope, inherentpermissions]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Keep caller-driven RecordRef.Open procedures non-public
|
||||
|
||||
## Description
|
||||
|
||||
A codeunit can hold permissions or `InherentPermissions` that its callers do not have. If it exposes a public procedure that accepts a table number or RecordId and calls `RecordRef.Open`, another extension can call that procedure to make the privileged codeunit open tables on its behalf. That turns a generic helper into a permission-bypass surface, especially for system tables.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Procedures that call `RecordRef.Open` with a caller-provided table number must be `local`, `internal`, or `[Scope('OnPrem')]`. If the procedure truly must be public in SaaS, validate the table number against a narrow allowlist before opening the RecordRef.
|
||||
|
||||
See sample: `keep-recordref-open-callers-non-public.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A public helper such as `ArchiveRecord(RecId: RecordId)` that opens `RecId.TableNo` and then reads, modifies, or deletes through RecordRef. The helper compiles, but it lets untrusted callers choose which table the privileged code opens.
|
||||
|
||||
See sample: `keep-recordref-open-callers-non-public.bad.al`.
|
||||
|
|
@ -10,6 +10,7 @@ codeunit 50209 "Sec Sample IsolatedStorage Bad"
|
|||
var
|
||||
ApiKey: Text;
|
||||
begin
|
||||
// Public wrapper: another extension can call this to read the secret.
|
||||
if IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey) then
|
||||
exit(ApiKey);
|
||||
exit('');
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
codeunit 50208 "Sec Sample IsolatedStorage Good"
|
||||
{
|
||||
procedure StoreApiKey(NewKey: SecretText)
|
||||
internal procedure StoreApiKey(NewKey: SecretText)
|
||||
begin
|
||||
IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module);
|
||||
end;
|
||||
|
||||
procedure TryGetApiKey(var ApiKey: SecretText): Boolean
|
||||
local procedure TryGetApiKey(var ApiKey: SecretText): Boolean
|
||||
begin
|
||||
if IsolatedStorage.Contains('ApiKey', DataScope::Module) then
|
||||
exit(IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey));
|
||||
|
|
|
|||
|
|
@ -17,13 +17,13 @@ IsolatedStorage is a per-extension, per-tenant key-value store. DataScope::Modul
|
|||
|
||||
## Best Practice
|
||||
|
||||
Use IsolatedStorage.SetEncrypted to write secrets, IsolatedStorage.Contains to probe, and IsolatedStorage.Get into a SecretText destination to read. Choose DataScope::Company for per-company credentials (for example, a tenant-per-company service account) and DataScope::Module for extension-wide configuration.
|
||||
Use IsolatedStorage.SetEncrypted to write secrets, IsolatedStorage.Contains to probe, and IsolatedStorage.Get into a SecretText destination to read. Choose DataScope::Company for per-company credentials (for example, a tenant-per-company service account) and DataScope::Module for extension-wide configuration. Procedures that call IsolatedStorage.Get, Set, SetEncrypted, Contains, or Delete must be `local` or `internal`; a public wrapper lets other extensions call into your storage boundary.
|
||||
|
||||
See sample: `use-isolated-storage-for-module-and-company-secrets.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Storing secrets in a Setup table column as plain Text, or using IsolatedStorage.Set (unencrypted) for values that authenticate the extension to an external service. Both shapes leave the secret readable by anyone with read rights on the underlying storage.
|
||||
Storing secrets in a Setup table column as plain Text, using IsolatedStorage.Set (unencrypted) for values that authenticate the extension to an external service, or exposing a public Get/Set procedure around IsolatedStorage. The first two leave secrets readable; the public wrapper lets another extension exfiltrate or overwrite values through your codeunit.
|
||||
|
||||
See sample: `use-isolated-storage-for-module-and-company-secrets.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,4 +13,9 @@ codeunit 50217 "Sec Sample NonDebuggable Bad"
|
|||
JObject.Get('access_token', JToken);
|
||||
SessionToken := JToken.AsValue().AsText();
|
||||
end;
|
||||
|
||||
procedure BuildAuthorizationHeader(ApiKey: SecretText): Text
|
||||
begin
|
||||
exit('Bearer ' + ApiKey.Unwrap());
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,4 +12,10 @@ codeunit 50216 "Sec Sample NonDebuggable Good"
|
|||
JObject.Get('access_token', JToken);
|
||||
SessionToken := JToken.AsValue().AsText();
|
||||
end;
|
||||
|
||||
[NonDebuggable]
|
||||
procedure BuildAuthorizationHeader(ApiKey: SecretText): Text
|
||||
begin
|
||||
exit('Bearer ' + ApiKey.Unwrap());
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,17 +13,17 @@ application-area: [all]
|
|||
|
||||
## Description
|
||||
|
||||
SecretText transit (assignment between SecretText variables, parameters, and return values) is protected automatically. Extracting a secret from a Text source — for example, reading an access token out of a parsed JSON response — is a legitimate Text-to-SecretText conversion during which the plaintext exists. The [NonDebuggable] attribute prevents debuggers (regular and snapshot) from inspecting the procedure's locals, parameters, and return at that moment.
|
||||
SecretText transit (assignment between SecretText variables, parameters, and return values) is protected automatically. Extracting a secret from a Text source — for example, reading an access token out of a parsed JSON response — is a legitimate Text-to-SecretText conversion during which the plaintext exists. Calling `SecretText.Unwrap()` has the same exposure in the opposite direction: it materializes the secret as plain Text. The [NonDebuggable] attribute prevents debuggers (regular and snapshot) from inspecting the procedure's locals, parameters, and return at that moment.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Apply [NonDebuggable] to any procedure that reads a response body, parses it, and assigns the extracted secret to a SecretText out-parameter or return. Keep the procedure narrow: it SHOULD do the minimum work required to obtain the SecretText, and nothing else.
|
||||
Apply [NonDebuggable] to any procedure that reads a response body, parses it, and assigns the extracted secret to a SecretText out-parameter or return. Also apply it to every procedure that calls `Unwrap()` because the secret becomes plain Text inside that procedure. Keep the procedure narrow: it SHOULD do the minimum work required to obtain or unwrap the secret, and nothing else.
|
||||
|
||||
See sample: `use-nondebuggable-when-parsing-secrets.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Parsing a token response in a normal (debuggable) procedure. The plaintext token is visible in debug sessions and snapshots taken during the parse.
|
||||
Parsing a token response in a normal (debuggable) procedure, or calling `ApiKey.Unwrap()` there to build a legacy Text value. The plaintext token is visible in debug sessions and snapshots taken during the parse or unwrap.
|
||||
|
||||
See sample: `use-nondebuggable-when-parsing-secrets.bad.al`.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
codeunit 50241 "Sec Sample Url Bad"
|
||||
{
|
||||
procedure Sync(ServiceUrl: Text)
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
begin
|
||||
Client.Get(ServiceUrl, Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
codeunit 50240 "Sec Sample Url Good"
|
||||
{
|
||||
procedure Sync(ServiceUrl: Text)
|
||||
var
|
||||
Client: HttpClient;
|
||||
Response: HttpResponseMessage;
|
||||
Uri: Codeunit Uri;
|
||||
ExpectedBaseUrl: Text;
|
||||
begin
|
||||
ExpectedBaseUrl := 'https://api.contoso.com';
|
||||
|
||||
if not Uri.AreURIsHaveSameHost(ServiceUrl, ExpectedBaseUrl) then
|
||||
Error('Service URL must point to api.contoso.com.');
|
||||
|
||||
Client.Get(ServiceUrl, Response);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: security
|
||||
keywords: [url, uri, httpclient, ssrf, validation, endpoint]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Validate user-configurable URLs before HTTP calls
|
||||
|
||||
## Description
|
||||
|
||||
URLs stored in setup tables or accepted from user input are user-configurable endpoints. Passing them directly to `HttpClient` lets a malicious or compromised setup value redirect the extension to internal services, metadata endpoints, or attacker-controlled hosts. Business Central's System Application `Uri` codeunit provides host and pattern validation helpers for this exact boundary.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Before `HttpClient.Get`, `Post`, `Put`, or similar calls use a URL from a table field, validate it with `Uri.AreURIsHaveSameHost()` when the host must be fixed, or `Uri.IsValidURIPattern()` when a known URL pattern is allowed. Validate before writing the request body so sensitive payloads are never sent to an unexpected host.
|
||||
|
||||
See sample: `validate-user-configurable-urls-before-http-calls.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Reading `Setup."Service URL"` or `WebhookSetup."Callback URL"` and passing it directly to HttpClient. The code looks configurable, but it creates an SSRF path and can exfiltrate data to whichever host the setup row names.
|
||||
|
||||
See sample: `validate-user-configurable-urls-before-http-calls.bad.al`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue