TinyCDI runs other people's desktops. A defect in the API, gateway, broker or operator can expose one user's workspace, credentials or network position to another. This file sets out how to report a vulnerability and the hardening rules that contributors and operators must follow.
TinyCDI v0.2 has not had an independent security review; one is planned before v1.0. Until then, the mandatory rules and the blocking CI scanners in this file are the bar.
v0.2 architecture note: v0.2 merges the v0.1
api,portalandgatewayDeployments into onebackendbinary plus a staticfrontendserver (ADR 0005). The broker moves in-process intobackend; the internal mTLS listener keeps only operator routes; and each workspace is served on its own host<label>.<sessionDomain>behind one wildcard edge rule instead of a single shared session origin. Where a rule below still namesapi,gatewayorportal, it binds the same code in its new home. Items marked (v0.2 design) describe the ADR 0005 target where the code is still in flight; everything else is enforced today.
| Version | Supported |
|---|---|
main (unreleased) |
Yes. Fixes land here first. |
Latest 0.x minor release (images + oci://ghcr.io/tinyorbitvn/charts/tinycdi) |
Yes. Security fixes ship as a new patch release. |
Older 0.x minors |
No. Upgrade to the latest minor. |
While the project is pre-1.0, only the most recent minor release line gets
security fixes. Deploy images by digest (images.*.digest), not by tag:
a fix ships as a new release with new digests.
Do not open a public issue, discussion or pull request for a security problem.
Report it privately through GitHub's private vulnerability reporting: https://github.com/tinyorbitvn/tinycdi/security/advisories/new (repository Security tab → Report a vulnerability). This opens a draft GitHub Security Advisory that only you and the maintainers can see.
Please include:
- the affected component (
backend/api/gateway,frontend/portal,broker,operator, runtime image, Helm chart, CI) and the version, commit or image digest; - the trust boundary you crossed (see the threat model below) and the attacker position you assumed: unauthenticated, an authenticated user, a tenant controlling their own workspace, or a cluster-namespace user;
- reproduction steps or a proof of concept. Keep it minimal, use your own test deployment, and never use other people's data.
Never include real credentials, session cookies, launch tickets or customer data in a report. Redact them.
| Step | Target |
|---|---|
| Acknowledge the report | 3 business days |
| Triage: severity (CVSS v3.1) and affected versions | 7 calendar days |
| Fix or mitigation for Critical/High | 30 days |
| Fix or mitigation for Medium/Low | 90 days |
| Publish the GitHub Security Advisory (CVE requested through GitHub) | When the fix ships, or at 90 days, whichever comes first, unless both sides agree to an extension |
We credit reporters in the advisory unless they ask us not to. Good-faith research that follows this policy is welcome: stay within your own deployment and accounts, do not degrade service for others, and do not access, modify or keep other users' data.
Browsers authenticate to the portal/API with OIDC (server-side
session, __Host- cookie, derived synchronizer CSRF token, Origin
allowlist). The API mints a one-use, hash-stored, 60 s launch ticket
and returns a launchUrl on the workspace's own host
<label>.<sessionDomain>; the portal submits it as a form POST inside a
sandboxed iframe — same-site under the default lax cookie mode,
cross-site only under partitioned (v0.2 design). The session
listener on the backend redeems the ticket against the broker —
in-process since the merge; the internal mTLS listener keeps only
operator routes (ADR 0003, ADR 0005). That yields a fenced lease,
renewed every 10 s, and the session fails closed within 30 s if renewal
stops. The session listener binds every request to the lease's workspace
by host label, then reverse-proxies HTTP/WebSocket traffic to the
workspace runtime pod, injecting per-workspace credentials and
pinning the per-workspace TLS certificate. The operator creates the
runtime pods, Secrets, Services and NetworkPolicies in managed namespaces.
Treat every runtime pod as tenant-controlled: the user has a shell
inside it, can read its credentials and can replace the server it runs.
Containers share the host kernel, so namespaces are not a boundary between
mutually hostile tenants (see docs/architecture.md §6).
Background: architecture · ADR 0003 internal broker API · ADR 0004 launch origin policy · ADR 0005 v0.2 components and session hosts · vulnerability policy (proposal) · release provenance · node profiles.
The rules below are mandatory. Each one names where it is enforced today. Not yet enforced means the rule still applies but nothing automated checks it; reviewers must check it by hand, and adding the missing guard is welcome work. Changing a rule needs a security review and an update to this file.
Authentication and authorization
- Every
/v1route exceptGET /v1/loginandGET /v1/auth/callbackis wrapped inRequireAuth, and every state-changing route is also wrapped inRequireCSRF. Enforced:TestUnauthenticatedRequestRejected,TestCreateConnection_RequiresAuthAndCSRF,TestData_CSRFRequired,TestData_Unauthenticated. - Owner and tenant come only from the verified
Principal, never from the body, query or headers. Another user's or tenant's resource returns404, so the response never reveals that it exists. Enforced:TestOwnerDerivedFromPrincipalNotBody,TestCreateWorkspaceOwnerFromPrincipal,TestWorkspaceOwnership,TestUnknownTenantForbidden,TestSEC21_IdempotencyPrincipalScoped(idempotent replays too). - A request field that references an existing resource (for example
retainedDataRef,templateRef) is resolved through the same owner-scoped, state-checked, transactional path as that resource's own endpoint. Appliers re-verify the claim before they mutate anything in Kubernetes. Enforced:TestCreateRetainedDataRef_RoutesThroughAttach,TestCreateRetainedDataRef_ErrorMapping,TestSEC01_CreateRejectsBareRef,TestSEC01_UnclaimedRecordNeverMutated(integration). - OIDC uses the authorization-code flow with PKCE S256.
stateis single-use and bound to the initiating browser (a short-lived__Host-cookie). The nonce is verified, and go-oidc checks issuer and audience. ID and access tokens are never stored or logged. Enforced:TestCallbackRejects*(issuer, audience, expiry, nonce, unknown/replayed state),TestCallbackRequiresLoginCookie,TestCallbackRejectsWrongLoginCookie,TestLoginCookieAttributes,TestLoginSendsPKCES256. An optional login gate (-required-groups, chartoidc.requiredGroups) admits only principals whose verifiedgroupsclaim contains a listed group verbatim: no path matching, and a missing or malformed claim is denied with a 403 and no session. Enforced:TestRequiredGroups*,TestNewAuthenticatorRejectsEmptyRequiredGroup,TestGroupListValidation,TestHardeningOIDCRequiredGroups. - Sessions use opaque IDs of at least 256 bits, rotated at login.
Cookies are
__Host-,Secure,HttpOnlyand have noDomain. Idle and absolute expiry are enforced server-side and bound to the session epoch. The database stores only a hash of the session ID and only a MAC of the CSRF token; the token itself is derived —HMAC-SHA256(key = raw session ID, "tcdi-csrf-v2")— not random and never stored (v0.2 design). Enforced:TestSessionFixationPrevented,TestSessionIdleAndAbsoluteExpiry,TestOIDCLoginFlowSucceeds,TestSessionKeyIsDigest,TestCSRFTokenMACIsKeyedBySessionID,TestPGSessionStore(integration). - Unauthenticated endpoints use bounded memory and no global O(n) work
per request. Anything stored before authentication has a cap and a
TTL.
Enforced:
TestPendingLoginsAreBounded,TestPendingSweepDropsExpired. A flood can still evict in-flight logins once the cap is reached, so rate-limit/v1/loginat the edge (§B.2).
CSRF and Origin
-
Nothing changes state on
GET,HEADorOPTIONS. A cookie- authenticated mutation needs the synchronizer token — in v0.2 derived, not stored: recomputed asHMAC-SHA256(key = raw session ID, "tcdi-csrf-v2")and compared in constant time — and must passRequireTrustedOrigin. The JS-readabletcdi_csrfandtcdi_session_origincookies are gone; the SPA readscsrfTokenandsessionDomainfromGET /v1/meand keeps them in memory, so every portal cookie carries__Host-(v0.2 design). Enforced:TestCSRF*,TestOriginAllowlist,TestOriginMultipleHeadersRejected,TestCSRFChainForgedOriginRejected. -
On each workspace session host, only the launch POST accepts the portal-origin allowlist (ADR 0004). WebSocket upgrades and every other route require
Originto equal that workspace host's own origin. A rejected launch never consumes the ticket. The launch form posts into a sandboxed iframe on<label>.<sessionDomain>; how far the POST travels depends on the cookie mode (v0.2 design):lax(default): portal host and session domain share one registrable domain, so the POST is same-site and the__Host-SameSite=Laxsession cookie is sent on the iframe navigation;partitioned: cross-site deployments useSameSite=None; Secure; Partitioned, which keys the cookie to the portal top-level site.
In both modes:
- the portal CSP
form-actionallows'self'andhttps://*.<sessionDomain>; - the portal
Referrer-Policymust keep theOriginheader (usestrict-origin). Withno-referrerorsame-origin, browsers sendOrigin: nulland every launch is rejected; - the session cookie stays
__Host-,Secure,HttpOnly,Path=/, host-only on the workspace's own host; - session responses pin
frame-ancestors <portal-origin>(or'none'when no portal origin is configured),Cross-Origin-Resource-Policy: same-originandOrigin-Agent-Cluster: ?1; the iframesandboxnever carriesallow-top-navigation*,allow-popups*orallow-modals, and nopostMessagechannel exists between portal and frame (v0.2 design).
Enforced:
TestLaunch_*(incl.TestLaunch_SetsHostOnlyCookie),TestUpgrade_OriginEnforced,TestUpgrade_PortalOriginRejected,TestPortalCSP,TestSecurityHeaders(Referrer-Policy: strict-origin),TestPortalSessionOrigin, andweb/tests-portal/portal-csp.spec.ts(the real portal binary against a session mock that applies the session listener's Origin gate, with ano-referrernegative control). The per-workspace host-binding, cookie-mode and iframe-isolation e2e checks are (v0.2 design) and land with the session-host work.
Tickets, leases and the internal broker
-
Launch tickets:
- come from
crypto/rand, at least 256 bits; - are persisted only as a SHA-256 hash;
- have a TTL of 60 s or less;
- are consumed on the first redemption attempt;
- are bound to (workspace, tenant, owner, runtimeGeneration, runtimeUID, audience);
- never appear in URLs, logs, metrics or audit records.
Enforced:
TestIssueTicket_*,TestRedeemTicket_*,TestLaunch_TicketInQueryRejected,TestCreateConnection_NoTicketInLogs,TestCreateConnection_IssuesTicket(Cache-Control: no-store). The SPA posts a ticket only to the per-workspacelaunchUrlhost the API returns, validated against the configured session domain (TestNormalizeSessionOrigin,web/tests/unit/connect.test.tsx; per-workspace hosts (v0.2 design)). - come from
-
Leases:
- at most one is active per workspace (DB unique index);
- each is fenced by generation + runtimeUID + fencingVersion and bound to one gateway identity — in v0.2, one identity shared by every backend replica (v0.2 design);
- renewal happens at least every 10 s, and the session fails closed within 30 s;
- the broker refuses issue and renew when its observed state is older than 15 s.
Enforced:
TestRenewLease_*,TestLease_Stale*,TestTakeover_FencesOldLeaseFirst,TestSingleActiveLeaseAcrossGateways,TestRevoke_ClosesOpenStream,TestRenewLease_StaleObservationFailsClosed. -
The broker API is served only on the internal mTLS listener (TLS 1.3, client certificate required) and never on a public mux. Operator and gateway identities are disjoint. (v0.2 design, ADR 0005: the broker moves in-process into
backendand the mTLS listener keeps only operator routes; the remote broker API survives unchanged in a split/test mode.) Enforced:TestNoClientCert_401,TestWrongClientCA_Rejected,TestSPIFFESAN_*,TestActivity_OperatorIdentityForbidden,TestOperatorEndpoints_GatewayForbidden.
Session proxy (the v0.1 gateway; the backend's session listener in v0.2)
-
The upstream target and its credentials come only from the broker. The session listener strips the client's
AuthorizationandCookieheaders. Upstream TLS is verified against the pinned per-workspace certificate.InsecureSkipVerifynever appears in non-test code. Enforced:TestProxy_ArbitraryTargetRejected,TestProxy_StripsClientAuth,TestUpstreamTLS_RequiresPinnedCA.InsecureSkipVerifyban: not yet enforced by a linter. -
Every runtime response is tenant-controlled:
- the path allowlist is evaluated on the normalized path (no dot-segments or encoded separators);
- upstream
Set-Cookie,Service-Worker-Allowedand similar origin-wide headers are dropped; - the session listener adds its own CSP,
nosniffand HSTS.
Enforced:
TestProxy_ManagementPathsDenied,TestProxy_DotSegmentsRejected,TestProxy_ForwardsCleanedPath,TestProxy_HostileUpstreamHeadersDropped,TestProxy_ServiceWorkerScriptFetchRejected,TestProxy_SecurityHeadersEverywhere. (v0.2 design: each workspace serves its runtime HTML/JS on its own host under*.<sessionDomain>, so tenant-controlled script from one workspace no longer shares an origin with another workspace's session. ADR 0005 rejected serving pinned client assets from the backend — a pinned client could skew against the runtime's own KasmVNC version — so the earlier "pinned client assets" follow-up is obsolete.)
Input validation and data
- Every JSON body is read through
http.MaxBytesReaderwithDisallowUnknownFields. IDs must match anchored patterns. Errors use the stable error model and expose no SQL or Kubernetes internals. Enforced:TestData_BadIDs,TestErrorCodesMatchOpenAPI,TestCreateWorkspaceErrorMapping,TestCreateDecoderErrorNotEchoed,TestDataDecoderErrorNotEchoed,TestSignalBodyValidated,TestListWorkspaces_BadPageToken. - SQL goes only through pgx placeholders. Request input is never
interpolated; the only formatted values are integer
LIMITs. Not yet enforced (code review only). - End users never supply images, commands, PodSpec fragments, hostPath
or privileges. Templates are admin-only, their images are
digest-pinned and their specs are CEL-immutable. The operator
re-validates whatever it builds a Pod from.
Enforced: CRD CEL +
api/v1alpha1/validation_test.go(incl.adapterenum,sessionCmd-only-with-kasm,commandforbidden with kasm, digest-pinned image for kasm templates too),TestImageDigestPinning,TestForgedSnapshot*(internal/operator/snapshot_verify_test.go: hash, digest-only image, CEL invariants, live-template match). A snapshot written by a Workspace writer that names a template which no longer exists is still trusted on hash + invariants. Operator-authenticated snapshots (status or signature): not yet enforced. - Idempotency keys are scoped to the principal and the operation, and
a replay never bypasses authorization.
Enforced:
TestSEC21_IdempotencyPrincipalScoped(integration). Keys expire after 24 h (hourly prune incmd/backend).
Logging and secrets
- Never log, or emit to metrics or audit, any of: tickets, session IDs,
cookies,
Authorization, OIDC code/state/nonce/verifier, runtime credentials, raw query strings or request bodies. Audit actors are pseudonymous (observability.ActorRef). Enforced:TestAuditLogNeverContainsSecrets,TestNoSecretsInLogs,TestAuditRedactsSecretLikeFields,TestWorkspaceHandlerNoSecretsInLogs,TestLogsNeverContainRawSubject,TestNoLeaseIDsInLogs. - Secrets reach a process only as a mounted file or as
secretKeyRefenv. They never go in command-line flags,values.yaml, CR spec/status, ConfigMaps, images or git. Enforced:TestNoPlaintextSecrets,TestSecretsReferencedByName..gitignoreand.dockerignoreexclude.env*,secret/, keys and kubeconfigs. Not yet enforced: secret scanning (a CI job or GitHub push protection),**/patterns in.dockerignore, and rejecting literal secret values inextraEnv.
Crypto and TLS
- Every token and key comes from
crypto/rand. Public edges use TLS 1.2 or later; the internal listener uses TLS 1.3. Internal mTLS uses a dedicated CA. No workload ever mounts a CA private key: CA volumes projectca.crtonly, viaitems:. The api refuses to start unless PostgreSQL TLS verifies the server (verify-ca/verify-full, from the DSN orPGSSLMODE), unless a dev-only flag is set. Enforced: TLS minimums in code;TestCAVolumesProjectOnlyCACrt(every CA volume, including the DB CA),TestDatabaseTLSWiring,TestHardeningDatabaseTLSGate,TestCheckDatabaseTLS,TestCheckDatabaseTLSMalformedDSN,TestPGConnHonoursSSEnv. Not yet enforced:- a CA lifetime longer than its leaves;
- certificate hot-reload (v0.2 design: every backend listener reloads its certificate when the files change; until then pods load certificates once at startup, so restart them after every rotation, including edge certificates);
- a schema check that a CA key name is never a private key.
Kubernetes
-
RBAC is namespaced Roles and RoleBindings, only in the release and managed namespaces. There is no ClusterRoleBinding, wildcard,
escalate/bind/impersonateorpods/exec. Each component gets only the verbs it uses; for example, the operator gets no Secret or Pod access in the release namespace, and the gateway gets no Kubernetes access at all. Enforced:TestNoClusterRoleBindings,TestRoleBindingsOnlyInAllowedNamespaces,TestOperatorClusterRoleBoundPerManagedNamespace(no release-namespace binding),TestOperatorWatchNamespacesExcludesRelease,TestLeaderElectionRoleLeasesOnly,TestGatewayServiceAccountLockedDown,TestKustomizeRBACNoClusterRoleBinding. A generic ban on wildcard /escalate/bind/impersonate/pods/execverbs: not yet enforced (holds today by inspection). -
Pod security:
runAsNonRoot,allowPrivilegeEscalation: false, dropALL;- seccomp
RuntimeDefaultor a validatedLocalhostprofile, neverUnconfined; readOnlyRootFilesystemon platform pods;automountServiceAccountToken: falseunless the pod calls the apiserver;- CPU, memory and ephemeral-storage limits.
The only privileged object is the opt-in node-profile installer, which runs in its own namespace. Enforced:
TestEveryContainerHardened(Deployments),TestRuntimeServiceAccountAutomountFalse,TestPodAutomountServiceAccountTokenFalse,TestPodEphemeralStorageBounds,TestNodeProfilesOffByDefault,TestNodeProfilesDaemonSet(privileged initContainer only; no RBAC in the installer namespace),TestNodeProfilesInstallerNamespaceGuards,TestSchemaHardening,TestKasmAdapterPodShape(kasm pods add the adapter initContainer with requests/limits +readOnlyRootFilesystem, the desktop's ro rootfs, and the read-only browser-shim/managed-policy mounts),TestKasmAdapterInitAdmittedUnderQuota(envtest: the kasm pod is admitted in a ResourceQuota-governed namespace).TestHardeningInstallerNamespaceGuard(rejects the release, managed,defaultandkube-*namespaces),TestHardeningManagedEnforcePrivilegedGated,TestHardeningDevGateBypasses(extraArgs, capability lists, Unconfined, seLinuxOptions, root groups, hostPath volumes: all needdev.enabled),TestHardeningVerifierLeastPrivilege,TestHardeningVerifierRealSysfsProof. Not yet enforced:- seccomp and read-only-root assertions on every platform pod;
readOnlyRootFilesystemandhostUsers: falseon runtime pods;extraEnvequivalents of gated flags (TCDI_DEV_*,PG*,POD_NAMESPACE);localhost/<name>profile names, which are checked only by pattern (allowlisttinycdi-browser);- the installer verifier still shares
hostPID. KeepextraArgs,extraEnvandextraVolumesempty.
-
NetworkPolicy:
- default-deny ingress and egress in the release and managed namespaces;
- runtime ingress only from gateway pods, on TCP 8443;
- runtime egress = DNS + the template's profile;
InternetOnlyalways excludes RFC 1918, 100.64.0.0/10, loopback, link-local/metadata, multicast/reserved and the cluster CIDRs;- generated policies are reconciled when they drift;
- an empty peer list fails the render and never means "any".
Enforced:
TestDefaultDenyNetworkPolicyEverywhere,TestGatewayEgressToRuntimeNamespaces,TestAPIBrokerReachableFromOperator,TestInternetOnlyBuiltinExceptsAlwaysApplied,TestInternetOnlyExceptsMergeConfigured,TestNetPolReconcilesSpecDrift,TestNetPolReconcileStillRefusesForeign,TestIngressPeerScopedToGatewayNamespace,TestIngressPeerDefaultsToWorkspaceNamespace,TestParseExceptCIDRs,TestNetworkPolicyEmptyPeersFailClosed,TestMetricsEmptyPeersFailClosed,TestMetricsNotOnPublicGatewayService,TestInternetOnlyRequiresClusterCIDRs,TestOperatorPodNamespaceEnv,TestHardeningDevGateBypasses(--disable-builtin-egress-exceptsneedsdev.enabled). Rejecting flags that override chart-set ones (a later--internet-except-cidrsor--gateway-namespace), and guardingmanagedNamespacesagainst system namespaces: not yet enforced. KeepextraArgsempty.
Images and supply chain
-
Every
uses:is pinned to a full 40-character commit SHA.permissions:are least-privilege per workflow and per job. Untrusted input reaches the shell only throughenv:. Untrusted code never runs underpull_request_targetorworkflow_run. Enforced:- the
ci.ymljob "workflow lint (actionlint + yamllint + zizmor)" (hash-pinned tools; zizmor fails on tag-pinned actions, template injection, dangerous triggers and excessive permissions); - "workflow policy tests (.github)" (
.github/tests/*.test.sh, including the release-version validation); - Dependabot for actions.
These become merge gates only once
.github/scripts/setup-repo-protection.shhas applied branch protection. - the
-
Every
FROMis pinned by@sha256, and every download is sha256-verified. Final images run as a numeric non-root UID with setuid/setgid bits removed. Enforced (partly): sha256-pinned downloads of KasmVNC, helm, syft, trivy, cosign, actionlint and the PyPI wheels; the BuildKit image is digest-pinned (checked byrelease-topology.test.sh); the Makefile fails closed on unpinned checksums. AllFROMlines are pinned, images run as numeric UIDs and setuid bits are stripped, but no CI test asserts any of that: not yet enforced. -
Publishing happens only after every gate passes, in this order:
- build and push by digest only (no tags);
- vulnerability scan, in a job with no publish credentials, no
id-token and an explicit
--config/--ignorefile; - sign and attest the scanned digests (cosign signature, SBOM, provenance);
- only then promote the tags.
The publish job consumes only allowlisted artifacts, one expected artifact per name, never merged. Releases run only from a protected environment, and the released chart carries image digests.
.trivyignoreexceptions carry a reason, an approver and an expiry of at most 30 days, and an expired exception fails the scan gate. Enforced: digest-only push, scan before promotion, scan jobs without secrets or id-token, sign/attest before promotion, chart digest resolved from the registry and signed, a digest-stamped chart, the allowlist step, and exception expiry inside the scan jobs. Publish inputs are downloaded by exact artifact name and validated by.github/scripts/collect-publish-inputs.sh: exact folder set, the expected file per folder, strict refs, SBOM JSON, and chart digests equal to the refs. Theimages.ymlpromote job validates refs withvalidate-image-refs.sh. When a GPL runtime image is released, the KasmVNC corresponding-source bundle is required among the release assets. Checked by.github/tests/release-topology.test.sh,.github/tests/check-trivyignore.test.shand.github/tests/publish-inputs.test.sh. Dispatches are rehearsals only; a real publish needs av*tag. Repository settings (applied by.github/scripts/setup-repo-protection.sh --applyright after the repository goes public and before any tag or dispatch; its plan is checked by.github/tests/setup-repo-protection.test.sh):- the protected
releaseenvironment with av*tag policy; - the
v*tag ruleset (bypass: repository admins only); - branch protection on
main.
-
govulncheck,npm audit --omit=dev, dependency review and secret scanning gate CI. Thresholds follow the vulnerability policy. Enforced: "govulncheck (Go vuln scan)" (v1.8.0, called vulnerabilities fail the job),npm audit --omit=dev --audit-level=highin "portal ui", a weekly scheduled rerun, Dependabot, and "chromium apt-pin freshness". Repository settings (applied bysetup-repo-protection.sh --apply):- dependency review (
CODE_SCANNING_ENABLED); - secret scanning and push protection;
- private vulnerability reporting;
- required checks via branch protection.
They are not in force until the script has run.
- dependency review (
- OIDC (Keycloak):
- confidential client with PKCE S256 and the exact redirect URI
https://<portalHost>/v1/auth/callback; tenant_idcomes from an admin-controlled mapper (a hard-coded claim or a group mapping), never from a user-editable attribute;- the
groupsclaim is emitted without full paths, sotenant-adminmatches; - gate who may log in with
oidc.requiredGroups(e.g.[platform-admins]). Every account that passes gets a desktop with Internet egress inside your network. The match is verbatim, so emit thegroupsclaim without full paths (full.path: false). The gate applies at login: removing someone from the group takes effect when their session ends (up to the absolute timeout); - the client secret is supplied via
oidc.existingSecret.
- confidential client with PKCE S256 and the exact redirect URI
- Edges:
- the portal host and the session domain must satisfy the cookie mode
(v0.2 design): the default
laxmode requires both on the same registrable domain (same eTLD+1) overhttps;partitionedmode (SameSite=None; Secure; Partitioned) covers cross-site deployments; - wildcard DNS and a wildcard certificate for
*.<sessionDomain>, fronted by one wildcard edge rule (HTTPRoute/Ingress); - every cookie on the portal origin carries the
__Host-prefix, so a sibling host on the same registrable domain cannot tossDomain=cookies at it; - TLS on both edges (the chart enforces Ingress TLS and an HTTPRoute
sectionName; point it at the HTTPS listener); - HSTS: the portal and the session listener already send it;
- rate limits on
/v1/loginand/v1/launch; - if a CDN/WAF proxies the portal, it terminates TLS and sees portal
session cookies and launch tickets. Require strict origin TLS,
never cache
/v1/*, and make the origin reachable only through the CDN.
- the portal host and the session domain must satisfy the cookie mode
(v0.2 design): the default
- Database:
database.tls.mode: verify-full(the default) withdatabase.tls.caSecretfor a private CA (the api getsPGSSLMODE/PGSSLROOTCERT). A DSNsslmode=overridesdatabase.tls.mode; the api refuses to start with a non-verifying mode, so keepsslmode=out of the DSN, or set it toverify-full;- a dedicated role;
database.allowedPeersset explicitly: the default is a deny-all placeholder, and an empty list fails the render;- the DB is unreachable from tenant namespaces;
- backups are encrypted;
- after a restore, rotate the session epoch (backup-restore).
- Internal mTLS (operator → backend; in v0.2 the broker is
in-process and the mTLS listener keeps only operator routes):
- a dedicated CA, never a cluster-wide shared issuer: every cert the CA signs is a valid operator (or, in split/test mode, gateway) identity;
- the CA Secrets you reference contain only
ca.crt; api.operatorCNis unique;- certificates rotate through cert-manager
duration/renewBefore. Give the CA a longer lifetime than its leaves. (v0.2 design) backend listeners hot-reload certificates when their files change; any pod that still loads certificates only at startup must be restarted after a renewal (or use a reloader).
- Egress:
- the operator always excludes RFC 1918,
100.64.0.0/10, loopback, link-local/metadata and multicast/reserved ranges fromInternetOnly; - set
operator.clusterCIDRsto your pod, service and node CIDRs. It is required when you seedInternetOnlytemplates, and it matters for any range outside RFC 1918; - keep
extraArgs/extraEnvempty on every component: the chart rejects known weakening flags unlessdev.enabled, but cannot catch every override; - prefer the
Isolatedprofile where the use case allows it; - an FQDN allowlist needs an egress proxy.
- the operator always excludes RFC 1918,
- Namespaces:
- release namespace PSS
enforce=baseline(warn=restricted); managed namespacesrestricted; - never label the release namespace
privileged; networkPolicy.enabled=truewith explicitapiServerPeers,dnsPeersand monitoring peers.
- release namespace PSS
- Node profiles:
- install the seccomp and AppArmor profiles out-of-band
(node profiles), or with the
installer in a namespace of its own (
nodeProfiles.install.namespace; the chart rejects the release, managed,kube-*anddefaultnamespaces). The installer runs a privileged pod on every matched node; scope it withnodeProfiles.install.nodeSelectorto the nodes that run browser workspaces; - never run browser templates
Unconfined; - run all workspace templates on dedicated, promptly patched nodes that host no platform or cluster-infrastructure workloads (cert-manager, operators, storage, CI runners). A container escape from a tenant shell must not land next to cluster-wide credentials.
- install the seccomp and AppArmor profiles out-of-band
(node profiles), or with the
installer in a namespace of its own (
- Images and upgrades:
- pin
images.*.digestin your GitOps values; - verify signatures against the exact release identity, never a
regex that also matches branches:
cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity https://github.com/tinyorbitvn/tinycdi/.github/workflows/release.yml@refs/tags/vX.Y.Z <image>@sha256:<digest>. Provenance, when published, can also be checked withgh attestation verify oci://<image>@sha256:<digest> --owner tinyorbitvn --signer-workflow tinyorbitvn/tinycdi/.github/workflows/release.yml --source-ref refs/tags/vX.Y.Z; - follow upgrade and diff CRDs before applying them.
- kasm workspace images (
spec.linux.adapter: kasm): run onlybuild/kasm-catalog.txtentries — digest-pinned, trivy-gated and within the browser-engine freshness floor (.github/scripts/scan-kasm-catalog.sh, run weekly by thekasm-contractCI job). An operator-added kasm template pointing at a non-cataloged image is at the operator's risk: run the onboarding checklist in kasm-images first (wrapper audit, sessionCmd at the real binary, trivy gate, engine floor, contract test). The adapter requires--kasm-adapter-image(digest-pinned); a seeded kasm template without it fails the render and the backend rejects the workspace. Accepted residual risk for v0.2:kasmweb/*browser images may lag the nativetinycdi-browserimage by up to 4 Chromium major versions. The adapter is off by default; use the nativetinycdi-browserimage for untrusted browsing.
- pin
- Secret rotation:
- rotate the OIDC client secret, the DB credentials and the internal certs on a schedule;
- leave
gateway.controlTokenunset unless you need it; - to revoke all sessions, rotate the session epoch;
- runtime credentials are per-workspace and regenerated when the workspace is recreated.
- Observability:
- keep metrics off public Services;
- restrict access to API and session-listener logs (the v0.1 api/gateway pods; the backend in v0.2), which contain OIDC subjects and lease IDs;
- alert on spikes of denied
launch.redeemevents.
- Clipboard and other in-desktop restrictions are UX defaults, not DLP. The workspace user controls their own runtime.