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
4 changes: 4 additions & 0 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
token: string,
relativePath: string,
) {
// Deliberately not `verifySignedClaims` (auth/utils.ts), which the attachment
// upload and project sync tokens share: this one tolerates extra
// dot-separated segments and rewrites `expiresAt` for favicon claims, so
// adopting the shared check would change what it accepts.
const [encodedPayload, signature] = token.split(".");
if (!encodedPayload || !signature) return null;

Expand Down
35 changes: 7 additions & 28 deletions apps/server/src/assets/AttachmentUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

Expand All @@ -21,12 +20,7 @@ import {
sweepStalePendingAttachments,
} from "../attachmentStore.ts";
import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";
import {
base64UrlDecodeUtf8,
base64UrlEncode,
signPayload,
timingSafeEqualBase64Url,
} from "../auth/utils.ts";
import { base64UrlEncode, signPayload, verifySignedClaims } from "../auth/utils.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfig from "../config.ts";
import { inferImageExtension } from "../imageMime.ts";
Expand All @@ -53,14 +47,6 @@ const attachmentUploadClaimsJson = Schema.fromJsonString(AttachmentUploadClaims)
const decodeAttachmentUploadClaims = Schema.decodeUnknownOption(attachmentUploadClaimsJson);
const encodeAttachmentUploadClaims = Schema.encodeSync(attachmentUploadClaimsJson);

function decodeClaims(encodedPayload: string): AttachmentUploadClaims | null {
try {
return Option.getOrNull(decodeAttachmentUploadClaims(base64UrlDecodeUtf8(encodedPayload)));
} catch {
return null;
}
}

const loadSigningSecret = Effect.gen(function* () {
const secretStore = yield* ServerSecretStore.ServerSecretStore;
return yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
Expand Down Expand Up @@ -113,26 +99,19 @@ export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(f
export const validateAttachmentUploadToken = Effect.fn("AttachmentUpload.validateToken")(function* (
token: string,
) {
const [encodedPayload, signature, unexpectedSegment] = token.split(".");
if (!encodedPayload || !signature || unexpectedSegment) {
return null;
}

const secret = yield* loadSigningSecret.pipe(
Effect.tapError((cause) =>
Effect.logError("Failed to load the attachment upload signing key.", { cause }),
),
Effect.orElseSucceed(() => null),
);
if (!secret || !timingSafeEqualBase64Url(signature, signPayload(encodedPayload, secret))) {
return null;
}

const claims = decodeClaims(encodedPayload);
if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) {
return null;
}
return claims;
return verifySignedClaims({
token,
secret,
nowMs: yield* Clock.currentTimeMillis,
decode: decodeAttachmentUploadClaims,
});
});

export type StoreAttachmentUploadResult =
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
// A manifest and an export URL only ever read the workspace, so they sit
// with projects.readFile. Import URLs and deletions write it, so they sit
// with projects.writeFile.
[WS_METHODS.projectSyncManifest]: AuthOrchestrationReadScope,
[WS_METHODS.projectSyncCreateExportUrl]: AuthOrchestrationReadScope,
[WS_METHODS.projectSyncCreateImportUrl]: AuthOrchestrationOperateScope,
[WS_METHODS.projectSyncApplyDeletions]: AuthOrchestrationOperateScope,
[WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
[WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope,
[WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
import * as NodeCrypto from "node:crypto";
import * as Encoding from "effect/Encoding";
import * as Option from "effect/Option";
import * as Result from "effect/Result";

const SESSION_COOKIE_NAME = "t3_session";
Expand Down Expand Up @@ -89,6 +90,40 @@ export function timingSafeEqualBase64Url(left: string, right: string): boolean {
return NodeCrypto.timingSafeEqual(leftBuffer, rightBuffer);
}

/**
* Verifies one `<base64url claims>.<hmac signature>` bearer token and returns
* its claims, or `null` for any token that is malformed, unsigned, signed with
* the wrong key, undecodable, or expired.
*
* Every family of URL-borne token the server issues (attachment uploads,
* project sync export/import) shares this shape, and each hand-rolled copy of
* the check is one place for the families to drift apart on what counts as
* valid. `secret` is nullable so callers can pass the result of a signing-key
* load that failed: no key means no valid token.
*/
export function verifySignedClaims<A extends { readonly expiresAt: number }>(input: {
readonly token: string;
readonly secret: Uint8Array | null;
readonly nowMs: number;
readonly decode: (encoded: unknown) => Option.Option<A>;
}): A | null {
const [payload, signature, unexpectedSegment] = input.token.split(".");
if (!payload || !signature || unexpectedSegment !== undefined) {
return null;
}
if (!input.secret || !timingSafeEqualBase64Url(signature, signPayload(payload, input.secret))) {
return null;
}

let claims: A | null;
try {
claims = Option.getOrNull(input.decode(base64UrlDecodeUtf8(payload)));
} catch {
return null;
}
return claims && claims.expiresAt > input.nowMs ? claims : null;
}

function normalizeNonEmptyString(value: string | null | undefined): string | undefined {
if (typeof value !== "string") {
return undefined;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export const make = Effect.gen(function* () {
threadPinning: true,
threadPinReorder: true,
threadTitleRegeneration: true,
projectSync: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),
},
Expand Down
Loading