Summary#
A secret your code reads can travel freely through your code — locals, helpers, collections, what a helper returns — but it may end in only a few places, and the compiler refuses every other one ([[function-secret-read#confinement]]). Most APIs take a credential in a header, which is always allowed. Some take it in the query string or in a form or JSON body field; those slots are allowed only when the code that sends the secret says so on its egress line:
egress "api2.postnord.com" { secretIn query "apikey"; }That line means exactly one thing: a secret may be sent to this host in this slot. The platform redacts that slot in
every log and trace of those calls, and osy explain lists it.
Signature#
egress "<host>" {
secretIn query "<parameter>"; // a URL query parameter
secretIn body "<field>"; // a form field (a=b&c=d) or a JSON body field
}The host is written as an egress host is anywhere else — api.example.com, or *.example.com for every subdomain.
The slot kinds are query and body; a request header needs no line, because a header value is already a place a
secret may go.
Description#
Where a secret may end#
| sink | declared by |
|---|---|
a request header value — Authorization, X-Api-Key, … | the platform (always allowed) |
the key of a signing call — Crypto.HmacSha256Hex(key, …), Crypto.SignRs256(key, …) | the platform |
the mTLS client certificate — a clientCert grant | the platform |
| a URL query parameter of one host | a secretIn query line |
| a request body field of one host | a secretIn body line |
A signature, an HMAC or a digest is not the secret: what comes out of a signing call is an ordinary value.
In a package#
A package's code often sends a secret the APP fills in — PostNord's client holds an ApiKey field the app sets from
its own Secret.X. The package declares where that goes, on the host line in its package.osy:
package Osysharp.Carriers.PostNord {
version "0.1.0";
use Osysharp.Http;
egress "api2.postnord.com" { secretIn query "apikey"; }
}The app's grant of that host — use Osysharp.Carriers.PostNord@0 { egress "api2.postnord.com"; } — is its acceptance.
A grant left out is a compile error that says a secret travels there in the URL, so nobody accepts it without being
told. A package's line covers that package's code only: the app's own code, and another package, cannot use it.
For your app's own code#
Your own code declares the same line in the app { } manifest. An egress line in app.osy declares ONLY this —
your app's outbound rules still live in .egress (Where your app may connect — the .egress file) — so an egress "h"; there with no body is
refused, by name.
For your own code the compiler must also be able to READ the host: write it at the start of the URL
("https://api.example.com/v1?api_key=" + key), or build it from a class field that only literal URLs are ever written
into — a generated client's BaseUrl = "https://api.example.com" — and then every host that field can hold must be
covered. A URL built from a host nothing pins (baseUrl + "?api_key=" + key, with baseUrl a parameter) is refused in
app code, because nothing else bounds where it goes; in a package, the hosts the app granted it do. osy import-api
prints the exact line to add when the API it imports takes its key in the query string.
Checked again as it is sent#
The compiler follows a secret through your code; it cannot follow one through DATA. An API that echoes the key back in
its response, or a value put back together at run time, reaches the next call looking like any other text. So every
outbound call is checked again, as it actually is: once a run has read a secret or a token, any value it read that
appears in a call's URL or body must sit in a slot declared for that host — a package's own secretIn line for
package code, your app.osy line for yours — or the call is refused before it is sent. The refusal names the secret,
the host and the slot, never the value. A secret in a URL path, or in a body whose fields cannot be told, has no line
that could cover it.
egress "api.example.com" { secretIn query "api_key"; }
var echoed = Http.Get("https://api.example.com/v1/whoami?api_key=" + Secret.ApiKey); // declared: sent
Http.Get("https://collector.example.org/v1/log?k=" + echoed.Body);
// ✗ at run time, when the body IS the key: "This app's own code tried to send `Secret.ApiKey` to
// `collector.example.org` in the query parameter `k` … and the call was not sent."A run that has read no secret is not checked at all, so code that never touches one pays nothing for it.
Which slot is which#
The compiler reads the slot off how the value is built:
- a URL: the text right before the secret ends in
?name=or&name=; - a form body: the text before it ends in
name=(at the start, or after&); - a JSON body: an anonymous object's member (
new { client_secret = key }), a dictionary key, or the text before it ending in"name":.
A secret whose slot cannot be read that way is refused, and the message says why.
Redaction#
Every value the platform hands out — a Secret.X, an OAuth token — and every credential header value a call sends is
remembered for your app, with its URL-escaped and base64 spellings, and scrubbed from every trace before it is kept:
the steps and locals osy inspect shows, a fault's frames and message, the values that crossed between browser and
server. An outbound call's log line and a test's egress journal already drop the whole query string.
Examples#
An API that takes its key in the query string, called from the app's own code:
app Weather {
model "model/**/*.osy";
use Osysharp.Http;
egress "api.weather.example" { secretIn query "appid"; }
}
app.Secrets = [ new Secret("WeatherKey") ];
string Forecast(string city) {
var r = Http.Get("https://api.weather.example/v2/forecast?q=" + Uri.EscapeDataString(city)
+ "&appid=" + Uri.EscapeDataString(Secret.WeatherKey));
return r.IsSuccess ? r.Body : "";
}The ordinary shape needs no declaration at all — a helper that builds the header map is legal to write, and whatever calls it may hand the map to the call:
app Ledger {
model "model/**/*.osy";
use Osysharp.Http;
}
app.Secrets = [ new Secret("LedgerKey") ];
Dictionary<string, string> Auth() {
var h = new Dictionary<string, string>();
h.Add("Authorization", "Bearer " + Secret.LedgerKey);
return h;
}
int Balance(string account) {
return Http.Get("https://api.ledger.example/v1/accounts/" + account, Auth()).StatusCode;
}Without the line, the same query-string call is refused:
// ✗ it would travel in this request's URL as the query parameter `appid` to `api.weather.example`, and nothing
// declares `appid` a credential slot
Http.Get("https://api.weather.example/v2/forecast?appid=" + Secret.WeatherKey);See also#
- reading a secret's value (Secret.Name) — reading a secret, and everything it may not do
- package.osy — the package manifest this line lives in, and how an app grants a package its hosts
- Where your app may connect — the .egress file — the app's own outbound allow-list and deny-list
- Explaining your app's security —
osy explainlists every place each secret leaves, including these