Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import network.crypta.platform.api.PlatformApiException;
import network.crypta.platform.api.PlatformApiPaths;
import network.crypta.platform.api.PlatformApiPrincipal;
import network.crypta.platform.api.PlatformApiRequest;
import network.crypta.platform.api.PlatformApiResponse;
import network.crypta.platform.api.PlatformApiRouter;
import network.crypta.platform.appcatalog.AppCatalogManager;
import network.crypta.platform.apphost.AppHost;
import network.crypta.platform.apphost.AppTokenPrincipal;
import network.crypta.runtime.spi.RuntimePorts;
import network.crypta.support.MultiValueTable;
import network.crypta.support.URLDecoder;
Expand All @@ -29,8 +32,9 @@
*
* <p>This toadlet keeps all transport-neutral routing and JSON construction inside {@code
* :platform-api}. Its responsibility is limited to enforcing the existing full-access expectation
* for admin-facing routes, translating legacy HTTP request state into a {@link PlatformApiRequest},
* and writing the resulting JSON back through the legacy HTTP shell.
* for host/operator routes, authenticating AppHost launch-token headers when present, translating
* legacy HTTP request state into a {@link PlatformApiRequest}, and writing the resulting JSON back
* through the legacy HTTP shell.
*/
@SuppressWarnings("unused")
public final class PlatformApiToadlet extends Toadlet {
Expand All @@ -50,6 +54,9 @@ public final class PlatformApiToadlet extends Toadlet {

private static final String URL_ENCODED_CONTENT_TYPE = "application/x-www-form-urlencoded";

private static final String APP_TOKEN_HEADER = "x-crypta-app-token";
private static final String AUTHORIZATION_HEADER = "authorization";
private static final String BEARER_PREFIX = "bearer";
private static final String DELETE_METHOD = "DELETE";
private static final String ALERTS_SEGMENT = "alerts";
private static final String APP_CATALOGS_SEGMENT = "app-catalogs";
Expand All @@ -67,15 +74,18 @@ public final class PlatformApiToadlet extends Toadlet {
*/
private final PlatformApiRouter router;

/** Optional AppHost used to authenticate process-originated app launch tokens. */
private final AppHost appHost;

/**
* Creates a platform API toadlet backed by the supplied runtime ports.
*
* @param runtimePorts detached runtime ports exposed to the platform API leaf
*/
public PlatformApiToadlet(RuntimePorts runtimePorts) {
this(
new PlatformApiRouter(
runtimePorts, null, null, LegacyAdminUsageRecorder.defaultRecorder()));
new PlatformApiRouter(runtimePorts, null, null, LegacyAdminUsageRecorder.defaultRecorder()),
null);
}

/**
Expand All @@ -87,7 +97,8 @@ public PlatformApiToadlet(RuntimePorts runtimePorts) {
public PlatformApiToadlet(RuntimePorts runtimePorts, AppHost appHost) {
this(
new PlatformApiRouter(
runtimePorts, appHost, null, LegacyAdminUsageRecorder.defaultRecorder()));
runtimePorts, appHost, null, LegacyAdminUsageRecorder.defaultRecorder()),
appHost);
}

/**
Expand All @@ -101,7 +112,8 @@ public PlatformApiToadlet(
RuntimePorts runtimePorts, AppHost appHost, AppCatalogManager appCatalogManager) {
this(
new PlatformApiRouter(
runtimePorts, appHost, appCatalogManager, LegacyAdminUsageRecorder.defaultRecorder()));
runtimePorts, appHost, appCatalogManager, LegacyAdminUsageRecorder.defaultRecorder()),
appHost);
}

/**
Expand All @@ -114,8 +126,13 @@ public PlatformApiToadlet(
* @param router transport-neutral router that handles request validation and response generation
*/
PlatformApiToadlet(PlatformApiRouter router) {
this(router, null);
}

PlatformApiToadlet(PlatformApiRouter router, AppHost appHost) {
super();
this.router = Objects.requireNonNull(router, "router");
this.appHost = appHost;
}

@Override
Expand Down Expand Up @@ -269,11 +286,13 @@ private void writePlatformApiResponse(
/**
* Routes a request through the Platform API and writes either a full or header-only reply.
*
* <p>Legacy HTTP integration keeps full-access enforcement at the bridge boundary. Mutating
* requests then pass through the legacy form-password check before the bridge converts them into
* a transport-neutral {@link PlatformApiRequest} and delegates to the router. Unexpected runtime
* failures are converted into a structured {@code 500} response so callers keep the Platform API
* JSON contract even when the bridge logs the underlying error.
* <p>Legacy HTTP integration keeps host/operator full-access enforcement at the bridge boundary.
* Mutating host/operator requests then pass through the legacy form-password check before the
* bridge converts them into a transport-neutral {@link PlatformApiRequest}. App-token requests
* skip that host/operator form guard and carry a token-free app principal into the router, where
* manifest capability enforcement happens centrally. Unexpected runtime failures are converted
* into a structured {@code 500} response so callers keep the Platform API JSON contract even when
* the bridge logs the underlying error.
*
* @param method HTTP method name forwarded into the Platform API router
* @param uri request target supplied by the legacy HTTP shell
Expand All @@ -288,17 +307,8 @@ private void writePlatformApiResponse(
String method, URI uri, HTTPRequest request, ToadletContext ctx, boolean includeBody)
throws ToadletContextClosedException, IOException {
PlatformApiResponse response;
if (!ctx.isAllowedFullAccess()) {
response = PlatformApiResponse.error(403, "forbidden", "Full access is required.");
writeJsonReply(ctx, response, includeBody);
return;
}
if (!authorizeMutationRequest(method, uri, request, ctx)) {
return;
}

try {
response = router.route(toPlatformApiRequest(method, uri, request));
response = resolveAndRoutePlatformApiRequest(method, uri, request, ctx);
} catch (URLEncodedFormatException _) {
response =
PlatformApiResponse.error(
Expand All @@ -310,9 +320,48 @@ private void writePlatformApiResponse(
response =
PlatformApiResponse.error(500, "internal_error", "Unexpected platform API failure.");
}
if (response == null) {
return;
}
writeJsonReply(ctx, response, includeBody);
}

/**
* Resolves the request principal, enforces bridge-local host guards, and routes the API request.
*
* <p>The caller wraps this method in the Platform API error guard so unexpected failures in token
* authentication, host access checks, form-password checks, request conversion, or router
* dispatch all produce the same structured JSON error contract.
*
* @param method HTTP method name forwarded into the Platform API router
* @param uri request target supplied by the legacy HTTP shell
* @param request decoded legacy HTTP request wrapper
* @param ctx current toadlet context used for access checks
* @return Platform API response to write, or {@code null} when a bridge-local guard already wrote
* the response
* @throws ToadletContextClosedException if the client disconnects while a guard response is sent
* @throws IOException if the legacy HTTP shell fails while writing a guard response
* @throws URLEncodedFormatException if the request path contains malformed percent-encoding
*/
private PlatformApiResponse resolveAndRoutePlatformApiRequest(
String method, URI uri, HTTPRequest request, ToadletContext ctx)
throws ToadletContextClosedException, IOException, URLEncodedFormatException {
PrincipalResolution principalResolution = resolvePrincipal(request);
if (principalResolution.failureResponse() != null) {
return principalResolution.failureResponse();
}
PlatformApiPrincipal principal = principalResolution.principal();

if (!principal.isApp() && !ctx.isAllowedFullAccess()) {
return PlatformApiResponse.error(403, "forbidden", "Full access is required.");
}
if (!principal.isApp() && !authorizeMutationRequest(method, uri, request, ctx)) {
return null;
}

return router.route(toPlatformApiRequest(method, uri, request, principal));
}

/**
* Returns whether the current request targets one of the mutating app-management routes.
*
Expand Down Expand Up @@ -603,7 +652,8 @@ private boolean authorizeMutationRequest(
* @return immutable Platform API request built from the method, relative path, and query values
* @throws URLEncodedFormatException if the request path contains malformed percent-encoding
*/
private PlatformApiRequest toPlatformApiRequest(String method, URI uri, HTTPRequest request)
private PlatformApiRequest toPlatformApiRequest(
String method, URI uri, HTTPRequest request, PlatformApiPrincipal principal)
throws URLEncodedFormatException {
LinkedHashMap<String, List<String>> queryParameters =
LinkedHashMap.newLinkedHashMap(request.getParameterNames().size());
Expand All @@ -614,9 +664,109 @@ private PlatformApiRequest toPlatformApiRequest(String method, URI uri, HTTPRequ
queryParameters.put(parameterName, List.of(request.getMultipleParam(parameterName)));
}
copyBodyParametersIfPresent(request, queryParameters);
return new PlatformApiRequest(method, relativeApiPath(requestPath(uri)), queryParameters);
return new PlatformApiRequest(
method, relativeApiPath(requestPath(uri)), queryParameters, principal);
}

private PrincipalResolution resolvePrincipal(HTTPRequest request) {
String directToken = directAppTokenFromHeader(request);
if (directToken != null) {
return authenticateExplicitAppToken(directToken);
}

String bearerToken = bearerTokenFromAuthorization(request);
if (bearerToken == null) {
return hostOperatorPrincipal();
}
return authenticateBearerTokenOrHostOperator(bearerToken);
}

private static PrincipalResolution unauthenticatedAppToken(String message) {
return new PrincipalResolution(
null, PlatformApiResponse.error(401, "invalid_app_token", message));
}

private static PrincipalResolution hostOperatorPrincipal() {
return new PrincipalResolution(PlatformApiPrincipal.hostOperator(), null);
}

/**
* Authenticates a token from the app-specific header.
*
* <p>{@code X-Crypta-App-Token} is an explicit app-token assertion, so blank, stale, unknown, or
* unavailable token validation returns a structured authentication failure.
*
* @param token token value after header normalization
* @return app principal for a live token, or an authentication failure response
*/
private PrincipalResolution authenticateExplicitAppToken(String token) {
if (appHost == null) {
return unauthenticatedAppToken("App token authentication is unavailable.");
}
Optional<AppTokenPrincipal> appPrincipal = appHost.authenticateLaunchToken(token);
if (appPrincipal.isEmpty()) {
return unauthenticatedAppToken("Invalid app token.");
}
return appTokenPrincipal(appPrincipal.get());
}

/**
* Authenticates a Bearer token when it matches a live app token.
*
* <p>{@code Authorization: Bearer} is intentionally opportunistic because the same header is also
* used by reverse proxies and shared HTTP client configurations for unrelated host/operator
* credentials. A matching live AppHost token becomes an app principal. A non-matching Bearer
* value keeps the existing host/operator path instead of converting the request into a failed
* app-token attempt. Lookup failures are allowed to propagate to the guarded Platform API error
* path so a token-bearing request cannot silently widen into a host/operator principal when
* AppHost state is unavailable.
*
* @param token bearer token value after header normalization
* @return app principal for a live token, otherwise the host/operator principal
*/
private PrincipalResolution authenticateBearerTokenOrHostOperator(String token) {
if (appHost == null) {
return hostOperatorPrincipal();
}
Optional<AppTokenPrincipal> appPrincipal = appHost.authenticateLaunchToken(token);
if (appPrincipal.isEmpty()) {
return hostOperatorPrincipal();
}
return appTokenPrincipal(appPrincipal.get());
}

private static PrincipalResolution appTokenPrincipal(AppTokenPrincipal principal) {
return new PrincipalResolution(
PlatformApiPrincipal.appToken(principal.appId(), principal.permissions()), null);
}

private static String directAppTokenFromHeader(HTTPRequest request) {
String directToken = request.getHeader(APP_TOKEN_HEADER);
return directToken == null ? null : directToken.trim();
}

private static String bearerTokenFromAuthorization(HTTPRequest request) {
String authorization = request.getHeader(AUTHORIZATION_HEADER);
if (authorization == null) {
return null;
}
Comment thread
leumor marked this conversation as resolved.
String trimmed = authorization.trim();
if (trimmed.length() < BEARER_PREFIX.length()
|| !trimmed.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) {
return null;
}
if (trimmed.length() == BEARER_PREFIX.length()) {
return "";
}
if (!Character.isWhitespace(trimmed.charAt(BEARER_PREFIX.length()))) {
return null;
}
return trimmed.substring(BEARER_PREFIX.length()).trim();
}

private record PrincipalResolution(
PlatformApiPrincipal principal, PlatformApiResponse failureResponse) {}

/**
* Copies body-backed form values into the query-like parameter map when present.
*
Expand Down
19 changes: 17 additions & 2 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,24 @@ data/cache/run directories, and a per-launch `CRYPTAD_APP_TOKEN` for app-origina
authentication. It does not currently provide containers, WASM isolation, seccomp, chroot, jails,
Windows Job Object restrictions, network isolation, or browser-scoped app sessions.

App-originated Platform API calls authenticate with the launch token in `X-Crypta-App-Token`.
`Authorization: Bearer` is accepted only when the Bearer value matches a live app token; unrelated
Bearer credentials continue through the host/operator path so reverse proxies and shared clients do
not accidentally convert local management requests into failed app-token attempts. Valid app
principals are denied by default unless the route is covered by manifest-declared capabilities such
as `queue.read`, `queue.write`, or `content.insert`. Invalid or stale `X-Crypta-App-Token` values
fail authentication, and missing capabilities fail authorization without echoing the token.

Runtime status and process-log Platform API responses must remain token-free and path-free. Log
tail responses redact the current launch token and obvious `CRYPTAD_APP_TOKEN=...` text before
returning app output to the Web Shell. This is defense in depth for operator visibility; it is not a
general secret scanner for arbitrary app output.

App-originated allowed and denied Platform API decisions are recorded in a bounded process-local
audit log. Audit events keep route family, action, required capabilities, decision, status, and a
short reason code. They must not include raw launch tokens, query strings, request bodies, form
passwords, or filesystem paths.

First-party static app UIs can fetch `/apps/{appId}/.well-known/cryptad-bootstrap.json` to discover
local route roots and the existing local-admin form password used for mutating Platform API calls.
That bootstrap is host/operator scoped. It must not contain `CRYPTAD_APP_TOKEN`, AppHost launch
Expand All @@ -93,5 +106,7 @@ Treat a bypass that serves host files, follows symlink escapes, executes JavaScr
UI has JavaScript disabled, exposes AppHost launch tokens to browser code, or allows bundled UI to
exfiltrate operator-entered data off-node as security-relevant.

For the detailed runtime boundary, exposed environment variables, restart policy semantics, and
remaining limitations, see [apphost-runtime-hardening.md](apphost-runtime-hardening.md).
For the detailed runtime boundary, exposed environment variables, restart policy semantics,
permission matrix, audit model, and remaining limitations, see
[apphost-runtime-hardening.md](apphost-runtime-hardening.md) and
[app-permissions-and-audit.md](app-permissions-and-audit.md).
5 changes: 3 additions & 2 deletions docs/app-catalogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,6 @@ and security boundary.

## Future work

This PR does not add permission enforcement, container or WASM sandboxing, public app-store
governance, or background app update scheduling.
Manifest permissions are enforced for app-process Platform API calls as described in
[app-permissions-and-audit.md](app-permissions-and-audit.md). Container or WASM sandboxing, public
app-store governance, and background app update scheduling remain future work.
16 changes: 12 additions & 4 deletions docs/app-owned-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,11 @@ App-owned UI uses stable URLs across reinstall and update operations. Responses
with non-public no-cache headers instead of the legacy admin adapter's long-lived static cache
policy.

The route remains same-origin with the local admin UI and Platform API. Permission enforcement,
stronger isolation, and audit logging belong to later platform work. Until stronger browser
isolation exists, install static UI bundles only from sources trusted to run JavaScript in the local
admin origin.
The route remains same-origin with the local admin UI and Platform API. Static browser UI does not
receive `CRYPTAD_APP_TOKEN` and cannot authenticate as the app process in this model. App
permission enforcement and audit apply to process-originated Platform API calls that present the
launch token. Stronger browser isolation remains later platform work. Until that exists, install
static UI bundles only from sources trusted to run JavaScript in the local admin origin.

## First-party app bootstrap

Expand Down Expand Up @@ -163,6 +164,8 @@ Runtime hardening APIs are separate from the app summary:
```text
GET /api/v1/apps/{appId}/runtime
GET /api/v1/apps/{appId}/logs?maxBytes=65536
GET /api/v1/apps/{appId}/permissions
GET /api/v1/apps/{appId}/audit
```

The runtime endpoint reports process state, PID, start time, last exit metadata, restart attempt
Expand All @@ -173,6 +176,11 @@ installed/data/cache/run filesystem paths.
The Web Shell uses those endpoints for operator visibility. Static app UIs must not treat the
runtime endpoint as proof of app-level health; it is process status only.

The permissions and audit endpoints expose manifest-declared permissions, recent app-originated
Platform API decisions, and retained denied-call counts. Audit entries are bounded and
process-local, and they omit tokens, query strings, request bodies, form passwords, and filesystem
paths.

## Examples

Static UI bundle:
Expand Down
Loading
Loading