@sudobility/sider_lib 0.0.5 → 0.0.7

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/tokenize.js CHANGED
@@ -43,17 +43,33 @@ export function tokenizeObservation(raw, known) {
43
43
  });
44
44
  };
45
45
  // 1. Known values (exact substring match everywhere).
46
+ //
47
+ // `known` is EVERY cookie and storage entry the page has, not the ones this
48
+ // request uses, so most of them appear nowhere in it. Masking still runs
49
+ // unconditionally — that costs nothing and protects a value wherever it turns
50
+ // up — but a SLOT is only proposed for a value actually found in the request.
51
+ //
52
+ // Minting one regardless, at a default of `authorization`, invented an
53
+ // injection point out of an absence of evidence: a public GET carried 21
54
+ // headers all named `authorization`, each holding a different analytics
55
+ // cookie, because every cookie on the page fell through to that default.
56
+ // Where a value goes is something to observe, never to assume.
46
57
  for (const s of known) {
47
58
  if (!s.value)
48
59
  continue;
49
- const loc = locate(raw, s.value) ?? { at: "header", name: "authorization" };
60
+ const loc = locate(raw, s.value);
50
61
  const ph = samplePlaceholder(s.slotId);
51
62
  url = url.split(s.value).join(ph);
52
63
  for (const k of Object.keys(headers))
53
64
  headers[k] = headers[k].split(s.value).join(ph);
54
65
  requestBody = replaceInJson(requestBody, s.value, ph);
55
66
  responseBody = replaceInJson(responseBody, s.value, ph);
56
- ensure(s.slotId, s.kind, s.role, loc, s.resolverHint);
67
+ if (!loc)
68
+ continue;
69
+ // Found inside the Cookie header: the browser put it there, so it is
70
+ // attached automatically at execution rather than substituted by us.
71
+ const inCookieHeader = loc.at === "header" && loc.name.toLowerCase() === "cookie";
72
+ ensure(s.slotId, s.kind, inCookieHeader ? "auto_cookie" : s.role, inCookieHeader ? { at: "cookie", name: loc.name } : loc, s.resolverHint);
57
73
  }
58
74
  // 2. Credential-bearing headers (by name), even when the value wasn't in storage.
59
75
  for (const name of Object.keys(headers)) {
@@ -40,3 +40,46 @@ test("leaves non-secret data untouched", () => {
40
40
  const { observation } = tokenizeObservation(raw({ responseBody: { price: 400, section: "A1" } }), []);
41
41
  expect(observation.responseBody).toEqual({ price: 400, section: "A1" });
42
42
  });
43
+ // --- where a secret goes is observed, never assumed ------------------------
44
+ const analyticsCookie = (value = "GA1.2.9876") => ({
45
+ value,
46
+ slotId: "slot:www.usfca.edu:cookie_ga",
47
+ kind: "unknown",
48
+ role: "active_inject",
49
+ resolverHint: { from: "cookie", name: "_ga" },
50
+ });
51
+ test("proposes no slot for a page secret the request never carries", () => {
52
+ // Every cookie and storage entry on the page is offered as `known`, not the
53
+ // ones this request uses. Defaulting the absent ones to an Authorization
54
+ // header gave a public GET 21 headers all named `authorization`, each holding
55
+ // a different analytics cookie.
56
+ const { slots } = tokenizeObservation(raw(), [analyticsCookie()]);
57
+ expect(slots).toEqual([]);
58
+ });
59
+ test("still masks a value it proposes no slot for", () => {
60
+ // Absent from the REQUEST is not absent from the observation: the same value
61
+ // echoed in a response body must not be uploaded in the clear.
62
+ const { observation, slots } = tokenizeObservation(raw({ responseBody: { tracker: "GA1.2.9876" } }), [analyticsCookie()]);
63
+ expect(JSON.stringify(observation.responseBody)).not.toContain("GA1.2.9876");
64
+ expect(slots).toEqual([]);
65
+ });
66
+ test("proposes a slot where the value is actually found", () => {
67
+ const { slots } = tokenizeObservation(raw({ requestHeaders: { "x-session": "GA1.2.9876" } }), [analyticsCookie()]);
68
+ expect(slots).toHaveLength(1);
69
+ expect(slots[0].injectionLocation).toEqual({ at: "header", name: "x-session" });
70
+ expect(slots[0].role).toBe("active_inject");
71
+ });
72
+ test("finds a value in a query parameter", () => {
73
+ const { slots } = tokenizeObservation(raw({ url: "https://resale.fifa.com/api/seats?sid=GA1.2.9876" }), [analyticsCookie()]);
74
+ expect(slots[0].injectionLocation).toEqual({ at: "query", param: "sid" });
75
+ });
76
+ test("finds a value in the request body", () => {
77
+ const { slots } = tokenizeObservation(raw({ method: "POST", requestBody: { auth: { token: "GA1.2.9876" } } }), [analyticsCookie()]);
78
+ expect(slots[0].injectionLocation).toEqual({ at: "body", jsonPath: "auth.token" });
79
+ });
80
+ test("a value found in the Cookie header is attached by the browser, not injected", () => {
81
+ const { slots } = tokenizeObservation(raw({ requestHeaders: { cookie: "_ga=GA1.2.9876" } }), [analyticsCookie()]);
82
+ const slot = slots.find((s) => s.id === "slot:www.usfca.edu:cookie_ga");
83
+ expect(slot.role).toBe("auto_cookie");
84
+ expect(slot.injectionLocation.at).toBe("cookie");
85
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sudobility/sider_lib",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Sider business logic — recipe compiler, egress gates, tokenizer, clustering. Pure, no Chrome/DOM/server.",
5
5
  "license": "BUSL-1.1",
6
6
  "publishConfig": {
@@ -19,7 +19,7 @@
19
19
  "test": "bun test"
20
20
  },
21
21
  "dependencies": {
22
- "@sudobility/sider_types": "^0.0.5"
22
+ "@sudobility/sider_types": "^0.0.6"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/bun": "^1.3.14",
@@ -52,3 +52,69 @@ test("leaves non-secret data untouched", () => {
52
52
  const { observation } = tokenizeObservation(raw({ responseBody: { price: 400, section: "A1" } }), []);
53
53
  expect(observation.responseBody).toEqual({ price: 400, section: "A1" });
54
54
  });
55
+
56
+ // --- where a secret goes is observed, never assumed ------------------------
57
+
58
+ const analyticsCookie = (value = "GA1.2.9876"): KnownSecret => ({
59
+ value,
60
+ slotId: "slot:www.usfca.edu:cookie_ga",
61
+ kind: "unknown",
62
+ role: "active_inject",
63
+ resolverHint: { from: "cookie", name: "_ga" },
64
+ });
65
+
66
+ test("proposes no slot for a page secret the request never carries", () => {
67
+ // Every cookie and storage entry on the page is offered as `known`, not the
68
+ // ones this request uses. Defaulting the absent ones to an Authorization
69
+ // header gave a public GET 21 headers all named `authorization`, each holding
70
+ // a different analytics cookie.
71
+ const { slots } = tokenizeObservation(raw(), [analyticsCookie()]);
72
+ expect(slots).toEqual([]);
73
+ });
74
+
75
+ test("still masks a value it proposes no slot for", () => {
76
+ // Absent from the REQUEST is not absent from the observation: the same value
77
+ // echoed in a response body must not be uploaded in the clear.
78
+ const { observation, slots } = tokenizeObservation(
79
+ raw({ responseBody: { tracker: "GA1.2.9876" } }),
80
+ [analyticsCookie()],
81
+ );
82
+ expect(JSON.stringify(observation.responseBody)).not.toContain("GA1.2.9876");
83
+ expect(slots).toEqual([]);
84
+ });
85
+
86
+ test("proposes a slot where the value is actually found", () => {
87
+ const { slots } = tokenizeObservation(
88
+ raw({ requestHeaders: { "x-session": "GA1.2.9876" } }),
89
+ [analyticsCookie()],
90
+ );
91
+ expect(slots).toHaveLength(1);
92
+ expect(slots[0]!.injectionLocation).toEqual({ at: "header", name: "x-session" });
93
+ expect(slots[0]!.role).toBe("active_inject");
94
+ });
95
+
96
+ test("finds a value in a query parameter", () => {
97
+ const { slots } = tokenizeObservation(
98
+ raw({ url: "https://resale.fifa.com/api/seats?sid=GA1.2.9876" }),
99
+ [analyticsCookie()],
100
+ );
101
+ expect(slots[0]!.injectionLocation).toEqual({ at: "query", param: "sid" });
102
+ });
103
+
104
+ test("finds a value in the request body", () => {
105
+ const { slots } = tokenizeObservation(
106
+ raw({ method: "POST", requestBody: { auth: { token: "GA1.2.9876" } } }),
107
+ [analyticsCookie()],
108
+ );
109
+ expect(slots[0]!.injectionLocation).toEqual({ at: "body", jsonPath: "auth.token" });
110
+ });
111
+
112
+ test("a value found in the Cookie header is attached by the browser, not injected", () => {
113
+ const { slots } = tokenizeObservation(
114
+ raw({ requestHeaders: { cookie: "_ga=GA1.2.9876" } }),
115
+ [analyticsCookie()],
116
+ );
117
+ const slot = slots.find((s) => s.id === "slot:www.usfca.edu:cookie_ga");
118
+ expect(slot!.role).toBe("auto_cookie");
119
+ expect(slot!.injectionLocation.at).toBe("cookie");
120
+ });
package/src/tokenize.ts CHANGED
@@ -88,15 +88,36 @@ export function tokenizeObservation(raw: RawObservation, known: KnownSecret[]):
88
88
  };
89
89
 
90
90
  // 1. Known values (exact substring match everywhere).
91
+ //
92
+ // `known` is EVERY cookie and storage entry the page has, not the ones this
93
+ // request uses, so most of them appear nowhere in it. Masking still runs
94
+ // unconditionally — that costs nothing and protects a value wherever it turns
95
+ // up — but a SLOT is only proposed for a value actually found in the request.
96
+ //
97
+ // Minting one regardless, at a default of `authorization`, invented an
98
+ // injection point out of an absence of evidence: a public GET carried 21
99
+ // headers all named `authorization`, each holding a different analytics
100
+ // cookie, because every cookie on the page fell through to that default.
101
+ // Where a value goes is something to observe, never to assume.
91
102
  for (const s of known) {
92
103
  if (!s.value) continue;
93
- const loc = locate(raw, s.value) ?? { at: "header" as const, name: "authorization" };
104
+ const loc = locate(raw, s.value);
94
105
  const ph = samplePlaceholder(s.slotId);
95
106
  url = url.split(s.value).join(ph);
96
107
  for (const k of Object.keys(headers)) headers[k] = headers[k]!.split(s.value).join(ph);
97
108
  requestBody = replaceInJson(requestBody, s.value, ph);
98
109
  responseBody = replaceInJson(responseBody, s.value, ph);
99
- ensure(s.slotId, s.kind, s.role, loc, s.resolverHint);
110
+ if (!loc) continue;
111
+ // Found inside the Cookie header: the browser put it there, so it is
112
+ // attached automatically at execution rather than substituted by us.
113
+ const inCookieHeader = loc.at === "header" && loc.name.toLowerCase() === "cookie";
114
+ ensure(
115
+ s.slotId,
116
+ s.kind,
117
+ inCookieHeader ? "auto_cookie" : s.role,
118
+ inCookieHeader ? { at: "cookie", name: loc.name } : loc,
119
+ s.resolverHint,
120
+ );
100
121
  }
101
122
 
102
123
  // 2. Credential-bearing headers (by name), even when the value wasn't in storage.