title: Secure Access on GetEndpoint and Signed Endpoint authors:
- "@Pangjiping" creation-date: 2026-04-19 last-updated: 2026-04-25 status: implemented
OSEP-0011: Secure Access on GetEndpoint and Signed Endpoint
Summary
Optional secure_access on sandbox create. There are two complementary mechanisms:
Static header authorization (from
GetEndpoint) — whensecure_accessis enabled,GetEndpointreturns a stable opaqueSecureAccessToken. Clients attach it to all subsequent requests asOpenSandbox-Secure-Access: <token>
Ingress evaluates this header before route-signature verification, with fail-fast semantics when the header field is present but wrong (see § Ingress verification).Route
signature(short route token) — a 9-character value embedded in host / header / path:hex8(8 lowercase hex) +signed_key_id(exactly 1 char[0-9a-z]).
Every signed route also carries anexpiresvalue: Linux / Unix epoch seconds (POSIX: whole seconds since1970-01-01 00:00:00UTC, same astime(2); not milliseconds) asuint64, encoded for routing and signing asexpires_b36: base-36 using lowercase digits0-9and lettersa-z, no leading zeros (exceptexpires_sec == 0is0). Equivalently (Go):strconv.FormatUint(expires_sec, 36)/strconv.ParseUint(s, 36, 64). It appears incanonical_bytesand as its own--delimited segment:{sandbox_id}-{port}-{expires_b36}-{signature}.
Minting uses the sameGetEndpointpath with anexpiresquery (see API) — the value is Linux / Unix epoch seconds. Ingress enforcesnow ≤ expires_secondsafter decoding.
There is no signing of app path or query, and no DNS parent domain in the signed material. The wildcard parent domain is routing-only.
Static access token (GetEndpoint)
When the sandbox has secure_access enabled, GetEndpoint(sandboxId) (or equivalent lifecycle response) includes SecureAccessToken.
Client rule: for every follow-up request through the gateway:
OpenSandbox-Secure-Access: <token>Ingress rule (secure sandbox): define header present as: the OpenSandbox-Secure-Access field appears on the HTTP request (any value, including empty). Then:
- If present and the value matches
SecureAccessToken(constant-time compare) → allow; route-signature verification is not required. - If present and the value does not match →
401immediately; ingress must not fall through to route-signature verification (prevents “bad/stale header + valid signed URL” from being accepted). - If absent → ingress may authenticate using the route
signaturepath (when provided and valid).
expires_b36 encoding
Let expires_sec be uint64 Linux / Unix epoch seconds (UTC): whole seconds since the Unix epoch, not milliseconds.
expires_b36 is the base-36 encoding of expires_sec using lowercase alphabet 0-9a-z, with no leading zeros, except expires_sec == 0 is encoded as 0. Normative reference (Go): strconv.FormatUint(expires_sec, 36) for minting and strconv.ParseUint(segment, 36, 64) for ingress.
- Length: 1 to 13 characters inclusive for any
uint64value (max is18446744073709551615→3w5e11264sgsf). - Charset:
[0-9a-z]only; reject uppercase. - Routing segment and
canonical_bytesembed the same literal string (not decimal seconds). - Ingress: reject empty, invalid charset, overflow on parse, or length > 13 →
400. Then401ifnow > expires_sec.
Rationale: Base36 is shorter than decimal for typical timestamps (e.g.
2000000000→x2qxvk, 6 chars) while staying URL/host friendly without extra escaping.
Signing algorithm (signed routes always include expires_b36)
Inputs and constraints
sandbox_id: verbatim in canonical (may contain-).port: decimal1..65535, no leading zeros.expires_b36: required for any minted signed route; rules above.secret_bytes: raw decoded secret forsigned_key_id(see config:key_idis 1 char[0-9a-z]).
canonical_bytes (UTF-8)
Always (note: {expires_b36} is base36, not decimal):
v1\nshort\n{sandbox_id}\n{port}\n{expires_b36}\ninner and signature
BE32(x) = 4-byte big-endian uint32.
inner = BE32(len(secret_bytes)) || secret_bytes || BE32(len(canonical_bytes)) || canonical_bytes
digest = SHA256(inner)
hex_all = lowercase_hex(digest)
hex8 = hex_all[0:8]
signature = hex8 + signed_key_id // 9 chars totalRouting token (always four logical segments for signed routes)
{sandbox-id}-{port}-{expires_b36}-{signature}Right-to-left parse:
- Last:
signature([0-9a-f]{8}[0-9a-z]{1}— exactly 9 characters). - Second-to-last:
expires_b36([0-9a-z]{1,13}, decode with base 36 touint64). - Third-to-last:
port(decimal, rules above). - Remaining (joined with
-):sandbox_id.
Unsigned legacy (no route signature): {sandbox_id}-{port} — two segments only.
API
- CreateSandbox:
secure_access.enabled(defaultfalse). GetEndpoint—GET /sandboxes/{sandboxId}/endpoints/{port}- Without query
expires: returns the public URL; when secure access is on, the response also carriesSecureAccessToken(and clients useOPENSANDBOX-SECURE-ACCESS) as in § Static access token. - With query
expires=<unix_seconds>: mints a signed route.expiresis a decimaluint64Linux / Unix epoch second (whole seconds since1970-01-01 00:00:00UTC; not milliseconds). The server normalizes toexpires_b36(rules above) for bothcanonical_bytesand the returned routing token in the JSON.- Omitting
expiresdoes not invoke minting (unsigned / legacy response); it is not a400by itself. - If
expiresis present but empty, malformed, or out of range →400.
- Without query
Returned signed routing material always uses {sandbox_id}-{port}-{expires_b36}-{signature} (then wrapped into host / path / header as usual).
Gateway routing
Host / header token (split on - from the right)
- Signed:
{sandbox_id}-{port}-{expires_b36}-{signature}. - Unsigned legacy:
{sandbox_id}-{port}.
| Mode | Where | Example (illustrative) |
|---|---|---|
| Wildcard | Host: {sandbox_id}-{port}-{expires_b36}-{signature}.<parent> | my-sandbox-8080-x2qxvk-aabbccddk.sandbox.example.com — expires_b36 = x2qxvk (2000000000 sec, Go FormatUint(..., 36)); signature = aabbccddk; parent = sandbox.example.com. |
| Header | Value: same --joined token | my-sandbox-8080-x2qxvk-aabbccddk |
| URI | Prefix: /{sandbox_id}/{port}/{expires_b36}/{signature}/ + upstream remainder | /my-sandbox/8080/x2qxvk/aabbccddk/v1/status — upstream after strip: /v1/status. |
URI parsing
Secure sandboxes (secure access required):
- If segments 2–4 are syntactically valid
port,expires_b36,signature, treat the path as signed OSEP: strip/{sandbox_id}/{port}/{expires_b36}/{signature}and forward the remainder + query unchanged.
Unsecured sandboxes (secure access not required) — legacy safeguard:
- Even when segments 2–4 happen to match the
expires_b36/signaturecharset and length rules, ingress must not treat them as a signed routing prefix for forwarding purposes. - Instead, re-parse the full path using legacy URI rules (first segment =
sandbox_id, second =port, everything after is the upstream path, including any segments that looked likeexpires_b36/signature). This preserves existing unsigned apps whose paths could collide with the signed shape and avoids silently rewriting upstream paths.
How to decide: after resolving sandbox_id from the first path segment, consult GetEndpoint / secure-access policy. Apply the signed OSEP strip only when the sandbox requires secure access; otherwise apply legacy parsing for URI mode.
Legacy unsigned (always): /{sandbox_id}/{port}/… when the path is not using the signed prefix or when legacy re-parse is mandated above.
Strip the signed prefix only on the secure path; forward path + query unchanged relative to the chosen interpretation.
Ingress verification
- Parse routing input (mode-dependent). For URI mode, a path may syntactically match
/{sandbox_id}/{port}/{expires_b36}/{signature}/…; still resolvesandbox_id(at minimum the first segment) for lookup. GetEndpoint(sandbox_id)once: secure-access flag,SecureAccessToken, and backend endpoint.- URI mode + secure access not required: re-parse the full path using legacy URI rules for
sandbox_id,port, and upstreamrequestURI(§ URI parsing / unsecured safeguard). Do not stripexpires_b36/signature-shaped segments from the forwarded path. - Secure access required (final signed interpretation for URI / host / header):
- Header branch: if
OpenSandbox-Secure-Accessis present (see § Static access token): match → allow; mismatch →401(no route-signature fallback). - Signature branch: if the header is absent and a signed route token is present: decode
expires_secfromexpires_b36, requirenow ≤ expires_sec, rebuildcanonical_byteswith the sameexpires_b36, verifysignature→401on failure; if no signed credential →401.
- Header branch: if
- Secure access not required (URI after step 3 legacy re-parse, or unsigned host/header shapes): allow without route-signature verification.
Config
Server (~/.sandbox.toml):
[ingress.secure_access]
enabled = true
active_key = "a" # 1 char [0-9a-z], must exist in keys
[[ingress.secure_access.keys]]
key_id = "a"
secret = "base64:..."
[[ingress.secure_access.keys]]
key_id = "b"
secret = "base64:..."Ingress: --secure-access-keys uses the same 1-character key_id per segment, e.g. a=base64:...,b=base64:....
opensandbox-ingress --secure-access-enabled \
--secure-access-keys "a=base64:...,b=base64:..."Errors
400:expiresquery present but invalid (empty, bad decimal, etc.), malformed token, invalidexpires_b36after normalization (empty / bad charset / length > 13 / parse overflow), badport/signature. Not “expiresomitted” on a normalGetEndpoint(that case returns the unsigned response).401: header mismatch,now > expires_sec, badhex8, unknown key, missing credential when required.
Tests
- Unit:
inner/hex8, four-part right split with-insandbox_id,expires_b36canonicalization (no leading zeros,0case, round-tripParseUint(..., 36, 64)). - Integration: three modes; invalid
expires_b36/ badexpiresquery →400; past expiry →401;expiresomitted (unsigned path) does not require400;expirespresent and invalid →400.
此页内容来自仓库源文件:
oseps/0011-secure-access-endpoint.md