pike/glyph Public Public: anyone can read this; nobody can change it.

forked from apps/glyph

workbenchthis machine
Files pike/3-events-leaks-every-user-s-drawings-to-ev
182 lines · 6.3 KB · ~1.8k tok Raw History
1
/**
2
 * The door turns away, before a byte of the body is read, exactly who the
3
 * table says could never pass — and nobody it would let through.
4
 */
5
import { afterAll, expect, test } from "bun:test";
6
import { mkdtempSync } from "node:fs";
7
import { tmpdir } from "node:os";
8
import { join } from "node:path";
9
import { readContract } from "./can.ts";
10
import { openDb } from "./db.ts";
11
import {
12
  allow,
13
  door,
14
  doorOf,
15
  mayKnock,
16
  openToStrangers,
17
  STRANGERS,
18
} from "./security.ts";
19
import { serve } from "./server.ts";
20
import { sign } from "./who.ts";
21
22
const KEY = "a-key-only-the-forge-holds";
23
const server = serve({
24
  port: 0,
25
  dataDir: mkdtempSync(join(tmpdir(), "app-door-")),
26
  identityKey: KEY,
27
});
28
const base = `http://${server.hostname}:${server.port}`;
29
afterAll(() => server.stop(true));
30
const as = (id: string, role: "user" | "admin"): Record<string, string> => {
31
  const identity = `${id}|${id}|${role}|${Date.now() + 60_000}`;
32
  return {
33
    "x-forge-identity": identity,
34
    "x-forge-signature": sign(identity, KEY),
35
  };
36
};
37
38
test("a path is a resource's door by the contract's api, and nothing else is", () => {
39
  expect(doorOf("GET", "/api/drawings")).toEqual({
40
    resource: "drawing",
41
    verb: "list",
42
  });
43
  expect(doorOf("POST", "/api/drawings")).toEqual({
44
    resource: "drawing",
45
    verb: "create",
46
  });
47
  expect(doorOf("GET", "/api/drawings/7")).toEqual({
48
    resource: "drawing",
49
    verb: "read",
50
  });
51
  expect(doorOf("PATCH", "/api/drawings/7")).toEqual({
52
    resource: "drawing",
53
    verb: "update",
54
  });
55
  expect(doorOf("DELETE", "/api/drawings/7")).toEqual({
56
    resource: "drawing",
57
    verb: "delete",
58
  });
59
  expect(doorOf("GET", "/api/audit")).toEqual({
60
    resource: "audit",
61
    verb: "list",
62
  });
63
  for (const [method, path] of [
64
    ["PUT", "/api/drawings"],
65
    ["GET", "/api/drawings/7/x"],
66
    ["GET", "/api/drawingsy"],
67
    ["GET", "/api/drawings/"],
68
    ["POST", "/drawings"],
69
  ] as const)
70
    expect(doorOf(method, path)).toBeUndefined();
71
});
72
73
test("the door refuses only what no row would open", () => {
74
  const anon = { role: "anon" } as const;
75
  const alice = { role: "user", id: "u1", name: "alice" } as const;
76
  const root = { role: "admin", id: "u0", name: "root" } as const;
77
  // Drawings are their asker's: a stranger has no door to any of them.
78
  expect(mayKnock(anon, "drawing", "list")).toBe(false);
79
  expect(mayKnock(anon, "drawing", "create")).toBe(false);
80
  expect(mayKnock(anon, "drawing", "delete")).toBe(false);
81
  expect(mayKnock(alice, "drawing", "list")).toBe(true);
82
  expect(mayKnock(alice, "drawing", "create")).toBe(true);
83
  // alice may delete a drawing of her own, so the door lets her ask; the action decides which.
84
  expect(mayKnock(alice, "drawing", "delete")).toBe(true);
85
  expect(mayKnock(alice, "drawing", "update")).toBe(false);
86
  expect(mayKnock(alice, "audit", "list")).toBe(false);
87
  expect(mayKnock(root, "audit", "list")).toBe(true);
88
  expect(mayKnock(root, "audit", "create")).toBe(false);
89
});
90
91
test("a stranger is refused /api before the body is read, when the contract closes it", async () => {
92
  // A body past the cap: read, it would be 413. The door answers first.
93
  const huge = "x".repeat(128 * 1024);
94
  const refused = await fetch(`${base}/api/drawings`, {
95
    method: "POST",
96
    headers: { "content-type": "application/json" },
97
    body: huge,
98
  });
99
  expect(refused.status).toBe(401);
100
  const signedIn = await fetch(`${base}/api/drawings`, {
101
    method: "POST",
102
    headers: { "content-type": "application/json", ...as("u1", "user") },
103
    body: huge,
104
  });
105
  expect(signedIn.status).toBe(413);
106
  expect(await refused.json()).toEqual({
107
    error: "signin_required",
108
    message: "Sign in first.",
109
  });
110
  expect((await fetch(`${base}/api/audit`)).status).toBe(401);
111
  expect((await fetch(`${base}/api/audit/1`)).status).toBe(401);
112
  expect(
113
    (await fetch(`${base}/api/audit`, { headers: as("u1", "user") })).status,
114
  ).toBe(403);
115
  // A stranger has no door to a drawing, nor to the description of doors they cannot use.
116
  expect((await fetch(`${base}/api/drawings`)).status).toBe(401);
117
  expect((await fetch(`${base}/openapi.json`)).status).toBe(401);
118
  // What the table opens to a signed-in person stays open.
119
  expect((await fetch(`${base}/api/drawings`, { headers: as("u1", "user") })).status).toBe(200);
120
  expect((await fetch(`${base}/api/drawings/0`, { headers: as("u1", "user") })).status).toBe(404);
121
  expect((await fetch(`${base}/openapi.json`, { headers: as("u1", "user") })).status).toBe(200);
122
});
123
124
test("the API's description is shut to strangers when the table opens them no door", () => {
125
  const closed = readContract(
126
    JSON.stringify({
127
      roles: {
128
        anon: { above: [], held: { by: "everyone" } },
129
        user: { above: ["anon"], held: { by: "identity", role: "user" } },
130
      },
131
      resources: {
132
        drawing: {
133
          api: "/api/drawings",
134
          can: {
135
            create: "user",
136
            read: "user",
137
            update: "user",
138
            delete: "user",
139
            list: "user",
140
          },
141
        },
142
      },
143
    }),
144
  );
145
  if (!closed.ok) throw new Error(closed.problems.join("; "));
146
  expect(openToStrangers(closed.contract)).toBe(false);
147
  // This app's own table opens a stranger nothing.
148
  expect(openToStrangers()).toBe(false);
149
});
150
151
test("strangers are rationed at the API, reads too", () => {
152
  const db = openDb(mkdtempSync(join(tmpdir(), "app-door-rate-")));
153
  const knock = () =>
154
    door(
155
      db,
156
      new Request("http://app/api/drawings"),
157
      new URL("http://app/api/drawings"),
158
      { role: "anon" },
159
      "203.0.113.7",
160
    );
161
  const answers = Array.from({ length: STRANGERS.burst + 5 }, knock);
162
  // Within the burst the contract answers (a stranger is asked to sign in); past it, the ration.
163
  expect(answers.slice(0, STRANGERS.burst).every((a) => a?.status === 401)).toBe(
164
    true,
165
  );
166
  expect(answers.at(-1)?.status).toBe(429);
167
  // The page itself is not the API: a stranger reading it is never rationed here.
168
  expect(
169
    door(
170
      db,
171
      new Request("http://app/"),
172
      new URL("http://app/"),
173
      { role: "anon" },
174
      "203.0.113.7",
175
    ),
176
  ).toBeUndefined();
177
  // A bucket refills.
178
  const t = Date.now();
179
  for (let i = 0; i < 25; i++) allow("k", { burst: 20, perSecond: 2 }, t);
180
  expect(allow("k", { burst: 20, perSecond: 2 }, t)).toBe(false);
181
  expect(allow("k", { burst: 20, perSecond: 2 }, t + 1000)).toBe(true);
182
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts