cursedbelt-core 2.6.0 → 2.8.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/dist/core/passwordPolicy.d.ts +9 -7
- package/dist/core/passwordPolicy.js +9 -7
- package/dist/ctgrClient/convertRequest.d.ts +18 -0
- package/dist/ctgrClient/convertRequest.js +98 -0
- package/dist/ctgrClient/ctgrConfig.d.ts +23 -0
- package/dist/ctgrClient/ctgrConfig.js +94 -0
- package/dist/ctgrClient/ctgrGuard.d.ts +1 -0
- package/dist/ctgrClient/ctgrGuard.js +83 -0
- package/dist/ctgrClient/index.d.ts +3 -0
- package/dist/ctgrClient/index.js +15 -0
- package/package.json +7 -1
- package/src/core/passwordPolicy.spec.ts +3 -3
- package/src/core/passwordPolicy.ts +9 -7
- package/src/ctgrClient/convertRequest.ts +123 -0
- package/src/ctgrClient/ctgrClient.spec.ts +135 -0
- package/src/ctgrClient/ctgrConfig.ts +89 -0
- package/src/ctgrClient/ctgrGuard.ts +86 -0
- package/src/ctgrClient/index.ts +21 -0
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one place a satellite's owner-password floor is decided.
|
|
3
3
|
*
|
|
4
|
-
* ── Why this is shared, and why the default is
|
|
4
|
+
* ── Why this is shared, and why the default is 6 ────────────────────────────
|
|
5
5
|
* Three apps had independently written a `MIN_PASSWORD_BYTES` constant into
|
|
6
6
|
* their own `scripts/owner.ts`, and they had already drifted (9, 14, 14) with
|
|
7
7
|
* a paragraph in each explaining itself against the others. A floor is a
|
|
8
8
|
* POLICY, and a policy copied three times is three policies.
|
|
9
9
|
*
|
|
10
|
-
* The default is {@link DEFAULT_MIN_PASSWORD_BYTES} =
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The default is {@link DEFAULT_MIN_PASSWORD_BYTES} = 6 — the owner, 2026-09-24:
|
|
11
|
+
* *"make the minimum password length 6 characters"* (it was 9). It is a SIGN-IN
|
|
12
|
+
* floor: every door that uses it rate-limits guesses, so a short password costs
|
|
13
|
+
* an attacker attempts, not seconds. A secret that can be guessed OFFLINE (vault's
|
|
14
|
+
* passphrase, which derives its encryption key) raises it at its own call site.
|
|
13
15
|
* Argon2id — what every caller hashes with — is what actually carries the cost
|
|
14
16
|
* of a guess here; these apps have exactly ONE principal and no enumerable user
|
|
15
17
|
* list, so the marginal value of characters 10–14 is small and the friction is
|
|
@@ -25,7 +27,7 @@
|
|
|
25
27
|
* {@link resolveMinPasswordBytes} takes an optional override and **refuses to
|
|
26
28
|
* go below the default**. An app that genuinely runs at higher stakes says so
|
|
27
29
|
* at its own call site, in one visible argument, with a comment saying why.
|
|
28
|
-
*
|
|
30
|
+
* `vault` does (9, since 2026-09-24 — its passphrase is an encryption key). The retired orch companion app did (14, as the single
|
|
29
31
|
* internet-facing door into the host that runs `claude` in every repo) until
|
|
30
32
|
* the owner dropped it to the shared floor on 2026-07-26.
|
|
31
33
|
*
|
|
@@ -38,12 +40,12 @@
|
|
|
38
40
|
* they read, so a multi-byte character counts as more than one, which is the
|
|
39
41
|
* conservative direction).
|
|
40
42
|
*/
|
|
41
|
-
export declare const DEFAULT_MIN_PASSWORD_BYTES =
|
|
43
|
+
export declare const DEFAULT_MIN_PASSWORD_BYTES = 6;
|
|
42
44
|
/**
|
|
43
45
|
* The floor this app enforces: the default, or a higher one it asks for.
|
|
44
46
|
*
|
|
45
47
|
* Throws on an override below the default rather than silently clamping. A
|
|
46
|
-
* caller passing
|
|
48
|
+
* caller passing 4 believes it is enforcing 4; being handed 6 without a word is
|
|
47
49
|
* how a security floor becomes folklore.
|
|
48
50
|
*/
|
|
49
51
|
export declare function resolveMinPasswordBytes(override?: number): number;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one place a satellite's owner-password floor is decided.
|
|
3
3
|
*
|
|
4
|
-
* ── Why this is shared, and why the default is
|
|
4
|
+
* ── Why this is shared, and why the default is 6 ────────────────────────────
|
|
5
5
|
* Three apps had independently written a `MIN_PASSWORD_BYTES` constant into
|
|
6
6
|
* their own `scripts/owner.ts`, and they had already drifted (9, 14, 14) with
|
|
7
7
|
* a paragraph in each explaining itself against the others. A floor is a
|
|
8
8
|
* POLICY, and a policy copied three times is three policies.
|
|
9
9
|
*
|
|
10
|
-
* The default is {@link DEFAULT_MIN_PASSWORD_BYTES} =
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The default is {@link DEFAULT_MIN_PASSWORD_BYTES} = 6 — the owner, 2026-09-24:
|
|
11
|
+
* *"make the minimum password length 6 characters"* (it was 9). It is a SIGN-IN
|
|
12
|
+
* floor: every door that uses it rate-limits guesses, so a short password costs
|
|
13
|
+
* an attacker attempts, not seconds. A secret that can be guessed OFFLINE (vault's
|
|
14
|
+
* passphrase, which derives its encryption key) raises it at its own call site.
|
|
13
15
|
* Argon2id — what every caller hashes with — is what actually carries the cost
|
|
14
16
|
* of a guess here; these apps have exactly ONE principal and no enumerable user
|
|
15
17
|
* list, so the marginal value of characters 10–14 is small and the friction is
|
|
@@ -25,7 +27,7 @@
|
|
|
25
27
|
* {@link resolveMinPasswordBytes} takes an optional override and **refuses to
|
|
26
28
|
* go below the default**. An app that genuinely runs at higher stakes says so
|
|
27
29
|
* at its own call site, in one visible argument, with a comment saying why.
|
|
28
|
-
*
|
|
30
|
+
* `vault` does (9, since 2026-09-24 — its passphrase is an encryption key). The retired orch companion app did (14, as the single
|
|
29
31
|
* internet-facing door into the host that runs `claude` in every repo) until
|
|
30
32
|
* the owner dropped it to the shared floor on 2026-07-26.
|
|
31
33
|
*
|
|
@@ -38,12 +40,12 @@
|
|
|
38
40
|
* they read, so a multi-byte character counts as more than one, which is the
|
|
39
41
|
* conservative direction).
|
|
40
42
|
*/
|
|
41
|
-
export const DEFAULT_MIN_PASSWORD_BYTES =
|
|
43
|
+
export const DEFAULT_MIN_PASSWORD_BYTES = 6;
|
|
42
44
|
/**
|
|
43
45
|
* The floor this app enforces: the default, or a higher one it asks for.
|
|
44
46
|
*
|
|
45
47
|
* Throws on an override below the default rather than silently clamping. A
|
|
46
|
-
* caller passing
|
|
48
|
+
* caller passing 4 believes it is enforcing 4; being handed 6 without a word is
|
|
47
49
|
* how a security floor becomes folklore.
|
|
48
50
|
*/
|
|
49
51
|
export function resolveMinPasswordBytes(override) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** One converted request ready for `fetch`: the GET-twin path + the GET init. */
|
|
2
|
+
export interface CtgrFetchRequest {
|
|
3
|
+
/** `/ctgr<xx><path>?<params>` — a path; the caller prepends any API prefix. */
|
|
4
|
+
url: string;
|
|
5
|
+
init: RequestInit;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Convert one request for the tunnel. Returns the chunked GET requests to send in
|
|
9
|
+
* order (usually one); when the mode is off, or it is already a body-less GET, the
|
|
10
|
+
* original request passes through unchanged as a single entry.
|
|
11
|
+
*
|
|
12
|
+
* `formData` may be passed explicitly, or left off and put on `init.body` (the shape
|
|
13
|
+
* a typed client already uses) — an `init.body` that is a `FormData` is detected and
|
|
14
|
+
* serialized the same way.
|
|
15
|
+
*/
|
|
16
|
+
export declare function convertIfOnlyGETRequests(url: string, init?: RequestInit, formData?: FormData): Promise<{
|
|
17
|
+
requests: CtgrFetchRequest[];
|
|
18
|
+
}>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The browser half of the GET-only tunnel. When the mode is on, a mutating request
|
|
3
|
+
* is rewritten into one or more `/ctgr<xx>` GET twins via the isomorphic codec (gzip
|
|
4
|
+
* + single base64url + integrity); a plain GET passes through untouched. FormData
|
|
5
|
+
* uploads are serialized into the JSON body's `__files` shape (the server
|
|
6
|
+
* reconstructs the uploads from it). When the mode is off, every request passes
|
|
7
|
+
* through unchanged.
|
|
8
|
+
*/
|
|
9
|
+
import { encodeRequest, METHOD_TO_ABBREV, toBase64, } from '../ctgr/index.js';
|
|
10
|
+
import { isCtgrModeOn } from './ctgrConfig.js';
|
|
11
|
+
// 10 MB per-file cap for GET-only mode. base64 + URL transport inflates each file and
|
|
12
|
+
// needs many sequential GETs per MB; beyond this the upload hangs/OOMs on mobile.
|
|
13
|
+
const CTGR_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
14
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD']);
|
|
15
|
+
/**
|
|
16
|
+
* Convert one request for the tunnel. Returns the chunked GET requests to send in
|
|
17
|
+
* order (usually one); when the mode is off, or it is already a body-less GET, the
|
|
18
|
+
* original request passes through unchanged as a single entry.
|
|
19
|
+
*
|
|
20
|
+
* `formData` may be passed explicitly, or left off and put on `init.body` (the shape
|
|
21
|
+
* a typed client already uses) — an `init.body` that is a `FormData` is detected and
|
|
22
|
+
* serialized the same way.
|
|
23
|
+
*/
|
|
24
|
+
export async function convertIfOnlyGETRequests(url, init = { method: 'GET' }, formData) {
|
|
25
|
+
const form = formData ??
|
|
26
|
+
(typeof FormData !== 'undefined' && init.body instanceof FormData ? init.body : undefined);
|
|
27
|
+
const method = (init.method ?? (form ? 'POST' : 'GET')).toUpperCase();
|
|
28
|
+
if (!isCtgrModeOn() || (SAFE_METHODS.has(method) && !form)) {
|
|
29
|
+
return { requests: [{ url, init }] };
|
|
30
|
+
}
|
|
31
|
+
const upper = method;
|
|
32
|
+
if (!METHOD_TO_ABBREV[upper]) {
|
|
33
|
+
throw new Error(`ctgr: "${method}" is not a tunnelable method`);
|
|
34
|
+
}
|
|
35
|
+
const body = form ? await formDataToBody(form) : bodyFromInit(init.body);
|
|
36
|
+
const getInit = toGetInit(init);
|
|
37
|
+
const { chunks } = await encodeRequest(upper, url, body);
|
|
38
|
+
return { requests: chunks.map((chunk) => ({ url: chunk.url, init: getInit })) };
|
|
39
|
+
}
|
|
40
|
+
/** Turn a FormData upload into the `__files` JSON body shape the GET path carries. */
|
|
41
|
+
async function formDataToBody(formData) {
|
|
42
|
+
const files = [];
|
|
43
|
+
const fields = {};
|
|
44
|
+
for (const [key, value] of formData.entries()) {
|
|
45
|
+
if (typeof value === 'object' && value !== null && 'arrayBuffer' in value) {
|
|
46
|
+
const file = value;
|
|
47
|
+
if (file.size > CTGR_MAX_FILE_BYTES) {
|
|
48
|
+
throw new Error(`"${file.name}" (${Math.round(file.size / 1024 / 1024)} MB) is too large to upload in ` +
|
|
49
|
+
`GET-only mode`);
|
|
50
|
+
}
|
|
51
|
+
files.push({
|
|
52
|
+
field: key,
|
|
53
|
+
name: file.name,
|
|
54
|
+
type: file.type,
|
|
55
|
+
dataBase64: toBase64(new Uint8Array(await file.arrayBuffer())),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
fields[key] = String(value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { __files: files, ...fields };
|
|
63
|
+
}
|
|
64
|
+
/** The request body as a JS value: a JSON string is parsed; anything else passes through. */
|
|
65
|
+
function bodyFromInit(body) {
|
|
66
|
+
if (typeof body !== 'string')
|
|
67
|
+
return body ?? undefined;
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(body);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return body;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** A GET copy of the init: no body, and the now-meaningless content-type dropped. */
|
|
76
|
+
function toGetInit(init) {
|
|
77
|
+
const next = { ...init, method: 'GET', headers: stripContentType(init.headers) };
|
|
78
|
+
delete next.body;
|
|
79
|
+
return next;
|
|
80
|
+
}
|
|
81
|
+
function stripContentType(headers) {
|
|
82
|
+
if (!headers)
|
|
83
|
+
return headers;
|
|
84
|
+
if (headers instanceof Headers) {
|
|
85
|
+
const next = new Headers(headers);
|
|
86
|
+
next.delete('content-type');
|
|
87
|
+
return next;
|
|
88
|
+
}
|
|
89
|
+
if (Array.isArray(headers)) {
|
|
90
|
+
return headers.filter(([key]) => key.toLowerCase() !== 'content-type');
|
|
91
|
+
}
|
|
92
|
+
const next = {};
|
|
93
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
94
|
+
if (key.toLowerCase() !== 'content-type')
|
|
95
|
+
next[key] = value;
|
|
96
|
+
}
|
|
97
|
+
return next;
|
|
98
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const isCtgrModeOn: () => boolean;
|
|
2
|
+
export declare const setCtgrMode: (on: boolean) => void;
|
|
3
|
+
/**
|
|
4
|
+
* The state a `ctgr` query value asks for: on, off, or (unknown/absent) leave the
|
|
5
|
+
* device as it is. Pure — no storage, no DOM — so it is trivially testable and the
|
|
6
|
+
* one place the accepted spellings live.
|
|
7
|
+
*/
|
|
8
|
+
export declare const readCtgrIntent: (value: string | null | undefined) => "on" | "off" | "keep";
|
|
9
|
+
/**
|
|
10
|
+
* Apply a `ctgr` query value to this device's persisted mode and report whether the
|
|
11
|
+
* mode is on afterwards. Called by `applyCtgrFromLocation` with the value read off
|
|
12
|
+
* the URL; exposed on its own so a host that already parsed its query can reuse it.
|
|
13
|
+
*/
|
|
14
|
+
export declare const applyCtgrIntent: (value: string | null | undefined) => boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Boot hook: read `?ctgr=…` off the current location (or a supplied search string /
|
|
17
|
+
* URL), persist the change for this device, and return whether the mode is on. Safe
|
|
18
|
+
* to call with no argument in the browser; returns `false` with no `window`.
|
|
19
|
+
*
|
|
20
|
+
* Call this ONCE, before the app issues its first request — pairing it with
|
|
21
|
+
* `installCtgrGuard()` so a request made during that first tick is still caught.
|
|
22
|
+
*/
|
|
23
|
+
export declare const applyCtgrFromLocation: (search?: string) => boolean;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Single source of truth for GET-only request mode. When on, the request layer
|
|
3
|
+
* rewrites every mutating call to a GET (see `convertIfOnlyGETRequests`) and the
|
|
4
|
+
* network-boundary guard hard-blocks any raw non-GET. Both read this one flag, so
|
|
5
|
+
* they can never disagree.
|
|
6
|
+
*
|
|
7
|
+
* The mode is per-DEVICE, persisted in localStorage so it survives reloads and is
|
|
8
|
+
* never tied to an account — a device that has it on keeps it on, a device that does
|
|
9
|
+
* not never gains it. Default is OFF.
|
|
10
|
+
*
|
|
11
|
+
* A site sets it up front, before its first request, by reading the URL:
|
|
12
|
+
* • `?ctgr=t` (or `on`/`1`/`yes`) → turn on, persist for this device.
|
|
13
|
+
* • `?ctgr=f` (or `off`/`0`/`no`) → turn off, clear it for this device.
|
|
14
|
+
* • no `ctgr` param → whatever this device already had.
|
|
15
|
+
* `applyCtgrFromLocation()` is the one call that does this on boot.
|
|
16
|
+
*/
|
|
17
|
+
const CTGR_KEY = 'ctgr';
|
|
18
|
+
const ON_VALUES = new Set(['t', 'on', '1', 'yes', 'true']);
|
|
19
|
+
const OFF_VALUES = new Set(['f', 'off', '0', 'no', 'false']);
|
|
20
|
+
export const isCtgrModeOn = () => {
|
|
21
|
+
try {
|
|
22
|
+
return localStorage.getItem(CTGR_KEY) === 'on';
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
export const setCtgrMode = (on) => {
|
|
29
|
+
try {
|
|
30
|
+
if (on)
|
|
31
|
+
localStorage.setItem(CTGR_KEY, 'on');
|
|
32
|
+
else
|
|
33
|
+
localStorage.removeItem(CTGR_KEY);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// localStorage unavailable (private mode / SSR) — nothing to persist.
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* The state a `ctgr` query value asks for: on, off, or (unknown/absent) leave the
|
|
41
|
+
* device as it is. Pure — no storage, no DOM — so it is trivially testable and the
|
|
42
|
+
* one place the accepted spellings live.
|
|
43
|
+
*/
|
|
44
|
+
export const readCtgrIntent = (value) => {
|
|
45
|
+
if (value == null)
|
|
46
|
+
return 'keep';
|
|
47
|
+
const v = value.trim().toLowerCase();
|
|
48
|
+
if (ON_VALUES.has(v))
|
|
49
|
+
return 'on';
|
|
50
|
+
if (OFF_VALUES.has(v))
|
|
51
|
+
return 'off';
|
|
52
|
+
return 'keep';
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Apply a `ctgr` query value to this device's persisted mode and report whether the
|
|
56
|
+
* mode is on afterwards. Called by `applyCtgrFromLocation` with the value read off
|
|
57
|
+
* the URL; exposed on its own so a host that already parsed its query can reuse it.
|
|
58
|
+
*/
|
|
59
|
+
export const applyCtgrIntent = (value) => {
|
|
60
|
+
const intent = readCtgrIntent(value);
|
|
61
|
+
if (intent === 'on')
|
|
62
|
+
setCtgrMode(true);
|
|
63
|
+
else if (intent === 'off')
|
|
64
|
+
setCtgrMode(false);
|
|
65
|
+
return isCtgrModeOn();
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Boot hook: read `?ctgr=…` off the current location (or a supplied search string /
|
|
69
|
+
* URL), persist the change for this device, and return whether the mode is on. Safe
|
|
70
|
+
* to call with no argument in the browser; returns `false` with no `window`.
|
|
71
|
+
*
|
|
72
|
+
* Call this ONCE, before the app issues its first request — pairing it with
|
|
73
|
+
* `installCtgrGuard()` so a request made during that first tick is still caught.
|
|
74
|
+
*/
|
|
75
|
+
export const applyCtgrFromLocation = (search) => {
|
|
76
|
+
let query = search;
|
|
77
|
+
if (query === undefined) {
|
|
78
|
+
if (typeof window === 'undefined' || !window.location)
|
|
79
|
+
return isCtgrModeOn();
|
|
80
|
+
query = window.location.search;
|
|
81
|
+
}
|
|
82
|
+
let value = null;
|
|
83
|
+
try {
|
|
84
|
+
// Accept a bare search (`?ctgr=t`), a full href, or already-extracted params.
|
|
85
|
+
const params = query.includes('://')
|
|
86
|
+
? new URL(query).searchParams
|
|
87
|
+
: new URLSearchParams(query.startsWith('?') ? query.slice(1) : query);
|
|
88
|
+
value = params.get(CTGR_KEY);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
value = null;
|
|
92
|
+
}
|
|
93
|
+
return applyCtgrIntent(value);
|
|
94
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const installCtgrGuard: () => void;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Safety net for GET-only mode. Even though the request layer is supposed to rewrite
|
|
3
|
+
* every mutating call to a GET (`convertIfOnlyGETRequests`), this guard sits at the
|
|
4
|
+
* actual network boundary and hard-blocks any non-GET request to our own API surface
|
|
5
|
+
* when the mode is on — so a code path that forgot to run through the converter can
|
|
6
|
+
* never put a raw POST/PUT/PATCH/DELETE on the wire. Catches both `fetch` and
|
|
7
|
+
* `XMLHttpRequest`. It only ever tightens; it must never weaken.
|
|
8
|
+
*/
|
|
9
|
+
import { isCtgrModeOn } from './ctgrConfig.js';
|
|
10
|
+
// Methods that never mutate and are always safe to allow.
|
|
11
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD']);
|
|
12
|
+
const urlOf = (input) => {
|
|
13
|
+
if (typeof input === 'string')
|
|
14
|
+
return input;
|
|
15
|
+
if (input instanceof URL)
|
|
16
|
+
return input.href;
|
|
17
|
+
if (input instanceof Request)
|
|
18
|
+
return input.url;
|
|
19
|
+
return String(input);
|
|
20
|
+
};
|
|
21
|
+
// Only guard our OWN API surface — same-origin AND under a path the request layer
|
|
22
|
+
// owns (`/api/…` or a `/ctgr<xx>` twin). Restricting to these prefixes (rather than
|
|
23
|
+
// "any same-origin path") matters: same-origin also covers paths we do not own, like
|
|
24
|
+
// Cloudflare's `/cdn-cgi/rum` beacon, which fires a raw POST on every page view —
|
|
25
|
+
// guarding it would break RUM and fire for every ordinary visitor, not just a
|
|
26
|
+
// misbehaving call site.
|
|
27
|
+
const isGuardedPath = (rawUrl) => {
|
|
28
|
+
let path;
|
|
29
|
+
if (rawUrl.startsWith('/')) {
|
|
30
|
+
path = rawUrl;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
try {
|
|
34
|
+
const u = new URL(rawUrl, window.location.href);
|
|
35
|
+
if (u.origin !== window.location.origin)
|
|
36
|
+
return false;
|
|
37
|
+
path = u.pathname;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return path.startsWith('/api/') || path.startsWith('/ctgr');
|
|
44
|
+
};
|
|
45
|
+
// Deliberately generic: this message can reach the console of ANY visitor, so it says
|
|
46
|
+
// nothing about the request layer, the call site, or what a blocked request was.
|
|
47
|
+
const block = () => {
|
|
48
|
+
const message = 'Service unavailable.';
|
|
49
|
+
console.error(message, new Error('blocked request'));
|
|
50
|
+
return new Error(message);
|
|
51
|
+
};
|
|
52
|
+
let installed = false;
|
|
53
|
+
export const installCtgrGuard = () => {
|
|
54
|
+
if (installed || typeof window === 'undefined')
|
|
55
|
+
return;
|
|
56
|
+
installed = true;
|
|
57
|
+
// ── fetch ──────────────────────────────────────────────────────────────────
|
|
58
|
+
if (typeof window.fetch === 'function') {
|
|
59
|
+
const originalFetch = window.fetch.bind(window);
|
|
60
|
+
const guardedFetch = (input, init) => {
|
|
61
|
+
const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
62
|
+
const url = urlOf(input);
|
|
63
|
+
if (isCtgrModeOn() && !SAFE_METHODS.has(method) && isGuardedPath(url)) {
|
|
64
|
+
return Promise.reject(block());
|
|
65
|
+
}
|
|
66
|
+
return originalFetch(input, init);
|
|
67
|
+
};
|
|
68
|
+
window.fetch = guardedFetch;
|
|
69
|
+
}
|
|
70
|
+
// ── XMLHttpRequest ───────────────────────────────────────────────────────────
|
|
71
|
+
if (typeof XMLHttpRequest !== 'undefined') {
|
|
72
|
+
const originalOpen = XMLHttpRequest.prototype.open;
|
|
73
|
+
// biome-ignore lint/suspicious/noExplicitAny: matching XHR.open's variadic overloads
|
|
74
|
+
XMLHttpRequest.prototype.open = function open(method, url, ...rest) {
|
|
75
|
+
const upper = String(method).toUpperCase();
|
|
76
|
+
if (isCtgrModeOn() && !SAFE_METHODS.has(upper) && isGuardedPath(String(url))) {
|
|
77
|
+
throw block();
|
|
78
|
+
}
|
|
79
|
+
// biome-ignore lint/suspicious/noExplicitAny: forwarding the original variadic call
|
|
80
|
+
return originalOpen.call(this, method, url, ...rest);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* cursedbelt-core/ctgr-client — the browser adapters that turn any site into a
|
|
3
|
+
* GET-only client:
|
|
4
|
+
* • the per-device mode flag + the `?ctgr=t|f` boot toggle (`ctgrConfig`),
|
|
5
|
+
* • the request converter that rewrites a mutation into `/ctgr<xx>` GET twins,
|
|
6
|
+
* • the network-boundary guard that blocks a stray raw verb.
|
|
7
|
+
*
|
|
8
|
+
* The wire codec is `cursedbelt-core/ctgr`; the server that decodes the twins is
|
|
9
|
+
* `cursedbelt-server` (`mountCtgrTunnel`). A site opts in with two boot calls —
|
|
10
|
+
* `applyCtgrFromLocation()` then `installCtgrGuard()` — and routes its requests
|
|
11
|
+
* through `convertIfOnlyGETRequests`.
|
|
12
|
+
*/
|
|
13
|
+
export { applyCtgrFromLocation, applyCtgrIntent, isCtgrModeOn, readCtgrIntent, setCtgrMode, } from './ctgrConfig.js';
|
|
14
|
+
export { convertIfOnlyGETRequests } from './convertRequest.js';
|
|
15
|
+
export { installCtgrGuard } from './ctgrGuard.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
|
|
@@ -371,6 +371,12 @@
|
|
|
371
371
|
"bun": "./src/client/wsWire.ts",
|
|
372
372
|
"source": "./src/client/wsWire.ts",
|
|
373
373
|
"import": "./dist/client/wsWire.js"
|
|
374
|
+
},
|
|
375
|
+
"./ctgr-client": {
|
|
376
|
+
"types": "./dist/ctgrClient/index.d.ts",
|
|
377
|
+
"bun": "./src/ctgrClient/index.ts",
|
|
378
|
+
"source": "./src/ctgrClient/index.ts",
|
|
379
|
+
"import": "./dist/ctgrClient/index.js"
|
|
374
380
|
}
|
|
375
381
|
},
|
|
376
382
|
"dependencies": {
|
|
@@ -7,13 +7,13 @@ import { DEFAULT_MIN_PASSWORD_BYTES, resolveMinPasswordBytes } from './passwordP
|
|
|
7
7
|
describe('resolveMinPasswordBytes', () => {
|
|
8
8
|
it('is the shared default when an app asks for nothing', () => {
|
|
9
9
|
expect(resolveMinPasswordBytes()).toBe(DEFAULT_MIN_PASSWORD_BYTES);
|
|
10
|
-
expect(DEFAULT_MIN_PASSWORD_BYTES).toBe(
|
|
10
|
+
expect(DEFAULT_MIN_PASSWORD_BYTES).toBe(6);
|
|
11
11
|
});
|
|
12
12
|
it('lets an app raise it, visibly, at its own call site', () => {
|
|
13
13
|
expect(resolveMinPasswordBytes(14)).toBe(14);
|
|
14
14
|
});
|
|
15
15
|
it('🔴 refuses to go BELOW the default, and refuses a non-integer', () => {
|
|
16
|
-
expect(() => resolveMinPasswordBytes(
|
|
17
|
-
expect(() => resolveMinPasswordBytes(
|
|
16
|
+
expect(() => resolveMinPasswordBytes(5)).toThrow(/may only be RAISED/);
|
|
17
|
+
expect(() => resolveMinPasswordBytes(6.5)).toThrow(/must be an integer/);
|
|
18
18
|
});
|
|
19
19
|
});
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one place a satellite's owner-password floor is decided.
|
|
3
3
|
*
|
|
4
|
-
* ── Why this is shared, and why the default is
|
|
4
|
+
* ── Why this is shared, and why the default is 6 ────────────────────────────
|
|
5
5
|
* Three apps had independently written a `MIN_PASSWORD_BYTES` constant into
|
|
6
6
|
* their own `scripts/owner.ts`, and they had already drifted (9, 14, 14) with
|
|
7
7
|
* a paragraph in each explaining itself against the others. A floor is a
|
|
8
8
|
* POLICY, and a policy copied three times is three policies.
|
|
9
9
|
*
|
|
10
|
-
* The default is {@link DEFAULT_MIN_PASSWORD_BYTES} =
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The default is {@link DEFAULT_MIN_PASSWORD_BYTES} = 6 — the owner, 2026-09-24:
|
|
11
|
+
* *"make the minimum password length 6 characters"* (it was 9). It is a SIGN-IN
|
|
12
|
+
* floor: every door that uses it rate-limits guesses, so a short password costs
|
|
13
|
+
* an attacker attempts, not seconds. A secret that can be guessed OFFLINE (vault's
|
|
14
|
+
* passphrase, which derives its encryption key) raises it at its own call site.
|
|
13
15
|
* Argon2id — what every caller hashes with — is what actually carries the cost
|
|
14
16
|
* of a guess here; these apps have exactly ONE principal and no enumerable user
|
|
15
17
|
* list, so the marginal value of characters 10–14 is small and the friction is
|
|
@@ -25,7 +27,7 @@
|
|
|
25
27
|
* {@link resolveMinPasswordBytes} takes an optional override and **refuses to
|
|
26
28
|
* go below the default**. An app that genuinely runs at higher stakes says so
|
|
27
29
|
* at its own call site, in one visible argument, with a comment saying why.
|
|
28
|
-
*
|
|
30
|
+
* `vault` does (9, since 2026-09-24 — its passphrase is an encryption key). The retired orch companion app did (14, as the single
|
|
29
31
|
* internet-facing door into the host that runs `claude` in every repo) until
|
|
30
32
|
* the owner dropped it to the shared floor on 2026-07-26.
|
|
31
33
|
*
|
|
@@ -39,13 +41,13 @@
|
|
|
39
41
|
* they read, so a multi-byte character counts as more than one, which is the
|
|
40
42
|
* conservative direction).
|
|
41
43
|
*/
|
|
42
|
-
export const DEFAULT_MIN_PASSWORD_BYTES =
|
|
44
|
+
export const DEFAULT_MIN_PASSWORD_BYTES = 6;
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* The floor this app enforces: the default, or a higher one it asks for.
|
|
46
48
|
*
|
|
47
49
|
* Throws on an override below the default rather than silently clamping. A
|
|
48
|
-
* caller passing
|
|
50
|
+
* caller passing 4 believes it is enforcing 4; being handed 6 without a word is
|
|
49
51
|
* how a security floor becomes folklore.
|
|
50
52
|
*/
|
|
51
53
|
export function resolveMinPasswordBytes(override?: number): number {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The browser half of the GET-only tunnel. When the mode is on, a mutating request
|
|
3
|
+
* is rewritten into one or more `/ctgr<xx>` GET twins via the isomorphic codec (gzip
|
|
4
|
+
* + single base64url + integrity); a plain GET passes through untouched. FormData
|
|
5
|
+
* uploads are serialized into the JSON body's `__files` shape (the server
|
|
6
|
+
* reconstructs the uploads from it). When the mode is off, every request passes
|
|
7
|
+
* through unchanged.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
type CtgrMethod,
|
|
11
|
+
encodeRequest,
|
|
12
|
+
METHOD_TO_ABBREV,
|
|
13
|
+
type SerializedFile,
|
|
14
|
+
toBase64,
|
|
15
|
+
} from '../ctgr/index.js';
|
|
16
|
+
import { isCtgrModeOn } from './ctgrConfig.js';
|
|
17
|
+
|
|
18
|
+
/** One converted request ready for `fetch`: the GET-twin path + the GET init. */
|
|
19
|
+
export interface CtgrFetchRequest {
|
|
20
|
+
/** `/ctgr<xx><path>?<params>` — a path; the caller prepends any API prefix. */
|
|
21
|
+
url: string;
|
|
22
|
+
init: RequestInit;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 10 MB per-file cap for GET-only mode. base64 + URL transport inflates each file and
|
|
26
|
+
// needs many sequential GETs per MB; beyond this the upload hangs/OOMs on mobile.
|
|
27
|
+
const CTGR_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert one request for the tunnel. Returns the chunked GET requests to send in
|
|
33
|
+
* order (usually one); when the mode is off, or it is already a body-less GET, the
|
|
34
|
+
* original request passes through unchanged as a single entry.
|
|
35
|
+
*
|
|
36
|
+
* `formData` may be passed explicitly, or left off and put on `init.body` (the shape
|
|
37
|
+
* a typed client already uses) — an `init.body` that is a `FormData` is detected and
|
|
38
|
+
* serialized the same way.
|
|
39
|
+
*/
|
|
40
|
+
export async function convertIfOnlyGETRequests(
|
|
41
|
+
url: string,
|
|
42
|
+
init: RequestInit = { method: 'GET' },
|
|
43
|
+
formData?: FormData,
|
|
44
|
+
): Promise<{ requests: CtgrFetchRequest[] }> {
|
|
45
|
+
const form =
|
|
46
|
+
formData ??
|
|
47
|
+
(typeof FormData !== 'undefined' && init.body instanceof FormData ? init.body : undefined);
|
|
48
|
+
const method = (init.method ?? (form ? 'POST' : 'GET')).toUpperCase();
|
|
49
|
+
|
|
50
|
+
if (!isCtgrModeOn() || (SAFE_METHODS.has(method) && !form)) {
|
|
51
|
+
return { requests: [{ url, init }] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const upper = method as CtgrMethod;
|
|
55
|
+
if (!METHOD_TO_ABBREV[upper]) {
|
|
56
|
+
throw new Error(`ctgr: "${method}" is not a tunnelable method`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const body: unknown = form ? await formDataToBody(form) : bodyFromInit(init.body);
|
|
60
|
+
const getInit = toGetInit(init);
|
|
61
|
+
const { chunks } = await encodeRequest(upper, url, body);
|
|
62
|
+
return { requests: chunks.map((chunk) => ({ url: chunk.url, init: getInit })) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Turn a FormData upload into the `__files` JSON body shape the GET path carries. */
|
|
66
|
+
async function formDataToBody(formData: FormData): Promise<Record<string, unknown>> {
|
|
67
|
+
const files: SerializedFile[] = [];
|
|
68
|
+
const fields: Record<string, string> = {};
|
|
69
|
+
for (const [key, value] of formData.entries()) {
|
|
70
|
+
if (typeof value === 'object' && value !== null && 'arrayBuffer' in value) {
|
|
71
|
+
const file = value as File;
|
|
72
|
+
if (file.size > CTGR_MAX_FILE_BYTES) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`"${file.name}" (${Math.round(file.size / 1024 / 1024)} MB) is too large to upload in ` +
|
|
75
|
+
`GET-only mode`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
files.push({
|
|
79
|
+
field: key,
|
|
80
|
+
name: file.name,
|
|
81
|
+
type: file.type,
|
|
82
|
+
dataBase64: toBase64(new Uint8Array(await file.arrayBuffer())),
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
fields[key] = String(value);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { __files: files, ...fields };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The request body as a JS value: a JSON string is parsed; anything else passes through. */
|
|
92
|
+
function bodyFromInit(body: BodyInit | null | undefined): unknown {
|
|
93
|
+
if (typeof body !== 'string') return body ?? undefined;
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(body);
|
|
96
|
+
} catch {
|
|
97
|
+
return body;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A GET copy of the init: no body, and the now-meaningless content-type dropped. */
|
|
102
|
+
function toGetInit(init: RequestInit): RequestInit {
|
|
103
|
+
const next: RequestInit = { ...init, method: 'GET', headers: stripContentType(init.headers) };
|
|
104
|
+
delete next.body;
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function stripContentType(headers: HeadersInit | undefined): HeadersInit | undefined {
|
|
109
|
+
if (!headers) return headers;
|
|
110
|
+
if (headers instanceof Headers) {
|
|
111
|
+
const next = new Headers(headers);
|
|
112
|
+
next.delete('content-type');
|
|
113
|
+
return next;
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(headers)) {
|
|
116
|
+
return headers.filter(([key]) => key.toLowerCase() !== 'content-type');
|
|
117
|
+
}
|
|
118
|
+
const next: Record<string, string> = {};
|
|
119
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
120
|
+
if (key.toLowerCase() !== 'content-type') next[key] = value;
|
|
121
|
+
}
|
|
122
|
+
return next;
|
|
123
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { decodeRequest } from '../ctgr/index.js';
|
|
3
|
+
import {
|
|
4
|
+
applyCtgrFromLocation,
|
|
5
|
+
applyCtgrIntent,
|
|
6
|
+
convertIfOnlyGETRequests,
|
|
7
|
+
isCtgrModeOn,
|
|
8
|
+
readCtgrIntent,
|
|
9
|
+
setCtgrMode,
|
|
10
|
+
} from './index.js';
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
localStorage.clear();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('the per-device mode flag', () => {
|
|
17
|
+
test('is off by default and round-trips through setCtgrMode', () => {
|
|
18
|
+
expect(isCtgrModeOn()).toBe(false);
|
|
19
|
+
setCtgrMode(true);
|
|
20
|
+
expect(isCtgrModeOn()).toBe(true);
|
|
21
|
+
setCtgrMode(false);
|
|
22
|
+
expect(isCtgrModeOn()).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('readCtgrIntent — the accepted spellings', () => {
|
|
27
|
+
test('on-values → on', () => {
|
|
28
|
+
for (const v of ['t', 'on', '1', 'yes', 'true', 'T', ' On ']) {
|
|
29
|
+
expect(readCtgrIntent(v)).toBe('on');
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
test('off-values → off', () => {
|
|
33
|
+
for (const v of ['f', 'off', '0', 'no', 'false']) {
|
|
34
|
+
expect(readCtgrIntent(v)).toBe('off');
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
test('absent or unknown → keep', () => {
|
|
38
|
+
expect(readCtgrIntent(null)).toBe('keep');
|
|
39
|
+
expect(readCtgrIntent(undefined)).toBe('keep');
|
|
40
|
+
expect(readCtgrIntent('maybe')).toBe('keep');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('applyCtgrIntent persists per device', () => {
|
|
45
|
+
test('on then off', () => {
|
|
46
|
+
expect(applyCtgrIntent('t')).toBe(true);
|
|
47
|
+
expect(isCtgrModeOn()).toBe(true);
|
|
48
|
+
expect(applyCtgrIntent('f')).toBe(false);
|
|
49
|
+
expect(isCtgrModeOn()).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
test('an absent value leaves an already-on device on', () => {
|
|
52
|
+
setCtgrMode(true);
|
|
53
|
+
expect(applyCtgrIntent(null)).toBe(true);
|
|
54
|
+
expect(isCtgrModeOn()).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('applyCtgrFromLocation — the boot toggle', () => {
|
|
59
|
+
test('?ctgr=t turns it on, ?ctgr=f turns it off, from a bare search string', () => {
|
|
60
|
+
expect(applyCtgrFromLocation('?ctgr=t')).toBe(true);
|
|
61
|
+
expect(isCtgrModeOn()).toBe(true);
|
|
62
|
+
expect(applyCtgrFromLocation('?ctgr=f')).toBe(false);
|
|
63
|
+
expect(isCtgrModeOn()).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
test('accepts a full href and other params alongside', () => {
|
|
66
|
+
expect(applyCtgrFromLocation('https://desk.example.com/x?foo=1&ctgr=on')).toBe(true);
|
|
67
|
+
expect(isCtgrModeOn()).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
test('no ctgr param leaves the device as it was', () => {
|
|
70
|
+
setCtgrMode(true);
|
|
71
|
+
expect(applyCtgrFromLocation('?foo=1')).toBe(true);
|
|
72
|
+
setCtgrMode(false);
|
|
73
|
+
expect(applyCtgrFromLocation('?foo=1')).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('convertIfOnlyGETRequests', () => {
|
|
78
|
+
test('mode OFF: every request passes through unchanged', async () => {
|
|
79
|
+
const init = { method: 'POST', body: JSON.stringify({ a: 1 }) };
|
|
80
|
+
const { requests } = await convertIfOnlyGETRequests('/api/v1/files', init);
|
|
81
|
+
expect(requests).toHaveLength(1);
|
|
82
|
+
expect(requests[0].url).toBe('/api/v1/files');
|
|
83
|
+
expect(requests[0].init).toBe(init);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('mode ON: a POST becomes a decodable GET twin', async () => {
|
|
87
|
+
setCtgrMode(true);
|
|
88
|
+
const body = { title: 'hello', n: 42 };
|
|
89
|
+
const { requests } = await convertIfOnlyGETRequests('/api/v1/files', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'content-type': 'application/json' },
|
|
92
|
+
body: JSON.stringify(body),
|
|
93
|
+
});
|
|
94
|
+
expect(requests).toHaveLength(1);
|
|
95
|
+
const twin = requests[0];
|
|
96
|
+
expect(twin.url.startsWith('/ctgrpo/api/v1/files?')).toBe(true);
|
|
97
|
+
expect((twin.init.method ?? 'GET').toUpperCase()).toBe('GET');
|
|
98
|
+
expect(twin.init.body).toBeUndefined();
|
|
99
|
+
// The server codec reverses it exactly.
|
|
100
|
+
const params = new URL(twin.url, 'http://x').searchParams;
|
|
101
|
+
const decoded = await decodeRequest(params);
|
|
102
|
+
expect(decoded).not.toBe('partial');
|
|
103
|
+
if (decoded === 'partial') throw new Error('unreachable');
|
|
104
|
+
expect(decoded.method).toBe('POST');
|
|
105
|
+
expect(decoded.body).toEqual(body);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('mode ON: a plain GET still passes through untouched', async () => {
|
|
109
|
+
setCtgrMode(true);
|
|
110
|
+
const { requests } = await convertIfOnlyGETRequests('/api/v1/files', { method: 'GET' });
|
|
111
|
+
expect(requests).toHaveLength(1);
|
|
112
|
+
expect(requests[0].url).toBe('/api/v1/files');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('mode ON: a DELETE with no body tunnels too', async () => {
|
|
116
|
+
setCtgrMode(true);
|
|
117
|
+
const { requests } = await convertIfOnlyGETRequests('/api/v1/files/x', { method: 'DELETE' });
|
|
118
|
+
expect(requests[0].url.startsWith('/ctgrde/api/v1/files/x')).toBe(true);
|
|
119
|
+
const decoded = await decodeRequest(new URL(requests[0].url, 'http://x').searchParams);
|
|
120
|
+
if (decoded === 'partial') throw new Error('unreachable');
|
|
121
|
+
expect(decoded.method).toBe('DELETE');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('mode ON: a FormData upload rides as __files and reconstructs', async () => {
|
|
125
|
+
setCtgrMode(true);
|
|
126
|
+
const form = new FormData();
|
|
127
|
+
form.append('name', 'pic');
|
|
128
|
+
form.append('file', new File([new Uint8Array([1, 2, 3])], 'p.bin', { type: 'image/png' }));
|
|
129
|
+
const { requests } = await convertIfOnlyGETRequests('/api/v1/media', { method: 'POST' }, form);
|
|
130
|
+
const decoded = await decodeRequest(new URL(requests[0].url, 'http://x').searchParams);
|
|
131
|
+
if (decoded === 'partial') throw new Error('unreachable');
|
|
132
|
+
expect(decoded.files?.[0]).toMatchObject({ originalname: 'p.bin', mimetype: 'image/png' });
|
|
133
|
+
expect(decoded.body).toEqual({ name: 'pic' });
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Single source of truth for GET-only request mode. When on, the request layer
|
|
3
|
+
* rewrites every mutating call to a GET (see `convertIfOnlyGETRequests`) and the
|
|
4
|
+
* network-boundary guard hard-blocks any raw non-GET. Both read this one flag, so
|
|
5
|
+
* they can never disagree.
|
|
6
|
+
*
|
|
7
|
+
* The mode is per-DEVICE, persisted in localStorage so it survives reloads and is
|
|
8
|
+
* never tied to an account — a device that has it on keeps it on, a device that does
|
|
9
|
+
* not never gains it. Default is OFF.
|
|
10
|
+
*
|
|
11
|
+
* A site sets it up front, before its first request, by reading the URL:
|
|
12
|
+
* • `?ctgr=t` (or `on`/`1`/`yes`) → turn on, persist for this device.
|
|
13
|
+
* • `?ctgr=f` (or `off`/`0`/`no`) → turn off, clear it for this device.
|
|
14
|
+
* • no `ctgr` param → whatever this device already had.
|
|
15
|
+
* `applyCtgrFromLocation()` is the one call that does this on boot.
|
|
16
|
+
*/
|
|
17
|
+
const CTGR_KEY = 'ctgr';
|
|
18
|
+
|
|
19
|
+
const ON_VALUES = new Set(['t', 'on', '1', 'yes', 'true']);
|
|
20
|
+
const OFF_VALUES = new Set(['f', 'off', '0', 'no', 'false']);
|
|
21
|
+
|
|
22
|
+
export const isCtgrModeOn = (): boolean => {
|
|
23
|
+
try {
|
|
24
|
+
return localStorage.getItem(CTGR_KEY) === 'on';
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const setCtgrMode = (on: boolean): void => {
|
|
31
|
+
try {
|
|
32
|
+
if (on) localStorage.setItem(CTGR_KEY, 'on');
|
|
33
|
+
else localStorage.removeItem(CTGR_KEY);
|
|
34
|
+
} catch {
|
|
35
|
+
// localStorage unavailable (private mode / SSR) — nothing to persist.
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The state a `ctgr` query value asks for: on, off, or (unknown/absent) leave the
|
|
41
|
+
* device as it is. Pure — no storage, no DOM — so it is trivially testable and the
|
|
42
|
+
* one place the accepted spellings live.
|
|
43
|
+
*/
|
|
44
|
+
export const readCtgrIntent = (value: string | null | undefined): 'on' | 'off' | 'keep' => {
|
|
45
|
+
if (value == null) return 'keep';
|
|
46
|
+
const v = value.trim().toLowerCase();
|
|
47
|
+
if (ON_VALUES.has(v)) return 'on';
|
|
48
|
+
if (OFF_VALUES.has(v)) return 'off';
|
|
49
|
+
return 'keep';
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Apply a `ctgr` query value to this device's persisted mode and report whether the
|
|
54
|
+
* mode is on afterwards. Called by `applyCtgrFromLocation` with the value read off
|
|
55
|
+
* the URL; exposed on its own so a host that already parsed its query can reuse it.
|
|
56
|
+
*/
|
|
57
|
+
export const applyCtgrIntent = (value: string | null | undefined): boolean => {
|
|
58
|
+
const intent = readCtgrIntent(value);
|
|
59
|
+
if (intent === 'on') setCtgrMode(true);
|
|
60
|
+
else if (intent === 'off') setCtgrMode(false);
|
|
61
|
+
return isCtgrModeOn();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Boot hook: read `?ctgr=…` off the current location (or a supplied search string /
|
|
66
|
+
* URL), persist the change for this device, and return whether the mode is on. Safe
|
|
67
|
+
* to call with no argument in the browser; returns `false` with no `window`.
|
|
68
|
+
*
|
|
69
|
+
* Call this ONCE, before the app issues its first request — pairing it with
|
|
70
|
+
* `installCtgrGuard()` so a request made during that first tick is still caught.
|
|
71
|
+
*/
|
|
72
|
+
export const applyCtgrFromLocation = (search?: string): boolean => {
|
|
73
|
+
let query = search;
|
|
74
|
+
if (query === undefined) {
|
|
75
|
+
if (typeof window === 'undefined' || !window.location) return isCtgrModeOn();
|
|
76
|
+
query = window.location.search;
|
|
77
|
+
}
|
|
78
|
+
let value: string | null = null;
|
|
79
|
+
try {
|
|
80
|
+
// Accept a bare search (`?ctgr=t`), a full href, or already-extracted params.
|
|
81
|
+
const params = query.includes('://')
|
|
82
|
+
? new URL(query).searchParams
|
|
83
|
+
: new URLSearchParams(query.startsWith('?') ? query.slice(1) : query);
|
|
84
|
+
value = params.get(CTGR_KEY);
|
|
85
|
+
} catch {
|
|
86
|
+
value = null;
|
|
87
|
+
}
|
|
88
|
+
return applyCtgrIntent(value);
|
|
89
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Safety net for GET-only mode. Even though the request layer is supposed to rewrite
|
|
3
|
+
* every mutating call to a GET (`convertIfOnlyGETRequests`), this guard sits at the
|
|
4
|
+
* actual network boundary and hard-blocks any non-GET request to our own API surface
|
|
5
|
+
* when the mode is on — so a code path that forgot to run through the converter can
|
|
6
|
+
* never put a raw POST/PUT/PATCH/DELETE on the wire. Catches both `fetch` and
|
|
7
|
+
* `XMLHttpRequest`. It only ever tightens; it must never weaken.
|
|
8
|
+
*/
|
|
9
|
+
import { isCtgrModeOn } from './ctgrConfig.js';
|
|
10
|
+
|
|
11
|
+
// Methods that never mutate and are always safe to allow.
|
|
12
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD']);
|
|
13
|
+
|
|
14
|
+
const urlOf = (input: RequestInfo | URL): string => {
|
|
15
|
+
if (typeof input === 'string') return input;
|
|
16
|
+
if (input instanceof URL) return input.href;
|
|
17
|
+
if (input instanceof Request) return input.url;
|
|
18
|
+
return String(input);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Only guard our OWN API surface — same-origin AND under a path the request layer
|
|
22
|
+
// owns (`/api/…` or a `/ctgr<xx>` twin). Restricting to these prefixes (rather than
|
|
23
|
+
// "any same-origin path") matters: same-origin also covers paths we do not own, like
|
|
24
|
+
// Cloudflare's `/cdn-cgi/rum` beacon, which fires a raw POST on every page view —
|
|
25
|
+
// guarding it would break RUM and fire for every ordinary visitor, not just a
|
|
26
|
+
// misbehaving call site.
|
|
27
|
+
const isGuardedPath = (rawUrl: string): boolean => {
|
|
28
|
+
let path: string;
|
|
29
|
+
if (rawUrl.startsWith('/')) {
|
|
30
|
+
path = rawUrl;
|
|
31
|
+
} else {
|
|
32
|
+
try {
|
|
33
|
+
const u = new URL(rawUrl, window.location.href);
|
|
34
|
+
if (u.origin !== window.location.origin) return false;
|
|
35
|
+
path = u.pathname;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return path.startsWith('/api/') || path.startsWith('/ctgr');
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Deliberately generic: this message can reach the console of ANY visitor, so it says
|
|
44
|
+
// nothing about the request layer, the call site, or what a blocked request was.
|
|
45
|
+
const block = (): Error => {
|
|
46
|
+
const message = 'Service unavailable.';
|
|
47
|
+
console.error(message, new Error('blocked request'));
|
|
48
|
+
return new Error(message);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let installed = false;
|
|
52
|
+
|
|
53
|
+
export const installCtgrGuard = (): void => {
|
|
54
|
+
if (installed || typeof window === 'undefined') return;
|
|
55
|
+
installed = true;
|
|
56
|
+
|
|
57
|
+
// ── fetch ──────────────────────────────────────────────────────────────────
|
|
58
|
+
if (typeof window.fetch === 'function') {
|
|
59
|
+
const originalFetch = window.fetch.bind(window);
|
|
60
|
+
const guardedFetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
61
|
+
const method = (
|
|
62
|
+
init?.method ?? (input instanceof Request ? input.method : 'GET')
|
|
63
|
+
).toUpperCase();
|
|
64
|
+
const url = urlOf(input);
|
|
65
|
+
if (isCtgrModeOn() && !SAFE_METHODS.has(method) && isGuardedPath(url)) {
|
|
66
|
+
return Promise.reject(block());
|
|
67
|
+
}
|
|
68
|
+
return originalFetch(input, init);
|
|
69
|
+
};
|
|
70
|
+
window.fetch = guardedFetch as typeof window.fetch;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── XMLHttpRequest ───────────────────────────────────────────────────────────
|
|
74
|
+
if (typeof XMLHttpRequest !== 'undefined') {
|
|
75
|
+
const originalOpen = XMLHttpRequest.prototype.open;
|
|
76
|
+
// biome-ignore lint/suspicious/noExplicitAny: matching XHR.open's variadic overloads
|
|
77
|
+
XMLHttpRequest.prototype.open = function open(method: string, url: string, ...rest: any[]) {
|
|
78
|
+
const upper = String(method).toUpperCase();
|
|
79
|
+
if (isCtgrModeOn() && !SAFE_METHODS.has(upper) && isGuardedPath(String(url))) {
|
|
80
|
+
throw block();
|
|
81
|
+
}
|
|
82
|
+
// biome-ignore lint/suspicious/noExplicitAny: forwarding the original variadic call
|
|
83
|
+
return (originalOpen as any).call(this, method, url, ...rest);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* cursedbelt-core/ctgr-client — the browser adapters that turn any site into a
|
|
3
|
+
* GET-only client:
|
|
4
|
+
* • the per-device mode flag + the `?ctgr=t|f` boot toggle (`ctgrConfig`),
|
|
5
|
+
* • the request converter that rewrites a mutation into `/ctgr<xx>` GET twins,
|
|
6
|
+
* • the network-boundary guard that blocks a stray raw verb.
|
|
7
|
+
*
|
|
8
|
+
* The wire codec is `cursedbelt-core/ctgr`; the server that decodes the twins is
|
|
9
|
+
* `cursedbelt-server` (`mountCtgrTunnel`). A site opts in with two boot calls —
|
|
10
|
+
* `applyCtgrFromLocation()` then `installCtgrGuard()` — and routes its requests
|
|
11
|
+
* through `convertIfOnlyGETRequests`.
|
|
12
|
+
*/
|
|
13
|
+
export {
|
|
14
|
+
applyCtgrFromLocation,
|
|
15
|
+
applyCtgrIntent,
|
|
16
|
+
isCtgrModeOn,
|
|
17
|
+
readCtgrIntent,
|
|
18
|
+
setCtgrMode,
|
|
19
|
+
} from './ctgrConfig.js';
|
|
20
|
+
export { type CtgrFetchRequest, convertIfOnlyGETRequests } from './convertRequest.js';
|
|
21
|
+
export { installCtgrGuard } from './ctgrGuard.js';
|