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
23 changes: 23 additions & 0 deletions src/backend/controllers/auth/AuthController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3221,6 +3221,29 @@ describe('AuthController.handleConfirmEmail', () => {
original_client_socket_id: 's',
});
});

it('rejects "null" as a code when no confirmation code is stored', async () => {
const { user, actor } = await makeUserAndActor();
// A row with no stored code must never be confirmable: String(null)
// would otherwise equal a submitted "null" and confirm the email.
await server.stores.user.update(user.id, { email_confirm_code: null });
const res = makeRes();
await controller.handleConfirmEmail(
makeReq(
{ code: 'null', original_client_socket_id: 's' },
{ actor },
),
res,
);
expect(res.body).toEqual({
email_confirmed: false,
original_client_socket_id: 's',
});
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.email_confirmed).toBeFalsy();
});
});

// ── Password recovery flow ──────────────────────────────────────────
Expand Down
11 changes: 8 additions & 3 deletions src/backend/controllers/auth/AuthController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -924,8 +924,7 @@ export class AuthController extends PuterController {
is_temp: user!.password === null && user!.email === null,
ip:
(req?.headers?.['x-forwarded-for'] as
| string
| undefined) ||
string | undefined) ||
(
req as unknown as {
connection?: { remoteAddress?: string };
Expand Down Expand Up @@ -1078,7 +1077,13 @@ export class AuthController extends PuterController {
});
return;
}
if (String(user.email_confirm_code) !== String(code)) {
// Reject before comparing when no code is stored: `String(null)` would
// otherwise equal a submitted `"null"` and confirm the email without
// the real code.
if (
!user.email_confirm_code ||
String(user.email_confirm_code) !== String(code)
) {
res.json({
email_confirmed: false,
original_client_socket_id,
Expand Down
35 changes: 35 additions & 0 deletions src/backend/controllers/fs/FSController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,41 @@
expect(body.name).toBe('stat-me');
});

it('does not leak backend-internal fields to the client', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
await withActor(actor, () =>
controller.mkdirEntry(
makeReq({
body: { path: `/${username}/Documents/no-leak` },
actor,
}),
makeRes().res,
),
);

const { res, captured } = makeRes();
await withActor(actor, () =>
controller.statEntry(
makeReq({
body: { path: `/${username}/Documents/no-leak` },
actor,
}),
res,
),
);
const body = captured.body as Record<string, unknown>;
for (const field of [
'bucket',
'bucketRegion',
'userId',
'publicToken',
'fileRequestToken',
]) {
expect(body).not.toHaveProperty(field);
}
});

it('includes the subtree size when return_size is set on a directory', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
Expand Down Expand Up @@ -1429,7 +1464,7 @@
const username = actor.user!.username!;
await expect(
withActor(actor, () =>
controller.readEntry(

Check failure on line 1467 in src/backend/controllers/fs/FSController.test.ts

View workflow job for this annotation

GitHub Actions / test (DS/main, pr)

src/backend/controllers/fs/FSController.test.ts > FSController.readdirEntries pagination > wraps the root listing in an envelope when asked

AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ src/backend/controllers/fs/FSController.test.ts:1467:39
makeReq({
query: {
path: `/${username}/Documents/missing-${uuidv4()}.txt`,
Expand Down
39 changes: 26 additions & 13 deletions src/backend/controllers/fs/FSController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,11 +845,32 @@
await this.services.suggestedApps.getSuggestedApps(entry);

res.json({
...entry,
...this.#toClientEntry(entry),
...(subtreeSize !== undefined ? { size: subtreeSize } : {}),
});
}

/**
* Strip backend-internal fields before returning an entry to a client.
* Storage location (bucket/region), the owner's numeric id, and the
* capability-token columns are never used by clients and must not leak to
* callers who only hold `see`/`list` on the entry — a share recipient, or
* (with public folders enabled) any authenticated user. The legacy read
* path already curates its output; this does the same for the v2 routes.
*/
#toClientEntry(entry: object): Record<string, unknown> {
const clone: Record<string, unknown> = { ...entry };
for (const field of [
'bucket',
'bucketRegion',
'userId',
'publicToken',
'fileRequestToken',
])
delete clone[field];
return clone;
}

@Post('/readdir', { subdomain: 'api', requireVerified: true })
async readdirEntries(req: Request, res: Response) {
const actor = this.#requireActor(req);
Expand Down Expand Up @@ -880,16 +901,7 @@
child.suggestedApps = rootSuggestions[index] ?? [];
}
}
if (paginated) {
res.json({
items: rootChildren,
...(body.includeTotal === true
? { total: rootChildren.length }
: {}),
});
return;
}
res.json(rootChildren);
res.json(rootChildren.map((child) => this.#toClientEntry(child)));
return;
}

Expand All @@ -899,7 +911,7 @@
legacyCode: 'bad_request',
});
}
await this.#assertAccess(actor, parent.path, 'list');

Check failure on line 914 in src/backend/controllers/fs/FSController.ts

View workflow job for this annotation

GitHub Actions / test (DS/main, pr)

src/backend/controllers/fs/FSController.test.ts > FSController.readdirEntries pagination > reports total with includeTotal

ReferenceError: res is not defined ❯ FSController.attachSuggestedApps_fn src/backend/controllers/fs/FSController.ts:914:3 ❯ FSController.readdirEntries src/backend/controllers/fs/FSController.ts:646:7 ❯ readdir src/backend/controllers/fs/FSController.test.ts:1373:5 ❯ src/backend/controllers/fs/FSController.test.ts:1451:18

Check failure on line 914 in src/backend/controllers/fs/FSController.ts

View workflow job for this annotation

GitHub Actions / test (DS/main, pr)

src/backend/controllers/fs/FSController.test.ts > FSController.readdirEntries pagination > rejects a cursor that conflicts with the requested sort

ReferenceError: res is not defined ❯ FSController.attachSuggestedApps_fn src/backend/controllers/fs/FSController.ts:914:3 ❯ FSController.readdirEntries src/backend/controllers/fs/FSController.ts:646:7 ❯ readdir src/backend/controllers/fs/FSController.test.ts:1373:5 ❯ src/backend/controllers/fs/FSController.test.ts:1425:19

Check failure on line 914 in src/backend/controllers/fs/FSController.ts

View workflow job for this annotation

GitHub Actions / test (DS/main, pr)

src/backend/controllers/fs/FSController.test.ts > FSController.readdirEntries pagination > respects descending sort across pages

ReferenceError: res is not defined ❯ FSController.attachSuggestedApps_fn src/backend/controllers/fs/FSController.ts:914:3 ❯ FSController.readdirEntries src/backend/controllers/fs/FSController.ts:646:7 ❯ readdir src/backend/controllers/fs/FSController.test.ts:1373:5 ❯ src/backend/controllers/fs/FSController.test.ts:1408:19

Check failure on line 914 in src/backend/controllers/fs/FSController.ts

View workflow job for this annotation

GitHub Actions / test (DS/main, pr)

src/backend/controllers/fs/FSController.test.ts > FSController.readdirEntries pagination > pages through children with cursors in sort order

ReferenceError: res is not defined ❯ FSController.attachSuggestedApps_fn src/backend/controllers/fs/FSController.ts:914:3 ❯ FSController.readdirEntries src/backend/controllers/fs/FSController.ts:646:7 ❯ readdir src/backend/controllers/fs/FSController.test.ts:1373:5 ❯ src/backend/controllers/fs/FSController.test.ts:1396:20

Check failure on line 914 in src/backend/controllers/fs/FSController.ts

View workflow job for this annotation

GitHub Actions / test (DS/main, pr)

src/backend/controllers/fs/FSController.test.ts > FSController.readdirEntries pagination > returns the envelope when cursor is present (null = first page)

ReferenceError: res is not defined ❯ FSController.attachSuggestedApps_fn src/backend/controllers/fs/FSController.ts:914:3 ❯ FSController.readdirEntries src/backend/controllers/fs/FSController.ts:646:7 ❯ readdir src/backend/controllers/fs/FSController.test.ts:1373:5 ❯ src/backend/controllers/fs/FSController.test.ts:1387:18

Check failure on line 914 in src/backend/controllers/fs/FSController.ts

View workflow job for this annotation

GitHub Actions / test (DS/main, pr)

src/backend/controllers/fs/FSController.test.ts > FSController.readdirEntries pagination > keeps the bare array response for limit/offset requests

ReferenceError: res is not defined ❯ FSController.attachSuggestedApps_fn src/backend/controllers/fs/FSController.ts:914:3 ❯ FSController.readdirEntries src/backend/controllers/fs/FSController.ts:661:5 ❯ readdir src/backend/controllers/fs/FSController.test.ts:1373:5 ❯ src/backend/controllers/fs/FSController.test.ts:1381:18

Check failure on line 914 in src/backend/controllers/fs/FSController.ts

View workflow job for this annotation

GitHub Actions / test (DS/main, pr)

src/backend/controllers/fs/FSController.test.ts > FSController.readdirEntries sort + limit > defaults invalid sort_by/sort_order to null

ReferenceError: res is not defined ❯ FSController.attachSuggestedApps_fn src/backend/controllers/fs/FSController.ts:914:3 ❯ FSController.readdirEntries src/backend/controllers/fs/FSController.ts:661:5 ❯ src/backend/controllers/fs/FSController.test.ts:1331:7

Check failure on line 914 in src/backend/controllers/fs/FSController.ts

View workflow job for this annotation

GitHub Actions / test (DS/main, pr)

src/backend/controllers/fs/FSController.test.ts > FSController.readdirEntries sort + limit > accepts sort_by + sort_order and passes them through to listDirectory

ReferenceError: res is not defined ❯ FSController.attachSuggestedApps_fn src/backend/controllers/fs/FSController.ts:914:3 ❯ FSController.readdirEntries src/backend/controllers/fs/FSController.ts:661:5 ❯ src/backend/controllers/fs/FSController.test.ts:1300:7

Check failure on line 914 in src/backend/controllers/fs/FSController.ts

View workflow job for this annotation

GitHub Actions / test (DS/main, pr)

src/backend/controllers/fs/FSController.test.ts > FSController.readdirEntries > lists children of a directory

ReferenceError: res is not defined ❯ FSController.attachSuggestedApps_fn src/backend/controllers/fs/FSController.ts:914:3 ❯ FSController.readdirEntries src/backend/controllers/fs/FSController.ts:661:5 ❯ src/backend/controllers/fs/FSController.test.ts:661:5

const limit = this.#toNumberOrUndefined(body.limit);
const offset = this.#toNumberOrUndefined(body.offset);
Expand Down Expand Up @@ -960,6 +972,8 @@
child.suggestedApps = suggestions[index] ?? [];
}
}

res.json(children.map((child) => this.#toClientEntry(child)));
}

@Post('/search', { subdomain: 'api', requireVerified: true })
Expand Down Expand Up @@ -1849,8 +1863,7 @@
// the ActorUser type. Access via the escape hatch until a proper
// storage-quota mechanism is in place.
const actorUser = req.actor?.user as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;

const candidates = [
this.#toStorageCapacityCandidate(actorUser?.free_storage),
Expand Down
25 changes: 14 additions & 11 deletions src/backend/controllers/fs/LegacyFSController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import type { Actor } from '../../core/actor.js';
import { effectiveActorApp, isAccessTokenActor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import {
assertNotSuspended,
assertVerifiedAccount,
} from '../../core/http/middleware/gates.js';
import { RouteOptions } from '../../core/http/index.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import type { ACLService } from '../../services/acl/ACLService.js';
Expand Down Expand Up @@ -158,8 +162,7 @@ export class LegacyFSController extends PuterController {

router.get('/get-launch-apps', apiOptions, async (req, res) => {
const recommendedSvc = this.services.recommendedApps as unknown as
| { getRecommendedApps?: () => Promise<unknown[]> }
| undefined;
{ getRecommendedApps?: () => Promise<unknown[]> } | undefined;
const recommended = recommendedSvc?.getRecommendedApps
? await recommendedSvc.getRecommendedApps()
: [];
Expand Down Expand Up @@ -616,9 +619,7 @@ export class LegacyFSController extends PuterController {
// Trash, and `null`/`{}` when restoring. See
// `src/gui/src/helpers.js` → `window.move_items`.
newMetadata: (body.new_metadata ?? undefined) as
| Record<string, unknown>
| null
| undefined,
Record<string, unknown> | null | undefined,
});
const oldPath = source.path;
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
Expand Down Expand Up @@ -1001,6 +1002,12 @@ export class LegacyFSController extends PuterController {
});
}

// This endpoint authenticates the token by hand and never runs the
// route gate chain, so the suspension and pending-verification checks
// that guard every other authenticated FS route have to run here.
assertNotSuspended(actor!.user);
assertVerifiedAccount(actor!.user);

req.actor = actor!;
Context.set('actor', actor);

Expand Down Expand Up @@ -1042,8 +1049,7 @@ export class LegacyFSController extends PuterController {
}

type SignedOrEmpty =
| (SignedFile & { path?: string })
| Record<string, never>;
(SignedFile & { path?: string }) | Record<string, never>;
const result: { signatures: SignedOrEmpty[]; token?: string } = {
signatures: [],
};
Expand Down Expand Up @@ -1584,10 +1590,7 @@ export class LegacyFSController extends PuterController {
const subjectRef = body.subject;
const appRef = body.app;
const mode = (getString(body, 'mode') ?? 'read') as
| 'see'
| 'list'
| 'read'
| 'write';
'see' | 'list' | 'read' | 'write';
if (!subjectRef || !appRef)
throw new HttpError(400, '`subject` and `app` are required', {
legacyCode: 'bad_request',
Expand Down
17 changes: 17 additions & 0 deletions src/backend/drivers/ai-ocr/OCRDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,23 @@ it('meters one usage line per detected page at the per-page rate from costs.ts',
// ── Mistral OCR ─────────────────────────────────────────────────────

describe('OCRDriver.recognize (mistral)', () => {
it('throws 402 when the actor does not have enough credits', async () => {
hasCreditsSpy.mockResolvedValueOnce(false);
const { actor } = await makeUser();

await expect(
withActor(actor, () =>
driver.recognize({
source: dataUrl(Buffer.from('img'), 'image/png'),
provider: 'mistral',
}),
),
).rejects.toMatchObject({ statusCode: 402 });

// The paid Mistral call must not happen when credits are short.
expect(mistralOcrProcessMock).not.toHaveBeenCalled();
});

it('packages an image as an image_url chunk with a base64 data URL', async () => {
const { actor } = await makeUser();
mistralOcrProcessMock.mockResolvedValueOnce({
Expand Down
18 changes: 14 additions & 4 deletions src/backend/drivers/ai-ocr/OCRDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,9 @@ export class OCRDriver extends PuterDriver {
const providers = this.config.providers ?? {};

const textract = providers['aws-textract'] as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const textractAws = (textract?.aws ?? textract) as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const textractAccessKey = textractAws?.access_key as string | undefined;
const textractSecretKey = textractAws?.secret_key as string | undefined;
const textractRegion =
Expand Down Expand Up @@ -325,6 +323,18 @@ export class OCRDriver extends PuterDriver {
args: RecognizeArgs,
actor: Actor,
) {
// Gate on credits before the paid upstream call, mirroring the
// Textract branch. Page count isn't known until Mistral responds, so
// pre-flight one page's cost and meter the real total afterward.
const hasCredits = await this.services.metering.hasEnoughCredits(
actor,
OCR_COSTS['mistral-ocr:ocr:page'],
);
if (!hasCredits)
throw new HttpError(402, 'Insufficient credits', {
legacyCode: 'insufficient_funds',
});

const model = args.model ?? 'mistral-ocr-latest';
const chunk = this.#mistralBuildChunk(loaded);
const payload: Record<string, unknown> = { model, document: chunk };
Expand Down
35 changes: 35 additions & 0 deletions src/backend/services/auth/OIDCService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,38 @@ describe('OIDCService.createUserFromOIDC', () => {
}
});
});

describe('OIDCService.linkProviderToUser', () => {
const makeConfirmedUser = async (): Promise<number> => {
const username = `oidc-link-${crypto.randomBytes(4).toString('hex')}`;
const created = await server.stores.user.create({
username,
uuid: crypto.randomUUID(),
password: null,
email: `${username}@corp.example`,
requires_email_confirmation: false,
});
await server.stores.user.update(created.id, { email_confirmed: 1 });
return created.id;
};

it('refuses to link to an existing account when the provider omits email_verified', async () => {
const userId = await makeConfirmedUser();
const result = await oidc().linkProviderToUser(userId, 'custom-idp', {
sub: `attacker-${crypto.randomBytes(4).toString('hex')}`,
email: 'anything@corp.example',
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/verify/i);
});

it('links when the provider attests email_verified: true', async () => {
const userId = await makeConfirmedUser();
const result = await oidc().linkProviderToUser(userId, 'custom-idp', {
sub: `legit-${crypto.randomBytes(4).toString('hex')}`,
email: 'anything@corp.example',
email_verified: true,
});
expect(result.success).toBe(true);
});
});
6 changes: 5 additions & 1 deletion src/backend/services/auth/OIDCService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,11 @@ export class OIDCService extends PuterService {
providerId: string,
claims: OIDCUserInfo,
): Promise<{ success: boolean; error?: string }> {
if (claims.email_verified === false) {
// Fail closed: linking an OIDC identity to an EXISTING account hands
// login control to whoever holds that identity, so an absent
// `email_verified` claim (from a lax/custom provider) must not be
// treated as verified. Built-in providers always send it as `true`.
if (claims.email_verified !== true) {
return {
success: false,
error: 'Provider did not verify this email address.',
Expand Down
Loading