Correct security and privacy guidance

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 9c2eebc4-dcd5-4b85-8113-90772d818900
This commit is contained in:
Jesper Schulz-Wedde 2026-07-14 10:45:42 +02:00
parent 7a678d1aff
commit ec8f891954
34 changed files with 189 additions and 150 deletions

View file

@ -8,7 +8,7 @@ codeunit 50215 "Sec Sample IsoStorage Good"
exit(true);
end;
internal procedure SetApiKey(NewKey: Text)
internal procedure SetApiKey(NewKey: SecretText)
begin
IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module);
end;

View file

@ -1,5 +1,5 @@
---
bc-version: [all]
bc-version: [24..]
domain: security
keywords: [isolatedstorage, local, internal, public, getter, setter, encapsulation]
technologies: [al]

View file

@ -1,6 +1,6 @@
codeunit 50220 "Sec Sample DataScope Bad"
{
internal procedure StoreCompanyWebhook(WebhookUrl: Text)
internal procedure StoreCompanyWebhook(WebhookUrl: SecretText)
begin
IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Module);
end;

View file

@ -1,11 +1,11 @@
codeunit 50219 "Sec Sample DataScope Good"
{
internal procedure StoreTenantApiKey(ApiKey: Text)
internal procedure StoreTenantApiKey(ApiKey: SecretText)
begin
IsolatedStorage.SetEncrypted('TenantApiKey', ApiKey, DataScope::Module);
end;
internal procedure StoreCompanyWebhook(WebhookUrl: Text)
internal procedure StoreCompanyWebhook(WebhookUrl: SecretText)
begin
IsolatedStorage.SetEncrypted('WebhookUrl', WebhookUrl, DataScope::Company);
end;

View file

@ -1,5 +1,5 @@
---
bc-version: [all]
bc-version: [24..]
domain: security
keywords: [isolatedstorage, datascope, module, company, user, scope]
technologies: [al]

View file

@ -1,10 +1,11 @@
codeunit 50217 "Sec Sample SetEncrypted Good"
{
internal procedure StoreApiKey(ApiKeyValue: Text)
internal procedure StoreApiKey(ApiKeyValue: SecretText)
var
StoreApiKeyFailedErr: Label 'The API key could not be stored.';
begin
if StrLen(ApiKeyValue) > 200 then
Error('API key too long for encrypted storage');
IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module);
if not IsolatedStorage.SetEncrypted('ApiKey', ApiKeyValue, DataScope::Module) then
Error(StoreApiKeyFailedErr);
end;
local procedure ReadApiKey(var ApiKey: SecretText): Boolean

View file

@ -1,5 +1,5 @@
---
bc-version: [all]
bc-version: [24..]
domain: security
keywords: [isolatedstorage, setencrypted, encryption, secret, storage]
technologies: [al]
@ -15,7 +15,7 @@ application-area: [all]
## Best Practice
Use `IsolatedStorage.SetEncrypted` for every value that meets the definition of a secret. Pair it with the matching retrieval pattern: `IsolatedStorage.Contains` to test for presence and `IsolatedStorage.Get` (preferably with a `SecretText` destination) to read. Constrain the input length before storing — long values can exceed the encrypted-storage size limit and the write will fail at runtime. See sample: `isolatedstorage-setencrypted-for-sensitive-values.good.al`.
Use the `SecretText` overloads of `IsolatedStorage.SetEncrypted` and `IsolatedStorage.Get` for values that meet the definition of a secret. Check the optional Boolean result when storage failure needs a controlled error; encrypted values are subject to the documented storage-size limit. See sample: `isolatedstorage-setencrypted-for-sensitive-values.good.al`.
## Anti Pattern

View file

@ -1,19 +1,14 @@
codeunit 50214 "Sec Sample NonDebug Bad"
{
procedure BuildConnectionString(ApiKey: SecretText): Text
procedure CallLegacyOnPremisesConsumer(ApiKey: SecretText)
var
PlainApiKey: Text;
begin
exit('Server=db.example.com;Key=' + ApiKey.Unwrap());
PlainApiKey := ApiKey.Unwrap();
InvokeLegacyConsumer(PlainApiKey);
end;
procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText)
var
ResponseText: Text;
JsonObject: JsonObject;
JsonToken: JsonToken;
local procedure InvokeLegacyConsumer(ApiKey: Text)
begin
Response.Content.ReadAs(ResponseText);
JsonObject.ReadFrom(ResponseText);
JsonObject.Get('access_token', JsonToken);
SessionToken := JsonToken.AsValue().AsText();
end;
}

View file

@ -1,21 +1,17 @@
codeunit 50213 "Sec Sample NonDebug Good"
{
[NonDebuggable]
procedure BuildConnectionString(ApiKey: SecretText): Text
procedure CallLegacyOnPremisesConsumer(ApiKey: SecretText)
var
PlainApiKey: Text;
begin
exit('Server=db.example.com;Key=' + ApiKey.Unwrap());
PlainApiKey := ApiKey.Unwrap();
InvokeLegacyConsumer(PlainApiKey);
end;
[NonDebuggable]
procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText)
var
ResponseText: Text;
JsonObject: JsonObject;
JsonToken: JsonToken;
local procedure InvokeLegacyConsumer(ApiKey: Text)
begin
Response.Content.ReadAs(ResponseText);
JsonObject.ReadFrom(ResponseText);
JsonObject.Get('access_token', JsonToken);
SessionToken := JsonToken.AsValue().AsText();
// The on-premises legacy consumer accepts only Text.
end;
}

View file

@ -1,5 +1,5 @@
---
bc-version: [all]
bc-version: [23..]
domain: security
keywords: [nondebuggable, attribute, secrettext, unwrap, debugger]
technologies: [al]
@ -7,16 +7,16 @@ countries: [w1]
application-area: [all]
---
# Mark procedures that call SecretText.Unwrap() as [NonDebuggable]
# On-premises only: protect unavoidable SecretText.Unwrap calls
## Description
`SecretText` transit — assignment, parameter passing, and return values — is auto-protected: the debugger sees a redacted placeholder, not the value. The protection ends the moment code calls `.Unwrap()`, which converts the `SecretText` back to plain `Text`. From that point on, the local variable holding the result is visible in the debugger like any other `Text`. The `[NonDebuggable]` attribute marks a procedure so that none of its locals or parameters are visible to the debugger during execution, which is exactly what is needed for any procedure that performs an `Unwrap()` or that otherwise materializes a secret as `Text` (for example, while parsing a JSON response to extract an access token).
`SecretText.Unwrap()` is supported only for Business Central on-premises and exists for compatibility. It converts a protected value to plain `Text`, where debugger redaction no longer applies. `[NonDebuggable]` prevents the debugger from inspecting a procedure's parameters and locals, but it does not make the resulting `Text` safe to return, log, or pass through debuggable code.
## Best Practice
Apply `[NonDebuggable]` to any procedure whose body calls `.Unwrap()` on a `SecretText`, and to any procedure that constructs a `SecretText` from a `Text` source (such as a procedure that reads a JSON response body and converts the resulting `Text` into a `SecretText` for the caller). Keep the unwrap window as small as possible — ideally a single one-line helper that hands the unwrapped value straight to the consuming API. See sample: `nondebuggable-required-when-unwrapping-secrettext.good.al`.
In SaaS, keep the value as `SecretText` and use secret-aware APIs instead of unwrapping. For an unavoidable on-premises legacy API that accepts only `Text`, keep the plain-text path as short as possible and mark every procedure in that path `[NonDebuggable]`. Do not return the unwrapped value. See sample: `nondebuggable-required-when-unwrapping-secrettext.good.al`.
## Anti Pattern
Calling `ApiKey.Unwrap()` inside a procedure that is not marked `[NonDebuggable]`. The unwrapped value is now an ordinary `Text` local and the debugger will display it, defeating the purpose of using `SecretText` in the first place. Reviewers should flag any `Unwrap()` call in a procedure that lacks the attribute, and any procedure that parses a credential out of a response (`access_token`, `id_token`, `client_secret`) without the attribute. See sample: `nondebuggable-required-when-unwrapping-secrettext.bad.al`.
Calling `Unwrap()` in cloud-targeted code, or calling it in an on-premises procedure that is debuggable or returns the resulting `Text`. Both defeat the protection that `SecretText` provides. See sample: `nondebuggable-required-when-unwrapping-secrettext.bad.al`.

View file

@ -1,12 +1,17 @@
codeunit 50212 "Sec Sample SecretSubst Bad"
{
procedure BuildAuthHeader(Token: SecretText): Text
procedure BuildAuthHeader(Token: Text): Text
begin
exit(StrSubstNo('Bearer %1', Token.Unwrap()));
exit(StrSubstNo('Bearer %1', Token));
end;
procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): Text
procedure BuildSecretUri(ApiKey: Text): Text
begin
exit(BaseUrl + '?key=' + ApiKey.Unwrap());
exit(StrSubstNo('https://api.example.com/data?key=%1', ApiKey));
end;
procedure BuildBrokenAuthHeader(Token: SecretText): SecretText
begin
exit(SecretStrSubstNo('Bearer', Token));
end;
}

View file

@ -5,8 +5,8 @@ codeunit 50211 "Sec Sample SecretSubst Good"
exit(SecretStrSubstNo('Bearer %1', Token));
end;
procedure BuildSecretUri(BaseUrl: Text; ApiKey: SecretText): SecretText
procedure BuildSecretUri(ApiKey: SecretText): SecretText
begin
exit(SecretStrSubstNo('%1?key=%2', BaseUrl, ApiKey));
exit(SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey));
end;
}

View file

@ -1,5 +1,5 @@
---
bc-version: [all]
bc-version: [23..]
domain: security
keywords: [secretstrsubstno, secrettext, strsubstno, format, compose]
technologies: [al]
@ -11,12 +11,12 @@ application-area: [all]
## Description
`SecretStrSubstNo` is the secret-preserving counterpart of `StrSubstNo`. It accepts a format string and arguments (any of which may be `SecretText`) and returns a `SecretText` — the substitution happens without ever materializing the result as plain `Text`. It is the right tool whenever a secret needs to be embedded in a larger string: an `Authorization: Bearer <token>` header value, a URI that includes an API key as a query parameter, or any other interpolation that combines a `SecretText` with surrounding context.
`SecretStrSubstNo` is the secret-preserving counterpart of `StrSubstNo`. It inserts `SecretText` arguments into `%1`, `%2`, and similar placeholders and returns `SecretText` without materializing the result as plain text. It is the right tool for values such as a `Bearer %1` authorization header or a URI with an API key placeholder.
## Best Practice
Compose every secret-bearing string through `SecretStrSubstNo` and keep the result as `SecretText` end-to-end. Pass the result to the `SecretText` overload of the consumer — `HttpClient.SetSecretRequestUri`, `HttpHeaders.Add`, or `HttpContent.WriteFrom`. See sample: `secretstrsubstno-for-composing-secrets.good.al`.
Compose every secret-bearing string through `SecretStrSubstNo`, ensure the format contains a placeholder for each secret, and keep the result as `SecretText`. Pass it to `HttpRequestMessage.SetSecretRequestUri`, `HttpHeaders.Add`, or `HttpContent.WriteFrom`. See sample: `secretstrsubstno-for-composing-secrets.good.al`.
## Anti Pattern
Calling `StrSubstNo('Bearer %1', Token.Unwrap())` to build the header value, or concatenating `'Bearer ' + Token.Unwrap()`. Both produce a plain `Text` containing the secret, which is then visible in the debugger and in any subsequent log or trace. Reviewers should flag any `Unwrap()` whose result is fed into `StrSubstNo` or used in `+` concatenation — `SecretStrSubstNo` removes the need for either. See sample: `secretstrsubstno-for-composing-secrets.bad.al`.
Keeping a credential in `Text` and inserting it with `StrSubstNo`, or calling `SecretStrSubstNo` with a format that has no placeholder for the secret. The first exposes the value as plain text; the second silently omits it. See sample: `secretstrsubstno-for-composing-secrets.bad.al`.

View file

@ -1,14 +1,11 @@
codeunit 50207 "Sec Sample SecretText Good"
{
procedure CallExternalApi()
procedure CallExternalApi(ApiKey: SecretText)
var
ApiKey: SecretText;
HttpClient: HttpClient;
Response: HttpResponseMessage;
Headers: HttpHeaders;
begin
if IsolatedStorage.Contains('ApiKey', DataScope::Module) then
IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey);
Headers := HttpClient.DefaultRequestHeaders();
Headers.Add('X-Api-Key', ApiKey);
HttpClient.Get('https://api.example.com/data', Response);

View file

@ -1,5 +1,5 @@
---
bc-version: [all]
bc-version: [23..]
domain: security
keywords: [secrettext, credentials, api-key, token, debugger, unwrap]
technologies: [al]
@ -15,7 +15,7 @@ application-area: [all]
## Best Practice
Declare credential-carrying parameters and variables as `SecretText` from the call site that retrieves the secret all the way to the call site that consumes it (typically an `HttpClient` header or URI). Never round-trip through `Text` — every conversion is a potential exposure point. Retrieve secrets from `IsolatedStorage` with the `SecretText` overload of `Get` rather than the `Text` overload. See sample: `secrettext-for-credentials.good.al`.
Declare credential-carrying parameters and variables as `SecretText` from the call site that retrieves the secret all the way to the call site that consumes it (typically an HTTP header or URI). Never round-trip through `Text`. On BC 24 and later, use the `SecretText` overload of `IsolatedStorage.Get` when retrieving stored secrets. See sample: `secrettext-for-credentials.good.al`.
## Anti Pattern

View file

@ -1,23 +1,23 @@
codeunit 50210 "Sec Sample SecretHttp Bad"
{
procedure CallApiWithSecretInUri(ApiKey: SecretText)
procedure CallApiWithSecretInUri(ApiKey: Text)
var
HttpClient: HttpClient;
Response: HttpResponseMessage;
RequestUri: Text;
begin
RequestUri := 'https://api.example.com/data?key=' + ApiKey.Unwrap();
RequestUri := StrSubstNo('https://api.example.com/data?key=%1', ApiKey);
HttpClient.Get(RequestUri, Response);
end;
procedure CallApiWithBearer(BearerToken: SecretText)
procedure CallApiWithBearer(BearerToken: Text)
var
HttpClient: HttpClient;
Response: HttpResponseMessage;
Headers: HttpHeaders;
begin
Headers := HttpClient.DefaultRequestHeaders();
Headers.Add('Authorization', 'Bearer ' + BearerToken.Unwrap());
Headers.Add('Authorization', StrSubstNo('Bearer %1', BearerToken));
HttpClient.Get('https://api.example.com/data', Response);
end;
}

View file

@ -3,26 +3,32 @@ codeunit 50209 "Sec Sample SecretHttp Good"
procedure CallApiWithSecretUri(ApiKey: SecretText)
var
HttpClient: HttpClient;
Request: HttpRequestMessage;
Response: HttpResponseMessage;
SecretUri: SecretText;
begin
SecretUri := SecretStrSubstNo('https://api.example.com/data?key=%1', ApiKey);
HttpClient.SetSecretRequestUri(SecretUri);
HttpClient.Get('', Response);
Request.Method := 'GET';
Request.SetSecretRequestUri(SecretUri);
HttpClient.Send(Request, Response);
end;
procedure CallApiWithBearer(BearerToken: SecretText)
var
HttpClient: HttpClient;
Request: HttpRequestMessage;
Response: HttpResponseMessage;
Headers: HttpHeaders;
AuthHeader: SecretText;
AuthorizationHeaderMissingErr: Label 'Authorization header missing.';
begin
Request.Method := 'GET';
Request.SetRequestUri('https://api.example.com/data');
Request.GetHeaders(Headers);
AuthHeader := SecretStrSubstNo('Bearer %1', BearerToken);
Headers := HttpClient.DefaultRequestHeaders();
Headers.Add('Authorization', AuthHeader);
if not Headers.ContainsSecret('Authorization') then
Error('Authorization header missing');
HttpClient.Get('https://api.example.com/data', Response);
Error(AuthorizationHeaderMissingErr);
HttpClient.Send(Request, Response);
end;
}

View file

@ -1,5 +1,5 @@
---
bc-version: [all]
bc-version: [23..]
domain: security
keywords: [secrettext, httpclient, setsecretrequesturi, containssecret, headers, http]
technologies: [al]
@ -7,16 +7,16 @@ countries: [w1]
application-area: [all]
---
# Use the SecretText-aware HttpClient surface for secrets in requests
# Set secret request URIs on HttpRequestMessage
## Description
`HttpClient` and its companion types expose a parallel surface that accepts `SecretText` instead of `Text`, so that secret URIs, secret headers, and secret request bodies never round-trip through plain text. The key entry points are: `HttpClient.SetSecretRequestUri()` for URIs that contain secrets (the subsequent `Get`/`Post` is then called with an empty string); `HttpHeaders.Add()` overload that accepts a `SecretText` value for authorization headers; `HttpHeaders.ContainsSecret()` to test whether a secret header is present (the plain `Contains()` returns false for secret headers); `HttpContent.WriteFrom()` and `HttpContent.ReadAs()` overloads that accept and produce `SecretText` for request and response bodies that carry credentials.
The secret URI API belongs to `HttpRequestMessage`, not `HttpClient`. `HttpRequestMessage.SetSecretRequestUri(SecretText)` keeps a credential-bearing URI protected, and the prepared request is sent with `HttpClient.Send`. Companion APIs also accept `SecretText`, including `HttpHeaders.Add` for authorization headers and `HttpContent.WriteFrom` for secret request bodies.
## Best Practice
When the URI contains a secret query parameter, compose it as `SecretText` (see `secretstrsubstno-for-composing-secrets.md`), pass it to `SetSecretRequestUri`, and call `Get('', Response)` with an empty string as the URI argument. When the credential is an authorization header, build the header value as `SecretText` and pass it to `Headers.Add`. Use `ContainsSecret` rather than `Contains` to check for the presence of a secret header. See sample: `secrettext-with-httpclient.good.al`.
Compose a secret URI with `SecretStrSubstNo`, call `Request.SetSecretRequestUri(SecretUri)`, set the request method, and send the request with `HttpClient.Send(Request, Response)`. For authorization, get the request headers, add a `SecretText` value, and use `ContainsSecret` when checking for that header. See sample: `secrettext-with-httpclient.good.al`.
## Anti Pattern
Calling `ApiKey.Unwrap()` to build a URI or header string and passing the resulting `Text` to `HttpClient.Get` or `Headers.Add`. The unwrapped secret is now visible in the debugger, in any HTTP trace that captures the request URI, and in any error that includes the URI. Reviewers should flag any `Unwrap()` call whose result flows into an `HttpClient` argument; the `SecretText` overload exists precisely so the unwrap is not needed. See sample: `secrettext-with-httpclient.bad.al`.
Holding a credential in `Text`, interpolating it with `StrSubstNo` or concatenation, and passing that plain text to `HttpClient.Get` or `HttpHeaders.Add`. The secret-aware request and header APIs remove the need to materialize the value as `Text`. See sample: `secrettext-with-httpclient.bad.al`.

View file

@ -2,24 +2,21 @@ tableextension 50223 "Sec Sample VTR Good" extends Customer
{
fields
{
field(50223; "System Batch ID"; Code[20])
{
TableRelation = "Sales Header"."No.";
ValidateTableRelation = false;
Editable = false;
}
field(50224; "External Customer Ref"; Code[50])
field(50223; "External Customer Ref"; Code[50])
{
TableRelation = Customer."No.";
ValidateTableRelation = false;
TestTableRelation = false;
trigger OnValidate()
var
Customer: Record Customer;
InvalidExternalReferenceErr: Label 'The external customer reference must not contain spaces.';
begin
if "External Customer Ref" = '' then
exit;
if not Customer.Get("External Customer Ref") then
Error('External customer reference %1 does not exist.', "External Customer Ref");
"External Customer Ref" := CopyStr(
UpperCase(DelChr("External Customer Ref", '<>', ' ')),
1, MaxStrLen("External Customer Ref"));
if StrPos("External Customer Ref", ' ') > 0 then
Error(InvalidExternalReferenceErr);
end;
}
}

View file

@ -7,16 +7,16 @@ countries: [w1]
application-area: [all]
---
# Do not set ValidateTableRelation = false on user-editable fields
# Handle free-form input when ValidateTableRelation is false
## Description
`TableRelation` on a field declares that the field's value must exist in another table; the platform validates the value on entry and on `Validate`. Setting `ValidateTableRelation = false` keeps the relation as metadata (used by lookups, by Edit-in-Excel, by APIs) but turns off the runtime check. On a system-controlled, non-editable field that is populated only by the platform or by a posting routine, that is acceptable. On a user-editable field, it is dangerous: users can type any value, and downstream code that assumes the relation holds will read a `Customer` record that does not exist, post to an account that was deleted, or join against missing rows.
`ValidateTableRelation = false` intentionally lets a user keep free-form input even when it does not match `TableRelation`. This is supported for scenarios such as accepting a new vendor name and handling it in `OnValidate`. The risk is not the property itself; it is leaving downstream code to assume that every value identifies an existing related record.
## Best Practice
Leave `ValidateTableRelation` at its default (true) on any field a user can edit. If there is a legitimate reason to turn it off — typically because the relation is not on the primary key, or because the relation is computed — replace it with an `OnValidate` trigger that performs the equivalent check (`if FieldValue <> '' then VerifyExternalReferenceExists(FieldValue)`). Combine `ValidateTableRelation = false` with `Editable = false` for system-controlled fields, so the metadata is correct and the field is unreachable from the UI. See sample: `validatetablerelation-false-on-user-input.good.al`.
Keep the default validation when values must exist in the related table. When free-form values are intentional, set both `ValidateTableRelation = false` and `TestTableRelation = false`, then add compensating `OnValidate` logic that normalizes, validates, creates, or otherwise handles unmatched input. Document that downstream code must not assume the relation exists. See sample: `validatetablerelation-false-on-user-input.good.al`.
## Anti Pattern
`ValidateTableRelation = false` on a user-facing input field (a `Customer No.` typed by a sales user) with no alternative validation. Reviewers should flag the combination of `ValidateTableRelation = false` and any of: `Editable = true` (the default), an `OnValidate` trigger that does not perform the relation check, or a page that surfaces the field as input. See sample: `validatetablerelation-false-on-user-input.bad.al`.
`ValidateTableRelation = false` on a user-facing field with no intentional handling for unmatched values, or leaving `TestTableRelation = true` so database relation tests reject values the UI deliberately accepts. See sample: `validatetablerelation-false-on-user-input.bad.al`.