Repository Public
1import { randomBytes } from 'node:crypto';2import { afterAll, beforeAll, describe, expect, it } from 'vitest';3import { sql } from '../src/db.ts';4import { redactUrl } from '../src/server.ts';5import { anon, Client, closeAll, sha, signup } from './harness.ts';67afterAll(closeAll);8let a: Awaited<ReturnType<typeof signup>>, b: Awaited<ReturnType<typeof signup>>;9let docs: string, sub: string, outside: string, f1: any, f2: any, secret: any;10const data1 = randomBytes(300_000);11beforeAll(async () => {12 a = await signup(); b = await signup();13 docs = (await a.client.post('/api/v1/folders', { parentId: a.rootId, name: 'Partagé' })).body.id;14 sub = (await a.client.post('/api/v1/folders', { parentId: docs, name: 'Sous' })).body.id;15 outside = (await a.client.post('/api/v1/folders', { parentId: a.rootId, name: 'Privé' })).body.id;16 f1 = (await a.client.upload(docs, 'contrat.pdf', data1)).body.file;17 f2 = (await a.client.upload(sub, 'notes.txt', Buffer.from('notes partagées'))).body.file;18 secret = (await a.client.upload(outside, 'secret.txt', Buffer.from('jamais partagé'))).body.file;19});20const tokenOf = (url: string) => url.split('/s/')[1]!;21const share = async (body: Record<string, unknown>) => { const r = await a.client.post('/api/v1/shares', body); expect(r.status).toBe(200); return { id: r.body.share.id as string, token: tokenOf(r.body.url) }; };22const open = async (token: string, password?: string) => { const c = anon(); const r = await c.post(`/api/v1/public/shares/${token}/open`, password ? { password } : {}); return { c, r }; };23const P = '/api/v1/public/shares';2425describe('share links', () => {26 it('stores only a hash of the token and never returns the link again', async () => {27 const s = await share({ fileId: f1.id });28 expect(s.token).toMatch(/^[A-Za-z0-9_-]{43}$/);29 const rows = await sql<{ token_hash: string }[]>`select token_hash from shares where id = ${s.id}`;30 expect(rows[0]!.token_hash).not.toContain(s.token);31 expect(JSON.stringify((await a.client.get('/api/v1/shares')).body)).not.toContain(s.token);32 expect(redactUrl(`/api/v1/public/shares/${s.token}/open`)).toBe('/api/v1/public/shares/[redacted]/open');33 expect(redactUrl(`/s/${s.token}`)).toBe('/s/[redacted]'); expect(redactUrl(`/request/${s.token}?x=1`)).toBe('/request/[redacted]?x=1');34 });35 it('serves a shared file to an anonymous visitor only after the link is opened', async () => {36 const s = await share({ fileId: f1.id });37 expect((await anon().get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(401); // no grant yet38 const { c, r } = await open(s.token);39 expect(r.body).toMatchObject({ type: 'file', canDownload: true, file: { name: 'contrat.pdf', size: data1.length } });40 expect(JSON.stringify(r.body)).not.toMatch(/storage_key|wrapped|owner_id|folder_id|sha256/);41 const dl = await c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`);42 expect(dl.raw.equals(data1)).toBe(true);43 // the grant cookie is scoped and hardened44 const cookie = [...c.cookies.keys()][0]!;45 expect(cookie.startsWith('spd_g_')).toBe(true);46 // the same link gives access to nothing else, by any identifier47 for (const id of [f2.id, secret.id]) expect((await c.get(`${P}/${s.token}/files/${id}/content`)).status).toBe(404);48 expect((await c.get(`${P}/${s.token}/list`)).status).toBe(404);49 expect((await c.get(`${P}/${s.token}/zip`)).status).toBe(404);50 // and it does not make the visitor a user51 expect((await c.get(`/api/v1/files/${f1.id}/content`)).status).toBe(401);52 expect((await c.get('/api/v1/folders/root')).status).toBe(401);53 const events = (await a.client.get(`/api/v1/shares/${s.id}/events`)).body;54 expect(events.map((e: any) => `${e.kind}:${e.ok}`).sort()).toEqual(['download:true', 'open:true']);55 expect((await b.client.get(`/api/v1/shares/${s.id}/events`)).status).toBe(404);56 });57 it('revocation is immediate, even for a visitor in the middle of a session', async () => {58 const s = await share({ folderId: docs });59 const { c } = await open(s.token);60 expect((await c.get(`${P}/${s.token}/list`)).status).toBe(200);61 expect((await a.client.del(`/api/v1/shares/${s.id}`)).status).toBe(200);62 for (const url of [`${P}/${s.token}/list`, `${P}/${s.token}/files/${f1.id}/content`, `${P}/${s.token}/files/${f1.id}/content?download=1`, `${P}/${s.token}/zip`, `${P}/${s.token}`])63 expect([url, (await c.get(url)).status]).toEqual([url, 404]);64 expect((await open(s.token)).r.status).toBe(404);65 });66 it('confines a folder link to the folder: listing, files, breadcrumbs and ZIP', async () => {67 const s = await share({ folderId: docs });68 const { c, r } = await open(s.token);69 expect(r.body).toMatchObject({ type: 'folder', root: { name: 'Partagé' } });70 const top = (await c.get(`${P}/${s.token}/list`)).body;71 expect(top.files.map((f: any) => f.name)).toEqual(['contrat.pdf']); expect(top.folders.map((f: any) => f.name)).toEqual(['Sous']);72 const inner = (await c.get(`${P}/${s.token}/list?folder=${sub}`)).body;73 expect(inner.breadcrumbs.map((x: any) => x.name)).toEqual(['Partagé', 'Sous']); // never above the shared root74 expect((await c.get(`${P}/${s.token}/files/${f2.id}/content?as=text`)).raw.toString()).toBe('notes partagées');75 // escapes76 expect((await c.get(`${P}/${s.token}/list?folder=${a.rootId}`)).status).toBe(404);77 expect((await c.get(`${P}/${s.token}/list?folder=${outside}`)).status).toBe(404);78 expect((await c.get(`${P}/${s.token}/files/${secret.id}/content`)).status).toBe(404);79 expect((await c.get(`${P}/${s.token}/zip?folder=${a.rootId}`)).status).toBe(404);80 const other = (await b.client.upload(b.rootId, 'b.txt', Buffer.from('b'))).body.file;81 expect((await c.get(`${P}/${s.token}/files/${other.id}/content`)).status).toBe(404);82 const zip = await c.get(`${P}/${s.token}/zip`);83 expect(zip.status).toBe(200); expect(zip.raw.includes(Buffer.from('Sous/notes.txt'))).toBe(true); expect(zip.raw.includes(Buffer.from('secret'))).toBe(false);84 // a file moved out of the shared folder leaves the share at once; trashed content too85 await a.client.post('/api/v1/move', { fileIds: [f2.id], destinationId: outside });86 expect((await c.get(`${P}/${s.token}/files/${f2.id}/content?as=text`)).status).toBe(404);87 await a.client.post('/api/v1/move', { fileIds: [f2.id], destinationId: sub });88 await a.client.post('/api/v1/trash', { folderIds: [sub] });89 expect((await c.get(`${P}/${s.token}/files/${f2.id}/content?as=text`)).status).toBe(404);90 expect((await c.get(`${P}/${s.token}/list?folder=${sub}`)).status).toBe(404);91 await a.client.post('/api/v1/restore', { folderIds: [sub] });92 expect((await c.get(`${P}/${s.token}/files/${f2.id}/content?as=text`)).status).toBe(200);93 // trashing the shared folder itself kills the link until it is restored94 await a.client.post('/api/v1/trash', { folderIds: [docs] });95 expect((await c.get(`${P}/${s.token}/list`)).status).toBe(404);96 await a.client.post('/api/v1/restore', { folderIds: [docs] });97 expect((await c.get(`${P}/${s.token}/list`)).status).toBe(200);98 });99 it('protects with a hashed password and slows down guessing', async () => {100 const s = await share({ fileId: f1.id, password: 'correct horse' });101 const [row] = await sql<{ password_hash: string }[]>`select password_hash from shares where id = ${s.id}`;102 expect(row!.password_hash.startsWith('scrypt$')).toBe(true); expect(row!.password_hash).not.toContain('correct');103 expect((await anon().get(`${P}/${s.token}`)).body).toEqual({ needsPassword: true });104 expect((await open(s.token)).r.body.error).toBe('password_required');105 expect((await open(s.token, 'wrong')).r.body.error).toBe('password_invalid');106 const { c, r } = await open(s.token, 'correct horse');107 expect(r.status).toBe(200);108 expect((await c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(200);109 // changing the password invalidates grants already given110 await a.client.patch(`/api/v1/shares/${s.id}`, { password: 'another one' });111 expect((await c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(401);112 // brute force: after 20 failures the share refuses even the right password for a while, whatever the address113 await sql`insert into share_access_events (share_id, kind, ok) select ${s.id}, 'password', false from generate_series(1, 19)`;114 const late = anon(); late.ip = '10.7.7.7';115 expect((await late.post(`${P}/${s.token}/open`, { password: 'another one' })).status).toBe(429);116 });117 it('expires, including in the middle of a visit', async () => {118 const s = await share({ fileId: f1.id, expiresAt: new Date(Date.now() + 3600_000).toISOString() });119 const { c } = await open(s.token);120 expect((await c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(200);121 await sql`update shares set expires_at = now() - interval '1 second' where id = ${s.id}`;122 expect((await c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(404);123 expect((await open(s.token)).r.status).toBe(404);124 expect((await a.client.get('/api/v1/shares')).body.find((x: any) => x.id === s.id).status).toBe('expired');125 expect((await a.client.post('/api/v1/shares', { fileId: f1.id, expiresAt: new Date(Date.now() - 1000).toISOString() })).body.error).toBe('bad_expiry');126 });127 it('counts views and downloads atomically against their limits', async () => {128 const s = await share({ fileId: f1.id, maxViews: 3, maxDownloads: 2 });129 const opens = await Promise.all(Array.from({ length: 8 }, () => open(s.token)));130 expect(opens.filter((o) => o.r.status === 200)).toHaveLength(3);131 const c = opens.find((o) => o.r.status === 200)!.c;132 const dls = await Promise.all(Array.from({ length: 6 }, () => c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)));133 expect(dls.filter((d) => d.status === 200)).toHaveLength(2);134 expect(dls.filter((d) => d.status === 403)).toHaveLength(4);135 const st = (await a.client.get('/api/v1/shares')).body.find((x: any) => x.id === s.id);136 expect(st).toMatchObject({ viewCount: 3, downloadCount: 2, status: 'exhausted' });137 // resuming a download with Range does not burn another download138 const s2 = await share({ fileId: f1.id, maxDownloads: 1 });139 const v = (await open(s2.token)).c;140 expect((await v.get(`${P}/${s2.token}/files/${f1.id}/content?download=1`, { range: 'bytes=0-99' })).status).toBe(206);141 expect((await v.get(`${P}/${s2.token}/files/${f1.id}/content?download=1`, { range: 'bytes=100-199' })).status).toBe(206);142 expect((await v.get(`${P}/${s2.token}/files/${f1.id}/content?download=1`)).status).toBe(403);143 });144 it('single-use links open once', async () => {145 const s = await share({ fileId: f1.id, singleUse: true });146 const first = await open(s.token);147 expect(first.r.status).toBe(200);148 expect((await open(s.token)).r.status).toBe(404);149 expect((await first.c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(200); // the one visitor can still take the file150 });151 it('view-only links never send an attachment nor a format the viewer cannot display', async () => {152 const bin = (await a.client.upload(docs, 'données.bin', randomBytes(100))).body.file;153 const s = await share({ folderId: docs, canDownload: false });154 const { c, r } = await open(s.token);155 expect(r.body.canDownload).toBe(false);156 expect((await c.get(`${P}/${s.token}/files/${f1.id}/content?download=1`)).status).toBe(403);157 expect((await c.get(`${P}/${s.token}/zip`)).status).toBe(403);158 const view = await c.get(`${P}/${s.token}/files/${f1.id}/content`);159 expect(view.status).toBe(200); expect(view.headers['content-disposition']).toContain('inline'); expect(view.headers['content-type']).toBe('application/pdf');160 expect((await c.get(`${P}/${s.token}/files/${bin.id}/content`)).status).toBe(404);161 });162 it('rotating a link kills the old one; a link cannot be made for the root, for foreign items or for infected files', async () => {163 const s = await share({ fileId: f1.id });164 const rotated = await a.client.post(`/api/v1/shares/${s.id}/rotate`);165 expect((await open(s.token)).r.status).toBe(404);166 expect((await open(tokenOf(rotated.body.url))).r.status).toBe(200);167 expect((await a.client.post('/api/v1/shares', { folderId: a.rootId })).status).toBe(400);168 expect((await a.client.post('/api/v1/shares', { fileId: f1.id, folderId: docs })).status).toBe(400);169 expect((await b.client.post(`/api/v1/shares/${s.id}/rotate`)).status).toBe(404);170 expect((await b.client.patch(`/api/v1/shares/${s.id}`, { label: 'x' })).status).toBe(404);171 expect((await b.client.del(`/api/v1/shares/${s.id}`)).status).toBe(404);172 // an infected file: existing links stop, new ones are refused173 const live = await open(tokenOf(rotated.body.url));174 await sql`update storage_objects set scan_status = 'infected' where sha256 = ${sha(data1)}`;175 expect((await live.c.get(`${P}/${tokenOf(rotated.body.url)}/files/${f1.id}/content?download=1`)).status).toBe(404);176 expect((await a.client.post('/api/v1/shares', { fileId: f1.id })).body.error).toBe('infected');177 expect((await a.client.get(`/api/v1/files/${f1.id}/content`)).status).toBe(451);178 expect((await a.client.get(`/api/v1/files/${f1.id}/content?download=1&infected=ack`)).status).toBe(200);179 await sql`update storage_objects set scan_status = 'clean' where sha256 = ${sha(data1)}`;180 });181 it('gives nothing to malformed or guessed tokens and does not log tokens', async () => {182 for (const t of ['x', 'a'.repeat(43), '../../etc/passwd', `${'a'.repeat(42)}%00`]) expect((await anon().get(`${P}/${encodeURIComponent(t)}`)).status).toBe(404);183 const rows = await sql<{ t: string }[]>`select row_to_json(e)::text as t from share_access_events e union all select row_to_json(x)::text from audit_events x`;184 const s = await share({ fileId: f1.id });185 await open(s.token);186 const all = (await sql<{ t: string }[]>`select row_to_json(e)::text as t from share_access_events e union all select row_to_json(x)::text from audit_events x`).map((r) => r.t).join('\n');187 expect(all).not.toContain(s.token); expect(rows.length).toBeGreaterThan(0);188 });189});190