cursedops 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -1
- package/package.json +8 -2
- package/src/serve.ts +153 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# cursedops
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build-and-ops answers that the apps here wrote independently and identically.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
bun add cursedops
|
|
@@ -11,6 +11,7 @@ bun add cursedops
|
|
|
11
11
|
| `cursedops/roots` | finding a generation's roots, and a checkout's package root, without knowing a path |
|
|
12
12
|
| `cursedops/launchd` | installing, replacing and removing a macOS launchd user agent |
|
|
13
13
|
| `cursedops/smoke` | the scaffolding of a deployed smoke — the ledger, the fetch, the DNS hint, the exit code |
|
|
14
|
+
| `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers |
|
|
14
15
|
|
|
15
16
|
Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
|
|
16
17
|
name, a hostname, a port or a route.
|
|
@@ -114,6 +115,61 @@ No checks, ever. The checks are the part `desk` and `flix` wrote differently on
|
|
|
114
115
|
and a shared kit that starts absorbing route lists and health-payload shapes is how the
|
|
115
116
|
last one reached 277 files.
|
|
116
117
|
|
|
118
|
+
## `cursedops/serve`
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { contentTypeFor, fileWithin, installCrashHandlers, isHashedAsset } from "cursedops/serve";
|
|
122
|
+
|
|
123
|
+
installCrashHandlers("myapp"); // one log line, then exit 1
|
|
124
|
+
const file = fileWithin(clientDir, url.pathname); // null unless it is really inside
|
|
125
|
+
if (!file) return c.notFound();
|
|
126
|
+
return new Response(Bun.file(file), {
|
|
127
|
+
headers: {
|
|
128
|
+
"content-type": contentTypeFor(file),
|
|
129
|
+
"cache-control": isHashedAsset(url.pathname) ? ONE_YEAR : "no-cache",
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
🔴 `fileWithin` is the reason this subpath exists rather than the convenience of the
|
|
135
|
+
other three. It is the check that stops `%2e%2e%2f` reaching the filesystem, and on
|
|
136
|
+
2026-09-17 there were **eight** of it — one per app, character-identical, with nothing in
|
|
137
|
+
the generation counting them. Eight copies of a security guarantee are eight guarantees:
|
|
138
|
+
a fix in one is a fix in one.
|
|
139
|
+
|
|
140
|
+
### The verdict for every symbol in the `serve.ts` family
|
|
141
|
+
|
|
142
|
+
Measured 2026-09-17 across `collections`, `family`, `flix`, `music`, `patterns`, `roms`,
|
|
143
|
+
`station` and `vault` — the eight apps that have a `src/server/serve.ts`. The files are
|
|
144
|
+
247–387 lines and all eight differ, which is why no `diff` ever flagged them; the bodies
|
|
145
|
+
below were compared with whitespace and comments normalised away.
|
|
146
|
+
|
|
147
|
+
| symbol | identical in | verdict |
|
|
148
|
+
|---|---:|---|
|
|
149
|
+
| `fileWithin` | 8 of 8 | **moved.** The traversal guard. Entry rule 3 — a trap somebody paid for — in its purest form |
|
|
150
|
+
| `installCrashHandlers` | 8 of 8 | **moved.** The app's name is a parameter; it is the only app-shaped thing the four touch |
|
|
151
|
+
| `contentTypeFor` | 8 of 8 | **moved.** Extension → MIME, plus its private `CONTENT_TYPES` table, which was also 8 of 8 |
|
|
152
|
+
| `isHashedAsset` | 8 of 8 | **moved.** Vite's own output convention, which is the bundler's identity and not the app's |
|
|
153
|
+
| `clientDirOf` | 6 of 6 that have it | **stays.** One line, and six apps pin it with their own `clientDir.test.ts` against their own `vite.config.ts`. WHERE an app's build writes is app identity — entry rule 2 — and it is the line that once served `public/` and took a live app down |
|
|
154
|
+
| `createServer` / `createApp` | 0 — all eight differ | **stays.** Route tables, health payloads, gates and body limits. This is the 277-file mistake's front door |
|
|
155
|
+
| `parseRange` / `fileResponse` | `flix` and `station` only | **stays for now.** Two apps, so rule 1 is satisfiable, but rule 3 is not: nothing has been paid for yet, and a `Range` implementation shared by two apps that stream different things is a guess. The trigger to revisit is a THIRD app that needs byte ranges |
|
|
156
|
+
| `API_PREFIX`, `isApiPath`, `canonicalApiPath`, `canonicalApiRequest`, `apiNotFoundBody` | `station` only | **stays.** One app, and rule 4 says an addition one app will use does not go in. `apiNotFoundBody` also writes a sentence the owner reads on a page — identity, not mechanism |
|
|
157
|
+
|
|
158
|
+
🔴 **The task that ordered this move recorded `contentTypeFor` and `isHashedAsset` as
|
|
159
|
+
7 of 8, with `station` differing. They did not differ.** All four bodies were identical
|
|
160
|
+
in all eight apps when the move was made, so nothing had to converge and no app's
|
|
161
|
+
behaviour changed on adoption. The count came from a census of the whole `serve.ts`
|
|
162
|
+
family rather than of these two bodies; `bun tools/check-copies.ts` in the generation
|
|
163
|
+
repo is the live answer either way.
|
|
164
|
+
|
|
165
|
+
One behaviour is NOT a byte-for-byte carry, and `src/serve.ts`'s header argues it in
|
|
166
|
+
full: the eight copies checked containment lexically and were therefore blind to a
|
|
167
|
+
symlink under the root pointing out of it. `fileWithin` here resolves the real path of
|
|
168
|
+
**both** sides after the lexical check, which refuses the escape while leaving every
|
|
169
|
+
input the eight copies refused refused, at the same cost. A symlinked ancestor of the
|
|
170
|
+
root cancels out — which is the failure a one-sided `realpath` would have shipped to
|
|
171
|
+
every app on this machine, and is its own test.
|
|
172
|
+
|
|
117
173
|
## Verifying
|
|
118
174
|
|
|
119
175
|
```sh
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots without knowing a path, macOS launchd agent install/replace/remove, the scaffolding of a deployed smoke, and the static-serving helpers eight apps copied — the path-traversal guard among them. Mechanism only — no app knows its name from here. Bun, zero dependencies, ships source.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
"source": "./src/launchd.ts",
|
|
25
25
|
"import": "./src/launchd.ts"
|
|
26
26
|
},
|
|
27
|
+
"./serve": {
|
|
28
|
+
"types": "./src/serve.ts",
|
|
29
|
+
"bun": "./src/serve.ts",
|
|
30
|
+
"source": "./src/serve.ts",
|
|
31
|
+
"import": "./src/serve.ts"
|
|
32
|
+
},
|
|
27
33
|
"./smoke": {
|
|
28
34
|
"types": "./src/smoke.ts",
|
|
29
35
|
"bun": "./src/smoke.ts",
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four helpers every app's `src/server/serve.ts` wrote the same way — the
|
|
3
|
+
* path-traversal guard among them.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is a library and not eight copies
|
|
6
|
+
*
|
|
7
|
+
* Measured 2026-09-17 by the apps maintenance sweep, and again by this move before
|
|
8
|
+
* it started: `collections`, `family`, `flix`, `music`, `patterns`, `roms`,
|
|
9
|
+
* `station` and `vault` each have a `src/server/serve.ts`. The FILES differ — 247
|
|
10
|
+
* to 387 lines, eight distinct versions, which is why no `diff` ever flagged them —
|
|
11
|
+
* but these four bodies are character-identical in **eight of eight**, with the
|
|
12
|
+
* whitespace and comments normalised away.
|
|
13
|
+
*
|
|
14
|
+
* 🔴 **`fileWithin` is why this was urgent rather than tidy.** It is the check that
|
|
15
|
+
* stops `%2e%2e%2f` reaching the filesystem. Eight copies of a security guarantee
|
|
16
|
+
* are eight guarantees: a fix in one is a fix in one, and the other seven apps keep
|
|
17
|
+
* the hole. That is the arithmetic the entry rule in `../README.md` asks for —
|
|
18
|
+
* rule 1 (two apps wrote it independently) answered eight times over, rule 2
|
|
19
|
+
* (mechanism, not identity — nothing here knows an app's name, port, hostname or
|
|
20
|
+
* routes; `installCrashHandlers` is HANDED the name), and rule 3 (a trap somebody
|
|
21
|
+
* paid for — a traversal guard is the definition of one).
|
|
22
|
+
*
|
|
23
|
+
* `clientDirOf` deliberately did NOT come with them. It is one line, six apps pin
|
|
24
|
+
* it with their own `clientDir.test.ts` against their own `vite.config.ts`, and
|
|
25
|
+
* where an app's build writes is app identity.
|
|
26
|
+
*
|
|
27
|
+
* `tools/check-copies.baseline` in the generation repo carried these four at
|
|
28
|
+
* `8 <symbol> src/server/serve.ts` and is the census that will notice if a copy
|
|
29
|
+
* comes back.
|
|
30
|
+
*
|
|
31
|
+
* ## 🔴 The one behaviour that is NOT a byte-for-byte carry
|
|
32
|
+
*
|
|
33
|
+
* The eight copies resolved containment LEXICALLY — `resolve(join(root, …))` and a
|
|
34
|
+
* `startsWith` — which is correct for `..` and for `%2e%2e%2f`, and blind to a
|
|
35
|
+
* symlink inside the root that points out of it. {@link fileWithin} now resolves
|
|
36
|
+
* the real path of both sides before comparing, so a symlink cannot walk out
|
|
37
|
+
* either. It is a strict tightening, not a change of contract:
|
|
38
|
+
*
|
|
39
|
+
* - the lexical check runs FIRST and unchanged, so every input the eight copies
|
|
40
|
+
* refused is still refused, at the same cost;
|
|
41
|
+
* - a symlinked ANCESTOR of the root cancels out, because the root is resolved
|
|
42
|
+
* the same way — the failure mode a naive `realpath` on one side only would
|
|
43
|
+
* have introduced, and the reason this is not one line;
|
|
44
|
+
* - every caller today passes a Vite `dist/`, and all eight were checked for
|
|
45
|
+
* symlinks on 2026-09-17: there are none. So no app's behaviour moves, and the
|
|
46
|
+
* day one of them serves a directory it did not build, the guard already holds.
|
|
47
|
+
*
|
|
48
|
+
* `serve.test.ts` drives both halves, including the symlink escape, because a guard
|
|
49
|
+
* that has only ever been seen passing is a guard nobody has proved can fail.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
53
|
+
import { join, normalize, resolve, sep } from "node:path";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extension → content type, for what a built single-page client actually contains.
|
|
57
|
+
*
|
|
58
|
+
* Not a full MIME database on purpose: an app serving a type that is not here is an
|
|
59
|
+
* app serving something its build did not produce, and `application/octet-stream`
|
|
60
|
+
* is the honest answer to that rather than a guess.
|
|
61
|
+
*/
|
|
62
|
+
const CONTENT_TYPES: Readonly<Record<string, string>> = {
|
|
63
|
+
html: "text/html; charset=utf-8",
|
|
64
|
+
js: "text/javascript; charset=utf-8",
|
|
65
|
+
mjs: "text/javascript; charset=utf-8",
|
|
66
|
+
css: "text/css; charset=utf-8",
|
|
67
|
+
json: "application/json; charset=utf-8",
|
|
68
|
+
svg: "image/svg+xml",
|
|
69
|
+
png: "image/png",
|
|
70
|
+
jpg: "image/jpeg",
|
|
71
|
+
jpeg: "image/jpeg",
|
|
72
|
+
webp: "image/webp",
|
|
73
|
+
avif: "image/avif",
|
|
74
|
+
ico: "image/x-icon",
|
|
75
|
+
woff: "font/woff",
|
|
76
|
+
woff2: "font/woff2",
|
|
77
|
+
txt: "text/plain; charset=utf-8",
|
|
78
|
+
map: "application/json; charset=utf-8",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The `content-type` for a path, by extension — `application/octet-stream` if unknown. */
|
|
82
|
+
export const contentTypeFor = (path: string): string =>
|
|
83
|
+
CONTENT_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "application/octet-stream";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Vite's own output convention: `name-<8+ hex/base64url chars>.ext` under
|
|
87
|
+
* `/assets/`. Only these get the immutable year.
|
|
88
|
+
*/
|
|
89
|
+
export const isHashedAsset = (urlPath: string): boolean =>
|
|
90
|
+
/^\/assets\/[^/]+-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/.test(urlPath);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The file `urlPath` names inside `root`, or `null`.
|
|
94
|
+
*
|
|
95
|
+
* 🔴 The containment check is the point. `decodeURIComponent` then `normalize`
|
|
96
|
+
* collapses `..`, and the result must still start with the root — otherwise
|
|
97
|
+
* `/assets/..%2f..%2f..%2fetc%2fpasswd` reads whatever the process can. A
|
|
98
|
+
* directory, a missing file and a malformed escape are all `null`; a caller that
|
|
99
|
+
* gets a string has a regular file it may open.
|
|
100
|
+
*
|
|
101
|
+
* The final `realpath` pass is the one thing the eight app copies did not do — see
|
|
102
|
+
* the module header for why it cannot break a caller.
|
|
103
|
+
*/
|
|
104
|
+
export function fileWithin(root: string, urlPath: string): string | null {
|
|
105
|
+
let decoded: string;
|
|
106
|
+
try {
|
|
107
|
+
decoded = decodeURIComponent(urlPath);
|
|
108
|
+
} catch {
|
|
109
|
+
return null; // a malformed escape is not a path
|
|
110
|
+
}
|
|
111
|
+
if (decoded.includes("\0")) return null;
|
|
112
|
+
const full = resolve(join(root, normalize(decoded)));
|
|
113
|
+
const base = resolve(root);
|
|
114
|
+
if (full !== base && !full.startsWith(base + sep)) return null;
|
|
115
|
+
if (!existsSync(full) || !statSync(full).isFile()) return null;
|
|
116
|
+
// Lexically inside, and the file is there. Now the same question of the DISK: a
|
|
117
|
+
// symlink under the root is the one way a path can pass the check above and
|
|
118
|
+
// still read something outside. Both sides, or a symlinked ancestor of the root
|
|
119
|
+
// would refuse every file it holds.
|
|
120
|
+
let realFull: string;
|
|
121
|
+
let realBase: string;
|
|
122
|
+
try {
|
|
123
|
+
realFull = realpathSync(full);
|
|
124
|
+
realBase = realpathSync(base);
|
|
125
|
+
} catch {
|
|
126
|
+
return null; // it existed a line ago; whatever it is now, do not serve it
|
|
127
|
+
}
|
|
128
|
+
if (realFull !== realBase && !realFull.startsWith(realBase + sep)) return null;
|
|
129
|
+
return full;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Say what killed the process, once, before it goes.
|
|
134
|
+
*
|
|
135
|
+
* The whole value is the log line. An app holding SQLite handles that Bun closes on
|
|
136
|
+
* exit and nothing else in flight has nothing to drain — what was missing in the
|
|
137
|
+
* previous generation was not cleanup, it was ever finding out why a long-running
|
|
138
|
+
* process had stopped.
|
|
139
|
+
*
|
|
140
|
+
* 🔴 `name` is a parameter rather than anything this library could look up. That is
|
|
141
|
+
* the whole of rule 2: the only app-shaped thing these four helpers touch arrives
|
|
142
|
+
* as an argument.
|
|
143
|
+
*/
|
|
144
|
+
export function installCrashHandlers(name: string): void {
|
|
145
|
+
process.on("uncaughtException", (error) => {
|
|
146
|
+
console.error(`[${name}] uncaught exception — exiting`, error);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
});
|
|
149
|
+
process.on("unhandledRejection", (reason) => {
|
|
150
|
+
console.error(`[${name}] unhandled rejection — exiting`, reason);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
});
|
|
153
|
+
}
|