mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-24 23:58:47 +00:00
PUT-2058 and PUT-2096
This commit is contained in:
1 parent
471b900462
commit
5b40092ae6
7 files changed
+793
-75
No files matched your search
@@ -151,8 +151,14 @@ export class FSController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// Write routes admit scoped access tokens for the same reason the reads
|
||||
// do (see the note above `/stat`): every handler authorizes the target
|
||||
// through the ACL, which intersects a token's grant with its issuer's, so
|
||||
// a token writes exactly where it was granted write — the parity WebDAV
|
||||
// already has. Upload sessions are re-checked on complete, sign and abort.
|
||||
@Post('/startWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -219,6 +225,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/startBatchWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -325,6 +332,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/completeWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -367,6 +375,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/completeBatchWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -424,6 +433,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/abortWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
})
|
||||
@@ -438,12 +448,31 @@ export class FSController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
// An unknown id stays a no-op, as it always was.
|
||||
try {
|
||||
await this.services.fs.getUploadSessions(userId, [
|
||||
req.body.uploadId,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError && error.statusCode === 404)) {
|
||||
throw error;
|
||||
}
|
||||
res.json({ ok: true });
|
||||
return;
|
||||
}
|
||||
// Same re-check as complete/sign: a scoped token may only abort an
|
||||
// upload whose target it could write.
|
||||
await this.#assertUploadSessionWriteAccess(req, userId, [
|
||||
req.body.uploadId,
|
||||
]);
|
||||
|
||||
await this.services.fs.abortUrlWrite(userId, req.body.uploadId);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
@Post('/signMultipartParts', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MULTIPART_LIMIT,
|
||||
@@ -476,6 +505,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/write', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_WRITE_LIMIT,
|
||||
@@ -531,6 +561,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/batchWrite', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_WRITE_LIMIT,
|
||||
@@ -1502,6 +1533,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/mkdir', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1547,6 +1579,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/touch', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1587,6 +1620,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/rename', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1610,6 +1644,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/delete', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1636,6 +1671,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/move', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
@@ -1689,6 +1725,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/copy', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
@@ -1740,6 +1777,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/mkshortcut', {
|
||||
subdomain: 'api',
|
||||
allowAccessToken: true,
|
||||
requireVerified: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
})
|
||||
|
||||
@@ -209,12 +209,19 @@ export class LegacyFSController extends PuterController {
|
||||
// Limits come from `./limits` and carry an explicit `scope`, so these
|
||||
// draw from the same per-user budget as their v2 counterparts rather
|
||||
// than handing a caller a second allowance for the same operation.
|
||||
const mutate = { ...apiOptions, rateLimit: FS_MUTATE_LIMIT };
|
||||
// Read-side routes admit scoped access tokens: the ACL check each
|
||||
// handler runs already intersects a token's grant with its issuer's,
|
||||
// so the token reaches exactly what it was minted for. That is what a
|
||||
// background events handler acts through — its `user` is a token
|
||||
// scoped to the subscription's grant.
|
||||
//
|
||||
// The entry routes — reads and writes alike — admit scoped access
|
||||
// tokens: the ACL check each handler runs already intersects a token's
|
||||
// grant with its issuer's, so the token reaches exactly what it was
|
||||
// minted for. That is what a background events handler acts through —
|
||||
// its `user` is a token scoped to the subscription's grant — and it
|
||||
// matches what WebDAV admits. Routes reaching past a single entry
|
||||
// (search, df, sign, the app helpers) stay closed to them.
|
||||
const mutate = {
|
||||
...apiOptions,
|
||||
allowAccessToken: true,
|
||||
rateLimit: FS_MUTATE_LIMIT,
|
||||
} as RouteOptions;
|
||||
const reads = { ...apiOptions, allowAccessToken: true } as RouteOptions;
|
||||
router.post('/stat', { ...reads, rateLimit: FS_STAT_LIMIT }, this.stat);
|
||||
router.post(
|
||||
@@ -264,6 +271,8 @@ export class LegacyFSController extends PuterController {
|
||||
'/batch',
|
||||
{
|
||||
...spends,
|
||||
// Every op authorizes its own target.
|
||||
allowAccessToken: true,
|
||||
rateLimit: FS_BATCH_LIMIT,
|
||||
concurrent: FS_BATCH_CONCURRENT,
|
||||
},
|
||||
@@ -914,7 +923,9 @@ 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 overwrittenEntry = replacedByMove(overwriteTarget, moved, name);
|
||||
const oldPath = source.path;
|
||||
@@ -1391,7 +1402,8 @@ 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: [],
|
||||
};
|
||||
@@ -1986,7 +1998,10 @@ 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',
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
// A scoped access token writes through the `/fs` routes wherever its grant
|
||||
// reaches — the reach it already has over WebDAV — and nowhere else. The ACL
|
||||
// each handler runs intersects the token's grant with its issuer's, so these
|
||||
// check both edges of that, plus the routes that stay closed to tokens.
|
||||
describe('scoped access tokens on the /fs write routes', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const post = async (route: string, token: string, body: unknown) => {
|
||||
const res = await fetch(new URL(route, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: Record<string, unknown> | null = null;
|
||||
try {
|
||||
json = text ? (JSON.parse(text) as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
// Some refusals are plain text.
|
||||
}
|
||||
return { status: res.status, json, text };
|
||||
};
|
||||
|
||||
/** A user, a token granted `mode` on their Documents, and some fixtures. */
|
||||
const setup = async (mode: 'read' | 'write') => {
|
||||
const user = await createTestUser(env.server, {
|
||||
username: `stw${Math.random().toString(36).slice(2, 9)}`,
|
||||
password: 'puter-test-user-password',
|
||||
});
|
||||
const home = `/${user.username}`;
|
||||
const documents = await post('/stat', user.token, {
|
||||
path: `${home}/Documents`,
|
||||
});
|
||||
await post('/touch', user.token, { path: `${home}/Desktop/kept.txt` });
|
||||
await post('/touch', user.token, { path: `${home}/Documents/in.txt` });
|
||||
const minted = await post('/auth/create-access-token', user.token, {
|
||||
permissions: [`fs:${documents.json!.uid}:${mode}`],
|
||||
});
|
||||
expect(minted.status).toBe(200);
|
||||
return {
|
||||
user,
|
||||
home,
|
||||
token: minted.json!.token as string,
|
||||
exists: async (path: string) =>
|
||||
(await post('/stat', user.token, { path })).status === 200,
|
||||
};
|
||||
};
|
||||
|
||||
it('writes inside its grant on the legacy routes', async () => {
|
||||
const { home, token, exists } = await setup('write');
|
||||
const docs = `${home}/Documents`;
|
||||
|
||||
expect(
|
||||
(await post('/mkdir', token, { path: `${docs}/d` })).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await post('/touch', token, { path: `${docs}/d/t.txt` })).status,
|
||||
).toBe(200);
|
||||
const t = await post('/stat', token, { path: `${docs}/d/t.txt` });
|
||||
expect(
|
||||
(
|
||||
await post('/rename', token, {
|
||||
uid: t.json!.uid,
|
||||
new_name: 'r.txt',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/copy', token, {
|
||||
source: `${docs}/d/r.txt`,
|
||||
destination: docs,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/move', token, {
|
||||
source: `${docs}/r.txt`,
|
||||
destination: `${docs}/d`,
|
||||
new_name: 'm.txt',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await post('/delete', token, { paths: [`${docs}/d/m.txt`] }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
const batch = await post('/batch', token, {
|
||||
operations: [
|
||||
{ op: 'mkdir', path: `${docs}/b` },
|
||||
{ op: 'delete', path: `${docs}/d/r.txt` },
|
||||
],
|
||||
});
|
||||
expect(batch.status, batch.text).toBe(200);
|
||||
|
||||
expect(await exists(`${docs}/b`)).toBe(true);
|
||||
expect(await exists(`${docs}/d/r.txt`)).toBe(false);
|
||||
expect(await exists(`${docs}/d/m.txt`)).toBe(false);
|
||||
}, 180_000);
|
||||
|
||||
it('writes inside its grant on the v2 routes', async () => {
|
||||
const { home, token, exists } = await setup('write');
|
||||
const docs = `${home}/Documents`;
|
||||
|
||||
expect(
|
||||
(await post('/fs/mkdir', token, { path: `${docs}/v` })).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await post('/fs/touch', token, { path: `${docs}/v/t.txt` }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
const written = await post('/fs/write', token, {
|
||||
fileMetadata: {
|
||||
path: `${docs}/v/w.txt`,
|
||||
size: 3,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: 'abc',
|
||||
});
|
||||
expect(written.status, written.text).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/rename', token, {
|
||||
path: `${docs}/v/t.txt`,
|
||||
new_name: 'r.txt',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/copy', token, {
|
||||
source: `${docs}/v/r.txt`,
|
||||
destination: docs,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/move', token, {
|
||||
source: `${docs}/r.txt`,
|
||||
destination: `${docs}/v`,
|
||||
new_name: 'm.txt',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/mkshortcut', token, {
|
||||
parent: docs,
|
||||
target: `${docs}/v/w.txt`,
|
||||
name: 'w-link',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await post('/fs/delete', token, { path: `${docs}/v/m.txt` }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
const started = await post('/fs/startWrite', token, {
|
||||
fileMetadata: { path: `${docs}/v/up.bin`, size: 4 },
|
||||
});
|
||||
expect(started.status, started.text).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await post('/fs/abortWrite', token, {
|
||||
uploadId: started.json!.sessionId,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
expect(await exists(`${docs}/v/w.txt`)).toBe(true);
|
||||
expect(await exists(`${docs}/w-link`)).toBe(true);
|
||||
expect(await exists(`${docs}/v/m.txt`)).toBe(false);
|
||||
}, 180_000);
|
||||
|
||||
it('is refused everywhere outside its grant', async () => {
|
||||
const { home, token, exists } = await setup('write');
|
||||
const docs = `${home}/Documents`;
|
||||
const desktop = `${home}/Desktop`;
|
||||
|
||||
const attempts = [
|
||||
['/mkdir', { path: `${desktop}/x` }],
|
||||
['/touch', { path: `${desktop}/x.txt` }],
|
||||
['/move', { source: `${docs}/in.txt`, destination: desktop }],
|
||||
['/copy', { source: `${desktop}/kept.txt`, destination: docs }],
|
||||
['/delete', { paths: [`${desktop}/kept.txt`] }],
|
||||
['/delete', { paths: [home] }],
|
||||
['/fs/mkdir', { path: `${desktop}/x` }],
|
||||
[
|
||||
'/fs/write',
|
||||
{
|
||||
fileMetadata: {
|
||||
path: `${desktop}/kept.txt`,
|
||||
size: 1,
|
||||
overwrite: true,
|
||||
},
|
||||
fileContent: 'x',
|
||||
},
|
||||
],
|
||||
['/fs/move', { source: `${docs}/in.txt`, destination: desktop }],
|
||||
['/fs/copy', { source: `${desktop}/kept.txt`, destination: docs }],
|
||||
['/fs/rename', { path: `${desktop}/kept.txt`, new_name: 'y.txt' }],
|
||||
['/fs/delete', { path: `${desktop}/kept.txt` }],
|
||||
['/fs/delete', { path: home }],
|
||||
] as const;
|
||||
for (const [route, body] of attempts) {
|
||||
const res = await post(route, token, body);
|
||||
expect(
|
||||
[403, 404],
|
||||
`${route} ${JSON.stringify(body)} → ${res.status}`,
|
||||
).toContain(res.status);
|
||||
}
|
||||
|
||||
const batch = await post('/batch', token, {
|
||||
operations: [{ op: 'mkdir', path: `${desktop}/b` }],
|
||||
});
|
||||
expect(batch.status).not.toBe(200);
|
||||
|
||||
expect(await exists(`${desktop}/kept.txt`)).toBe(true);
|
||||
expect(await exists(`${desktop}/x`)).toBe(false);
|
||||
expect(await exists(`${desktop}/b`)).toBe(false);
|
||||
expect(await exists(`${docs}/in.txt`)).toBe(true);
|
||||
expect(await exists(`${docs}/kept.txt`)).toBe(false);
|
||||
}, 180_000);
|
||||
|
||||
it('refuses every write to a read-only token', async () => {
|
||||
const { home, token, exists } = await setup('read');
|
||||
const docs = `${home}/Documents`;
|
||||
|
||||
const attempts = [
|
||||
['/mkdir', { path: `${docs}/x` }],
|
||||
['/touch', { path: `${docs}/x.txt` }],
|
||||
['/delete', { paths: [`${docs}/in.txt`] }],
|
||||
['/fs/mkdir', { path: `${docs}/x` }],
|
||||
[
|
||||
'/fs/write',
|
||||
{
|
||||
fileMetadata: { path: `${docs}/x.txt`, size: 1 },
|
||||
fileContent: 'x',
|
||||
},
|
||||
],
|
||||
[
|
||||
'/fs/startWrite',
|
||||
{ fileMetadata: { path: `${docs}/x.bin`, size: 1 } },
|
||||
],
|
||||
['/fs/rename', { path: `${docs}/in.txt`, new_name: 'y.txt' }],
|
||||
['/fs/delete', { path: `${docs}/in.txt` }],
|
||||
] as const;
|
||||
for (const [route, body] of attempts) {
|
||||
const res = await post(route, token, body);
|
||||
expect(res.status, `${route} → ${res.status}`).toBe(403);
|
||||
}
|
||||
expect(await exists(`${docs}/in.txt`)).toBe(true);
|
||||
expect(await exists(`${docs}/x`)).toBe(false);
|
||||
}, 180_000);
|
||||
|
||||
// An upload session belongs to the account, not to the credential that
|
||||
// opened it, so each later step re-checks the target against the caller.
|
||||
it('cannot touch an upload the session opened outside its grant', async () => {
|
||||
const { user, home, token } = await setup('write');
|
||||
const started = await post('/fs/startWrite', user.token, {
|
||||
fileMetadata: { path: `${home}/Desktop/up.bin`, size: 4 },
|
||||
});
|
||||
expect(started.status).toBe(200);
|
||||
const uploadId = started.json!.sessionId as string;
|
||||
|
||||
for (const [route, body] of [
|
||||
['/fs/abortWrite', { uploadId }],
|
||||
['/fs/completeWrite', { uploadId }],
|
||||
['/fs/signMultipartParts', { uploadId, partNumbers: [1] }],
|
||||
] as const) {
|
||||
const res = await post(route, token, body);
|
||||
expect(
|
||||
[403, 404],
|
||||
`${route} → ${res.status} ${res.text}`,
|
||||
).toContain(res.status);
|
||||
}
|
||||
|
||||
// Still open: the session's own abort finds it to abort.
|
||||
const sessions = await env.server.services.fs.getUploadSessions(
|
||||
(await env.server.stores.user.getByUsername(user.username))!.id,
|
||||
[uploadId],
|
||||
);
|
||||
expect(sessions[0]?.status).not.toBe('aborted');
|
||||
expect(
|
||||
(await post('/fs/abortWrite', user.token, { uploadId })).status,
|
||||
).toBe(200);
|
||||
}, 180_000);
|
||||
|
||||
it('aborting an unknown upload stays a no-op', async () => {
|
||||
const { token } = await setup('write');
|
||||
const res = await post('/fs/abortWrite', token, {
|
||||
uploadId: 'no-such-upload',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
}, 180_000);
|
||||
|
||||
it('keeps the routes that reach past one entry closed', async () => {
|
||||
const { home, token } = await setup('write');
|
||||
const docs = `${home}/Documents`;
|
||||
|
||||
for (const [route, body] of [
|
||||
['/sign', { items: [{ path: `${docs}/in.txt`, action: 'read' }] }],
|
||||
['/search', { text: 'in' }],
|
||||
['/fs/search', { query: 'in' }],
|
||||
['/df', {}],
|
||||
['/readdir-subdomains', {}],
|
||||
] as const) {
|
||||
const res = await post(route, token, body);
|
||||
expect(res.status, `${route} → ${res.status}`).toBe(403);
|
||||
expect(res.json?.message ?? res.text).toMatch(
|
||||
/Access tokens are not allowed/,
|
||||
);
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -1006,7 +1006,7 @@ describe('WebDAVController', () => {
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('rejects delete when ACL denies write access', async () => {
|
||||
it('refuses a delete outside the caller’s tree as not found', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
@@ -1023,7 +1023,7 @@ describe('WebDAVController', () => {
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
expect(captured.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1593,7 +1593,7 @@ describe('WebDAVController verbs', () => {
|
||||
expect(captured.body).toBe('Cannot GET a directory');
|
||||
});
|
||||
|
||||
it("denies reading another user's file with 403", async () => {
|
||||
it("hides another user's file as not found", async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
const path = `/${owner.username}/Documents/private.txt`;
|
||||
@@ -1604,8 +1604,8 @@ describe('WebDAVController verbs', () => {
|
||||
path,
|
||||
actor: stranger.actor,
|
||||
});
|
||||
expect(captured.statusCode).toBe(403);
|
||||
expect(captured.body).toBe('Permission denied');
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.body).toBe('Not Found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1925,7 +1925,8 @@ describe('WebDAVController verbs', () => {
|
||||
path: `/${owner.username}/Documents/intruder`,
|
||||
actor: stranger.actor,
|
||||
});
|
||||
expect(captured.statusCode).toBe(403);
|
||||
// The parent is out of sight, so the refusal doesn't confirm it.
|
||||
expect(captured.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2136,7 +2137,7 @@ describe('WebDAVController verbs', () => {
|
||||
},
|
||||
});
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.body).toBe('Source not found');
|
||||
expect(captured.body).toBe('Not Found');
|
||||
});
|
||||
|
||||
it('returns 409 when the destination parent is not a directory', async () => {
|
||||
@@ -2262,7 +2263,7 @@ describe('WebDAVController verbs', () => {
|
||||
},
|
||||
});
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.body).toBe('Source not found');
|
||||
expect(captured.body).toBe('Not Found');
|
||||
});
|
||||
|
||||
it("denies moving into another user's home", async () => {
|
||||
@@ -2280,7 +2281,7 @@ describe('WebDAVController verbs', () => {
|
||||
host: 'dav.puter.localhost',
|
||||
},
|
||||
});
|
||||
expect(captured.statusCode).toBe(403);
|
||||
expect(captured.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2579,7 +2580,7 @@ describe('WebDAVController verbs', () => {
|
||||
expect(xml).not.toContain('Documents');
|
||||
});
|
||||
|
||||
it('keeps a sharer’s collection a 403 for a caller with no share', async () => {
|
||||
it('keeps a sharer’s collection out of sight for a caller with no share', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
await shareFolder(owner, await makeUser());
|
||||
@@ -2588,7 +2589,8 @@ describe('WebDAVController verbs', () => {
|
||||
`/${owner.username}`,
|
||||
stranger.actor,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
// The same 404 as a name nobody holds, so it confirms no account.
|
||||
expect(captured.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2758,9 +2760,9 @@ describe('WebDAVController verbs', () => {
|
||||
|
||||
const root = await propfind('/', holder.actor);
|
||||
expect(String(root.body)).not.toContain(`/${owner.username}/`);
|
||||
// And the level it used to stand in for is a plain 403 again.
|
||||
// And the level it used to stand in for is out of sight again.
|
||||
const owned = await propfind(`/${owner.username}`, holder.actor);
|
||||
expect(owned.statusCode).toBe(403);
|
||||
expect(owned.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses writes and reads aimed at the invented levels', async () => {
|
||||
@@ -2809,4 +2811,257 @@ describe('WebDAVController verbs', () => {
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// Every check runs on the path the entry is stored under, and a refusal of
|
||||
// something the caller can't see reads exactly like a path with nothing
|
||||
// at it.
|
||||
describe('authorization against the stored entry', () => {
|
||||
it('answers a stranger alike for a missing path and a private file', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
await putFile(
|
||||
owner.actor,
|
||||
`/${owner.username}/Documents/private.txt`,
|
||||
'secret',
|
||||
);
|
||||
|
||||
const answers = [];
|
||||
for (const [method, path] of [
|
||||
['GET', `/${owner.username}/Documents/private.txt`],
|
||||
['GET', `/${owner.username}/Documents/missing.txt`],
|
||||
// A directory too: refused before the "is a directory" 400.
|
||||
['GET', `/${owner.username}/Documents`],
|
||||
['PROPFIND', `/${owner.username}`],
|
||||
['PROPFIND', `/no-such-user-${uuidv4()}`],
|
||||
['DELETE', `/${owner.username}/Documents/private.txt`],
|
||||
['DELETE', `/${owner.username}/Documents/missing.txt`],
|
||||
] as const) {
|
||||
const captured = await dispatch({
|
||||
method,
|
||||
path,
|
||||
actor: stranger.actor,
|
||||
});
|
||||
answers.push([captured.statusCode, captured.body]);
|
||||
}
|
||||
for (const answer of answers) {
|
||||
expect(answer).toEqual([404, 'Not Found']);
|
||||
}
|
||||
});
|
||||
|
||||
// MySQL collates `fsentries.path` case-insensitively, so a request for
|
||||
// `/<owner>/Public/...` can resolve a private `/<owner>/public/...`.
|
||||
// The test databases compare exactly, so the lookup is made to answer
|
||||
// the way MySQL would; everything past it — the ancestor chain, the
|
||||
// ACL — runs for real.
|
||||
it('does not pass a case variant of Public off as the public folder', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
await server.stores.user.update(owner.userId, {
|
||||
email_confirmed: 1,
|
||||
});
|
||||
const privateDir = `/${owner.username}/public`;
|
||||
const mkcol = await dispatch({
|
||||
method: 'MKCOL',
|
||||
path: privateDir,
|
||||
actor: owner.actor,
|
||||
});
|
||||
expect(mkcol.statusCode).toBe(201);
|
||||
await putFile(owner.actor, `${privateDir}/secret.txt`, 'secret');
|
||||
await putFile(
|
||||
owner.actor,
|
||||
`/${owner.username}/Public/open.txt`,
|
||||
'open',
|
||||
);
|
||||
|
||||
const acl = server.services.acl as unknown as {
|
||||
config: { enable_public_folders?: boolean };
|
||||
};
|
||||
const wasEnabled = acl.config.enable_public_folders;
|
||||
acl.config.enable_public_folders = true;
|
||||
try {
|
||||
// The real Public folder is open to the stranger.
|
||||
const open = await dispatch({
|
||||
method: 'GET',
|
||||
path: `/${owner.username}/Public/open.txt`,
|
||||
actor: stranger.actor,
|
||||
});
|
||||
expect(open.statusCode).toBe(200);
|
||||
|
||||
const store = server.stores.fsEntry;
|
||||
const realLookup = store.getEntryByPath.bind(store);
|
||||
const asked = `/${owner.username}/Public`;
|
||||
vi.spyOn(store, 'getEntryByPath').mockImplementation(
|
||||
(path: string) =>
|
||||
realLookup(
|
||||
path === asked || path.startsWith(`${asked}/`)
|
||||
? privateDir + path.slice(asked.length)
|
||||
: path,
|
||||
),
|
||||
);
|
||||
|
||||
const { res, captured } = makeStreamRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'GET',
|
||||
path: `${asked}/secret.txt`,
|
||||
actor: stranger.actor,
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(captured.text()).toBe('');
|
||||
|
||||
const listing = await dispatch({
|
||||
method: 'PROPFIND',
|
||||
path: asked,
|
||||
actor: stranger.actor,
|
||||
});
|
||||
expect(listing.statusCode).toBe(404);
|
||||
expect(String(listing.body)).not.toContain('secret.txt');
|
||||
|
||||
const copied = await dispatch({
|
||||
method: 'COPY',
|
||||
path: `${asked}/secret.txt`,
|
||||
actor: stranger.actor,
|
||||
headers: {
|
||||
destination: `/${stranger.username}/Documents/loot.txt`,
|
||||
host: 'dav.puter.localhost',
|
||||
},
|
||||
});
|
||||
expect(copied.statusCode).toBe(404);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
acl.config.enable_public_folders = wasEnabled;
|
||||
}
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(
|
||||
`/${stranger.username}/Documents/loot.txt`,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// A scoped token writes over DAV wherever its grant reaches, and nowhere
|
||||
// else — the same reach it has over the `/fs` write routes.
|
||||
describe('scoped access tokens', () => {
|
||||
const mintFor = async (userId: number, permission: string) => {
|
||||
const user = (await server.stores.user.getById(userId))!;
|
||||
return server.services.auth.createAccessToken(makeActor({ user }), [
|
||||
[permission],
|
||||
]);
|
||||
};
|
||||
// Own address, so the per-IP auth failure bucket can't refuse these.
|
||||
const asToken = (token: string, init: Parameters<typeof dispatch>[0]) =>
|
||||
dispatch({
|
||||
...init,
|
||||
ip: '203.0.113.58',
|
||||
headers: {
|
||||
...init.headers,
|
||||
authorization: basicAuth('-token', token),
|
||||
},
|
||||
});
|
||||
|
||||
it('writes inside its grant and is refused outside it', async () => {
|
||||
const { userId, username, actor } = await makeUser();
|
||||
const documents = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
))!;
|
||||
const token = await mintFor(userId, `fs:${documents.uuid}:write`);
|
||||
const inside = `/${username}/Documents/tok`;
|
||||
|
||||
expect(
|
||||
(await asToken(token, { method: 'MKCOL', path: inside }))
|
||||
.statusCode,
|
||||
).toBe(201);
|
||||
expect(
|
||||
(
|
||||
await asToken(token, {
|
||||
method: 'PUT',
|
||||
path: `${inside}/a.txt`,
|
||||
headers: { 'content-length': '3' },
|
||||
content: 'abc',
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(201);
|
||||
expect(
|
||||
(
|
||||
await asToken(token, {
|
||||
method: 'MOVE',
|
||||
path: `${inside}/a.txt`,
|
||||
headers: {
|
||||
destination: `${inside}/b.txt`,
|
||||
host: 'dav.puter.localhost',
|
||||
},
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(201);
|
||||
expect(
|
||||
(
|
||||
await asToken(token, {
|
||||
method: 'DELETE',
|
||||
path: `${inside}/b.txt`,
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(204);
|
||||
|
||||
await putFile(actor, `/${username}/Desktop/kept.txt`, 'kept');
|
||||
for (const init of [
|
||||
{ method: 'MKCOL', path: `/${username}/Desktop/intruder` },
|
||||
{
|
||||
method: 'PUT',
|
||||
path: `/${username}/Desktop/kept.txt`,
|
||||
headers: { 'content-length': '1' },
|
||||
content: 'x',
|
||||
},
|
||||
{ method: 'DELETE', path: `/${username}/Desktop/kept.txt` },
|
||||
{
|
||||
method: 'COPY',
|
||||
path: `/${username}/Desktop/kept.txt`,
|
||||
headers: {
|
||||
destination: `${inside}/kept.txt`,
|
||||
host: 'dav.puter.localhost',
|
||||
},
|
||||
},
|
||||
{ method: 'DELETE', path: `/${username}` },
|
||||
]) {
|
||||
const captured = await asToken(token, init);
|
||||
// Outside the grant is out of sight, so it reads as missing.
|
||||
expect(captured.statusCode, init.method).toBe(404);
|
||||
}
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Desktop/intruder`,
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Desktop/kept.txt`,
|
||||
),
|
||||
).toMatchObject({ size: 4 });
|
||||
});
|
||||
|
||||
it('refuses every write to a read-only token', async () => {
|
||||
const { userId, username } = await makeUser();
|
||||
const documents = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
))!;
|
||||
const token = await mintFor(userId, `fs:${documents.uuid}:read`);
|
||||
|
||||
for (const init of [
|
||||
{ method: 'MKCOL', path: `/${username}/Documents/ro` },
|
||||
{
|
||||
method: 'PUT',
|
||||
path: `/${username}/Documents/ro.txt`,
|
||||
headers: { 'content-length': '1' },
|
||||
content: 'x',
|
||||
},
|
||||
{ method: 'DELETE', path: `/${username}/Documents` },
|
||||
]) {
|
||||
const captured = await asToken(token, init);
|
||||
expect(captured.statusCode, init.method).toBe(403);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -514,15 +514,15 @@ export class WebDAVController extends PuterController {
|
||||
headOnly: boolean,
|
||||
): Promise<void> {
|
||||
const entry = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (!entry)
|
||||
throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' });
|
||||
if (!entry) throw davNotFound();
|
||||
// Authorized before the directory check, so a caller who can't see
|
||||
// the entry can't learn that it is one.
|
||||
await this.#assertAccess(actor, entry.path, 'read');
|
||||
if (entry.isDir)
|
||||
throw new HttpError(400, 'Cannot GET a directory', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
await this.#assertRead(actor, davPath);
|
||||
|
||||
const modified = entry.modified ?? entry.created ?? 0;
|
||||
const etag = entryEtag(entry.uuid, modified);
|
||||
const size = entry.size ?? 0;
|
||||
@@ -568,7 +568,7 @@ export class WebDAVController extends PuterController {
|
||||
const depth = req.headers.depth ?? '1';
|
||||
|
||||
if (davPath === '/') {
|
||||
await this.#assertRead(actor, davPath);
|
||||
await this.#assertAccess(actor, davPath, 'read');
|
||||
this.#sendMultistatus(res, await this.#rootPropfind(actor, depth));
|
||||
return;
|
||||
}
|
||||
@@ -590,11 +590,9 @@ export class WebDAVController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' });
|
||||
}
|
||||
if (!entry) throw davNotFound();
|
||||
|
||||
await this.#assertRead(actor, davPath);
|
||||
await this.#assertAccess(actor, entry.path, 'read');
|
||||
|
||||
// `davPath` is the resolved real path; the href has to be the masked
|
||||
// one, like every child below it, or the self-entry names the owner's
|
||||
@@ -696,7 +694,7 @@ export class WebDAVController extends PuterController {
|
||||
if (!entry?.isDir) return null;
|
||||
// An owner who shared their whole home directory has a real listing
|
||||
// here, and it beats the virtual one.
|
||||
if (await this.#canRead(actor, davPath)) return null;
|
||||
if (await this.#canAccess(actor, entry.path, 'read')) return null;
|
||||
|
||||
const shares = (await this.#sharedWithMe(actor)).filter(
|
||||
(share) => share.owner?.username === owner,
|
||||
@@ -787,8 +785,7 @@ export class WebDAVController extends PuterController {
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
}
|
||||
const userId = actor.user!.id as number;
|
||||
const parentPath = pathPosix.dirname(davPath);
|
||||
await this.#assertWrite(actor, parentPath);
|
||||
await this.#assertCreate(actor, davPath);
|
||||
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (existing)
|
||||
@@ -824,8 +821,11 @@ export class WebDAVController extends PuterController {
|
||||
}
|
||||
|
||||
const userId = actor.user!.id as number;
|
||||
const parentPath = pathPosix.dirname(davPath);
|
||||
await this.#assertWrite(actor, parentPath);
|
||||
// Overwriting takes write on what is there, as `/fs/write` does;
|
||||
// creating takes write on the parent.
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (existing) await this.#assertAccess(actor, existing.path, 'write');
|
||||
else await this.#assertCreate(actor, davPath);
|
||||
|
||||
const contentLength = Number(
|
||||
req.headers['content-length'] ??
|
||||
@@ -837,9 +837,6 @@ export class WebDAVController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
// Check if overwrite
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
|
||||
// Expect: 100-continue
|
||||
if (req.headers.expect === '100-continue') {
|
||||
(req.socket as { write?: (s: string) => void }).write?.(
|
||||
@@ -884,11 +881,9 @@ export class WebDAVController extends PuterController {
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
}
|
||||
const userId = actor.user!.id as number;
|
||||
await this.#assertWrite(actor, davPath);
|
||||
|
||||
const entry = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (!entry)
|
||||
throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' });
|
||||
if (!entry) throw davNotFound();
|
||||
await this.#assertAccess(actor, entry.path, 'write');
|
||||
|
||||
await this.services.fs.remove(userId, { entry, recursive: true });
|
||||
this.#emitGuiEvent('outer.gui.item.removed', entry);
|
||||
@@ -910,14 +905,10 @@ export class WebDAVController extends PuterController {
|
||||
}
|
||||
|
||||
const userId = actor.user!.id as number;
|
||||
await this.#assertRead(actor, davPath);
|
||||
await this.#assertWrite(actor, pathPosix.dirname(destPath));
|
||||
|
||||
const source = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (!source)
|
||||
throw new HttpError(404, 'Source not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
if (!source) throw davNotFound();
|
||||
await this.#assertAccess(actor, source.path, 'read');
|
||||
await this.#assertCreate(actor, destPath);
|
||||
|
||||
const overwrite = req.headers.overwrite !== 'F';
|
||||
const destExists = await this.stores.fsEntry.getEntryByPath(destPath);
|
||||
@@ -962,14 +953,10 @@ export class WebDAVController extends PuterController {
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
|
||||
const userId = actor.user!.id as number;
|
||||
await this.#assertWrite(actor, davPath);
|
||||
await this.#assertWrite(actor, pathPosix.dirname(destPath));
|
||||
|
||||
const source = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (!source)
|
||||
throw new HttpError(404, 'Source not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
if (!source) throw davNotFound();
|
||||
await this.#assertAccess(actor, source.path, 'write');
|
||||
await this.#assertCreate(actor, destPath);
|
||||
|
||||
const overwrite = req.headers.overwrite !== 'F';
|
||||
const destExists = await this.stores.fsEntry.getEntryByPath(destPath);
|
||||
@@ -1014,8 +1001,11 @@ export class WebDAVController extends PuterController {
|
||||
|
||||
// ACL must succeed before any lock state is touched — otherwise
|
||||
// an authenticated user could lock paths they don't own (e.g. `/`)
|
||||
// and block writes for everyone else.
|
||||
await this.#assertWrite(actor, davPath);
|
||||
// and block writes for everyone else. A path with nothing at it yet
|
||||
// is a lock-null resource, locked ahead of its create.
|
||||
const target = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (target) await this.#assertAccess(actor, target.path, 'write');
|
||||
else await this.#assertCreate(actor, davPath);
|
||||
|
||||
const timeoutSeconds = parseLockTimeout(
|
||||
req.headers.timeout as string | undefined,
|
||||
@@ -1148,25 +1138,46 @@ export class WebDAVController extends PuterController {
|
||||
|
||||
// -- ACL helpers -------------------------------------------------
|
||||
|
||||
async #canRead(actor: Actor, path: string): Promise<boolean> {
|
||||
// Checks take the path an entry is stored under, never the request's
|
||||
// spelling of it: the path lookup can match a differently-cased row (MySQL
|
||||
// collates `fsentries.path` case-insensitively), while the ACL's path rules
|
||||
// — home, Public — compare exactly. Only a create, whose target has no row
|
||||
// yet, falls back to a requested path.
|
||||
|
||||
async #canAccess(
|
||||
actor: Actor,
|
||||
path: string,
|
||||
mode: 'read' | 'write',
|
||||
): Promise<boolean> {
|
||||
const acl = this.services.acl;
|
||||
return await acl.check(actor, acl.fsDescriptor(path), 'read');
|
||||
return await acl.check(actor, acl.fsDescriptor(path), mode);
|
||||
}
|
||||
|
||||
async #assertRead(actor: Actor, path: string): Promise<void> {
|
||||
if (!(await this.#canRead(actor, path)))
|
||||
throw new HttpError(403, 'Permission denied', {
|
||||
legacyCode: 'permission_denied',
|
||||
});
|
||||
/**
|
||||
* Refused the way `/fs` refuses: a caller who can't even see the target
|
||||
* gets the same 404 as for a path with nothing at it, so a refusal never
|
||||
* confirms that something exists.
|
||||
*/
|
||||
async #assertAccess(
|
||||
actor: Actor,
|
||||
path: string,
|
||||
mode: 'read' | 'write',
|
||||
): Promise<void> {
|
||||
const acl = this.services.acl;
|
||||
const descriptor = acl.fsDescriptor(path);
|
||||
if (await acl.check(actor, descriptor, mode)) return;
|
||||
const safe = await acl.getSafeAclError(actor, descriptor, mode);
|
||||
if (safe.status === 404) throw davNotFound();
|
||||
throw new HttpError(403, 'Permission denied', {
|
||||
legacyCode: 'permission_denied',
|
||||
});
|
||||
}
|
||||
|
||||
async #assertWrite(actor: Actor, path: string): Promise<void> {
|
||||
const acl = this.services.acl;
|
||||
const ok = await acl.check(actor, acl.fsDescriptor(path), 'write');
|
||||
if (!ok)
|
||||
throw new HttpError(403, 'Permission denied', {
|
||||
legacyCode: 'permission_denied',
|
||||
});
|
||||
/** Write on the parent of a path about to be created at `targetPath`. */
|
||||
async #assertCreate(actor: Actor, targetPath: string): Promise<void> {
|
||||
const parentPath = pathPosix.dirname(targetPath);
|
||||
const parent = await this.stores.fsEntry.getEntryByPath(parentPath);
|
||||
await this.#assertAccess(actor, parent?.path ?? parentPath, 'write');
|
||||
}
|
||||
|
||||
// -- Event emission ----------------------------------------------
|
||||
@@ -1260,6 +1271,11 @@ function sendDavError(res: Response, err: unknown): void {
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
|
||||
/** Nothing there — or nothing the caller may know is there; they read alike. */
|
||||
function davNotFound(): HttpError {
|
||||
return new HttpError(404, 'Not Found', { legacyCode: 'not_found' });
|
||||
}
|
||||
|
||||
// -- XML helpers ------------------------------------------------------
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
|
||||
@@ -552,6 +552,40 @@ describe('ACLService.check — public folders', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('stays closed for a case variant that resolved a private folder', async () => {
|
||||
const { service, stores } = publicService();
|
||||
stores.user.getByUsername.mockResolvedValue({
|
||||
username: 'owner',
|
||||
email_confirmed: true,
|
||||
});
|
||||
// What MySQL hands back for `/owner/Public/...` when the owner's
|
||||
// folder is stored as `public`: the lookup collates case-insensitively.
|
||||
const variant: ResourceDescriptor = {
|
||||
path: '/owner/Public/secret.txt',
|
||||
resolveAncestors: async () => [
|
||||
{ uid: 'uid:secret', path: '/owner/public/secret.txt' },
|
||||
{ uid: 'uid:public', path: '/owner/public' },
|
||||
{ uid: 'uid:owner', path: '/owner' },
|
||||
],
|
||||
};
|
||||
expect(await service.check(issuerActor, variant, 'read')).toBe(false);
|
||||
});
|
||||
|
||||
it('stays closed when no Public folder is stored under the path', async () => {
|
||||
const { service, stores } = publicService();
|
||||
stores.user.getByUsername.mockResolvedValue({
|
||||
username: 'owner',
|
||||
email_confirmed: true,
|
||||
});
|
||||
const orphan: ResourceDescriptor = {
|
||||
path: '/owner/Public/index.html',
|
||||
resolveAncestors: async () => [
|
||||
{ uid: 'uid:owner', path: '/owner' },
|
||||
],
|
||||
};
|
||||
expect(await service.check(issuerActor, orphan, 'read')).toBe(false);
|
||||
});
|
||||
|
||||
it('stays closed when the feature flag is off', async () => {
|
||||
const { service, stores } = makeService();
|
||||
stores.user.getByUsername.mockResolvedValue({
|
||||
|
||||
@@ -220,12 +220,18 @@ export class ACLService extends PuterService {
|
||||
}
|
||||
|
||||
// Public folders: /<user>/Public with read-ish mode, owner must have
|
||||
// confirmed email (or be admin).
|
||||
// confirmed email (or be admin). The folder has to be stored under
|
||||
// exactly that name: a path lookup collates case-insensitively, so
|
||||
// `/<user>/Public/...` can resolve a private `/<user>/public`, and the
|
||||
// asked path alone would pass it as public.
|
||||
if (
|
||||
this.config.enable_public_folders &&
|
||||
(PUBLIC_READ_MODES as AclMode[]).includes(mode) &&
|
||||
components.length > 1 &&
|
||||
components[1] === 'Public'
|
||||
components[1] === 'Public' &&
|
||||
ancestors.some(
|
||||
(ancestor) => ancestor.path === `/${components[0]}/Public`,
|
||||
)
|
||||
) {
|
||||
const ownerUsername = components[0];
|
||||
const owner = await this.stores.user.getByUsername(ownerUsername);
|
||||
|
||||
Reference in new issue
Block a user