Osy#betaRun it on Osyrin: osyrin.com

Reference / Project

where a secret may be sent — secretIn

egress "<host>" { secretIn query "<name>"; secretIn body "<name>"; }

A secret may end only at a sink: a request header, the key of a signing call, the mTLS certificate — or a URL query parameter or request-body field that the code sending it declares on its egress line, with `egress "host" { secretIn query "api_key"; }`. In a package that line lives in package.osy and the app's grant of the host accepts it; for the app's own code it lives in app.osy. Every such value is redacted in logs and traces.

preview2 examples compiled by CIprojectsecuritysecretsegress

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#

sinkdeclared 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 grantthe platform
a URL query parameter of one hosta secretIn query line
a request body field of one hosta 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#

Related

reading a secret's value (Secret.Name)

`Secret.Name` in a function body evaluates to the declared secret's value — the key itself, as a string, read at the…

package.osy

The manifest that makes a git repository a publishable Osy# package. It names the package, says which of the…

Where your app may connect — the .egress file

An application's own outbound allow-list and deny-list, kept in the project's .egress file. The osy egress verbs edit…

Explaining your app's security

Explains your app's declared authorization in plain English — who can read, create, update and delete each entity…

declaring secrets (app.Secrets)

`app.Secrets` declares the named secrets your app uses — API keys, tokens, client secrets. Each is `new…