26952ae0d9
- POST /api/me/avatar/url stores the https link (users.avatar_link) instead of downloading it; GET /api/me/avatar and /api/users/:id/avatar answer 302 to it (no referrer). Link rules: https, no userinfo, <= 2048 chars, no localhost or private/reserved IP literal. Upload and link replace each other. - Settings -> Account first loads the link as an embedded image and refuses one this browser cannot show: a picture behind a login (Mattermost's /api/v4/users/<id>/image) gets no cookie cross-site and 401s. - Files sidebar: each workspace under its owner's home, chevrons remembered per browser (agenthub.files.homesOpen); ownerless ones stay flat.
226 lines
8.6 KiB
JavaScript
226 lines
8.6 KiB
JavaScript
/**
|
|
* @file me-avatar.test.js
|
|
* @description Settings → Account's avatar (`/api/me/avatar`,
|
|
* server/lib/avatars.js): an account sets, reads and removes its own picture;
|
|
* only PNG/JPEG/GIF/WebP by magic bytes (an SVG is refused), at most 1 MB,
|
|
* served with `nosniff` and a `sandbox` CSP; `avatarAt` reaches `/api/auth/me`;
|
|
* another account never sees it through `/api/me`; staff read it at
|
|
* `/api/users/:id/avatar` (a developer is refused) and a workspace carries
|
|
* `owner_avatar_at` for the sidebar's owner groups. A picture from a link
|
|
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) is stored, never
|
|
* downloaded, and the avatar routes redirect to it; https only, never a
|
|
* loopback/private IP literal.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("node:path");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "me-avatar-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
process.env.DASHBOARD_DATA_DIR = TMP;
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const auth = require("../lib/auth");
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
const PNG = Buffer.concat([
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
Buffer.alloc(32, 1),
|
|
]);
|
|
const JPG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(32, 2)]);
|
|
|
|
async function login(username, password) {
|
|
const res = await fetch(BASE + "/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
return res.headers.get("set-cookie").split(";")[0];
|
|
}
|
|
|
|
const put = (cookie, body) =>
|
|
fetch(BASE + "/api/me/avatar", {
|
|
method: "PUT",
|
|
headers: { cookie, "Content-Type": "application/octet-stream" },
|
|
body,
|
|
});
|
|
|
|
before(async () => {
|
|
process.env.AGENTHUB_MODE = "server";
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
await auth.createUser("ava", "ava-pass-1", "developer");
|
|
await auth.createUser("bob", "bob-pass-1", "developer");
|
|
await auth.createUser("ann", "ann-pass-1", "analyst");
|
|
});
|
|
|
|
after(() => {
|
|
delete process.env.AGENTHUB_MODE;
|
|
server?.close();
|
|
fs.rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
it("sets, serves, replaces and removes the account's own avatar", async () => {
|
|
const ava = await login("ava", "ava-pass-1");
|
|
const bob = await login("bob", "bob-pass-1");
|
|
assert.equal((await fetch(BASE + "/api/me/avatar", { headers: { cookie: ava } })).status, 404);
|
|
|
|
const set = await put(ava, PNG);
|
|
assert.equal(set.status, 200);
|
|
const at = (await set.json()).user.avatarAt;
|
|
assert.ok(at);
|
|
const me = await (await fetch(BASE + "/api/auth/me", { headers: { cookie: ava } })).json();
|
|
assert.equal(me.user.avatarAt, at);
|
|
|
|
const got = await fetch(BASE + "/api/me/avatar", { headers: { cookie: ava } });
|
|
assert.equal(got.status, 200);
|
|
assert.equal(got.headers.get("content-type"), "image/png");
|
|
assert.equal(got.headers.get("x-content-type-options"), "nosniff");
|
|
assert.match(got.headers.get("content-security-policy"), /sandbox/);
|
|
assert.deepEqual(Buffer.from(await got.arrayBuffer()), PNG);
|
|
// Another account sees only its own (none).
|
|
assert.equal((await fetch(BASE + "/api/me/avatar", { headers: { cookie: bob } })).status, 404);
|
|
|
|
// A JPEG replaces the PNG — one file per account.
|
|
assert.equal((await put(ava, JPG)).status, 200);
|
|
const files = fs.readdirSync(path.join(TMP, "avatars"));
|
|
assert.deepEqual(files, [files.find((f) => f.endsWith(".jpg"))]);
|
|
|
|
const del = await fetch(BASE + "/api/me/avatar", { method: "DELETE", headers: { cookie: ava } });
|
|
assert.equal(del.status, 200);
|
|
assert.equal((await del.json()).user.avatarAt, null);
|
|
assert.equal(fs.readdirSync(path.join(TMP, "avatars")).length, 0);
|
|
});
|
|
|
|
it("refuses an SVG, an empty body, a too-large image and no login", async () => {
|
|
const ava = await login("ava", "ava-pass-1");
|
|
const svg = await put(ava, Buffer.from("<svg xmlns='http://www.w3.org/2000/svg'/>"));
|
|
assert.equal(svg.status, 400);
|
|
assert.equal((await svg.json()).error.code, "EBADIMAGE");
|
|
assert.equal((await put(ava, Buffer.alloc(0))).status, 400);
|
|
const big = await put(ava, Buffer.concat([PNG, Buffer.alloc(1024 * 1024)]));
|
|
assert.equal(big.status, 413);
|
|
assert.equal((await big.json()).error.code, "ETOOBIG");
|
|
assert.equal((await put("", PNG)).status, 401);
|
|
});
|
|
|
|
it("a picture from a link is stored and redirected to, never downloaded", async () => {
|
|
const ava = await login("ava", "ava-pass-1");
|
|
const ann = await login("ann", "ann-pass-1");
|
|
const link =
|
|
"https://chat.runsystem.vn/api/v4/users/7bya4wy58bdk3qmb5ks1rgoqkh/image?_=1776312011651";
|
|
assert.equal((await put(ava, PNG)).status, 200);
|
|
const set = await fetch(BASE + "/api/me/avatar/url", {
|
|
method: "POST",
|
|
headers: { cookie: ava, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ url: ` ${link} ` }),
|
|
});
|
|
assert.equal(set.status, 200);
|
|
assert.ok((await set.json()).user.avatarAt);
|
|
// The uploaded file is gone: a link replaces it.
|
|
assert.equal(fs.readdirSync(path.join(TMP, "avatars")).length, 0);
|
|
|
|
const got = await fetch(BASE + "/api/me/avatar", {
|
|
headers: { cookie: ava },
|
|
redirect: "manual",
|
|
});
|
|
assert.equal(got.status, 302);
|
|
assert.equal(got.headers.get("location"), link);
|
|
assert.equal(got.headers.get("referrer-policy"), "no-referrer");
|
|
const id = auth.findUser("ava").id;
|
|
const staff = await fetch(`${BASE}/api/users/${id}/avatar`, {
|
|
headers: { cookie: ann },
|
|
redirect: "manual",
|
|
});
|
|
assert.equal(staff.status, 302);
|
|
assert.equal(staff.headers.get("location"), link);
|
|
|
|
// An upload replaces the link.
|
|
assert.equal((await put(ava, PNG)).status, 200);
|
|
assert.equal(
|
|
(await fetch(BASE + "/api/me/avatar", { headers: { cookie: ava }, redirect: "manual" })).status,
|
|
200
|
|
);
|
|
await fetch(BASE + "/api/me/avatar", { method: "DELETE", headers: { cookie: ava } });
|
|
});
|
|
|
|
it("a picture from a link: https only, never a private or local address", async () => {
|
|
const ava = await login("ava", "ava-pass-1");
|
|
const post = (url) =>
|
|
fetch(BASE + "/api/me/avatar/url", {
|
|
method: "POST",
|
|
headers: { cookie: ava, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ url }),
|
|
});
|
|
for (const [url, code] of [
|
|
["not a url", "EBADURL"],
|
|
["http://example.com/a.png", "EBADURL"],
|
|
["https://user:pw@example.com/a.png", "EBADURL"],
|
|
["https://127.0.0.1/a.png", "EPRIVATEURL"],
|
|
["https://[::1]/a.png", "EPRIVATEURL"],
|
|
["https://localhost/a.png", "EPRIVATEURL"],
|
|
["https://169.254.169.254/latest", "EPRIVATEURL"],
|
|
]) {
|
|
const res = await post(url);
|
|
assert.equal(res.status, 400, url);
|
|
assert.equal((await res.json()).error.code, code, url);
|
|
}
|
|
assert.equal((await post("")).status, 400);
|
|
|
|
const { isPublicAddress } = require("../lib/avatar-url");
|
|
for (const a of [
|
|
"10.1.2.3",
|
|
"172.20.0.1",
|
|
"192.168.1.1",
|
|
"100.64.0.1",
|
|
"0.0.0.0",
|
|
"::",
|
|
"fe80::1",
|
|
"fd00::1",
|
|
"::ffff:127.0.0.1",
|
|
"::ffff:7f00:1",
|
|
"2002:7f00::1",
|
|
"ff02::1",
|
|
]) {
|
|
assert.equal(isPublicAddress(a), false, a);
|
|
}
|
|
for (const a of ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111", "::ffff:8.8.8.8"]) {
|
|
assert.equal(isPublicAddress(a), true, a);
|
|
}
|
|
});
|
|
|
|
it("staff read another account's avatar; workspaces carry owner_avatar_at", async () => {
|
|
const ava = await login("ava", "ava-pass-1");
|
|
const ann = await login("ann", "ann-pass-1");
|
|
const bob = await login("bob", "bob-pass-1");
|
|
const id = auth.findUser("ava").id;
|
|
const { db } = require("../db");
|
|
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/ava', 'claude', ?)").run(id);
|
|
assert.equal((await put(ava, PNG)).status, 200);
|
|
const got = await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: ann } });
|
|
assert.equal(got.status, 200);
|
|
assert.equal(got.headers.get("x-content-type-options"), "nosniff");
|
|
assert.deepEqual(Buffer.from(await got.arrayBuffer()), PNG);
|
|
assert.equal(
|
|
(await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: bob } })).status,
|
|
403
|
|
);
|
|
const lanes = (await (await fetch(BASE + "/api/lanes", { headers: { cookie: ann } })).json())
|
|
.lanes;
|
|
assert.ok(lanes.find((l) => l.cwd === "/w/ava").owner_avatar_at);
|
|
});
|
|
|
|
it("deleting the account deletes its avatar", async () => {
|
|
const bob = await login("bob", "bob-pass-1");
|
|
assert.equal((await put(bob, PNG)).status, 200);
|
|
auth.deleteUser("bob");
|
|
assert.ok(!fs.readdirSync(path.join(TMP, "avatars")).some((f) => f.startsWith("u2.")));
|
|
});
|