@camstack/server 1.2.78 → 1.2.79

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.
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ /**
3
+ * Device-config secret redaction for NON-ADMIN principals.
4
+ *
5
+ * A device's `config` blob is the driver's connection record — host, port,
6
+ * username and, for every IP camera in the deployment, the camera's
7
+ * `password` in plaintext. It rides the wire on every device-manager read:
8
+ * `getDevice`, `listAll`, `getChildren`, and the three settings surfaces that
9
+ * project the same values into form fields.
10
+ *
11
+ * Those methods are declared `auth: 'protected'` (the default in
12
+ * `capability-definition.ts`), which is NOT admin — it is "authenticated, and
13
+ * scope-matched". Measured against the live hub: a `cst_*` scoped token
14
+ * carrying nothing but `category:system` + `view` — a READ-ONLY grant, the one
15
+ * the deploy runbook mints for CI — read `deviceManager.getDevice` and received
16
+ * `config.password` verbatim, and `deviceManager.listAll` answered with a
17
+ * populated password for every camera at once. Read-only access to a device
18
+ * list is not consent to hand over the credentials that open those cameras.
19
+ *
20
+ * WHY HERE, and not at the provider. `toDeviceInfo()` builds one `config` for
21
+ * everybody, and its in-process consumers need the real thing: the pipeline
22
+ * orchestrator's per-camera `getDevice`, dispatch routing, the drivers that
23
+ * actually connect. Those callers arrive through `ctx.api` / UDS and never
24
+ * touch this middleware, so redacting here withholds the secret from the WIRE
25
+ * without blinding the system to its own credentials. The transport boundary
26
+ * is also the only layer that knows the principal.
27
+ *
28
+ * WHY ADMINS ARE UNTOUCHED. The settings form renders the current value into a
29
+ * password field, and `updateConfig` is `auth: 'admin'`, so a redacted value
30
+ * can never be written back by a caller who received one. Making the field
31
+ * write-only for admins too is a bigger, UI-side change; it is not needed to
32
+ * close this hole and would regress the form.
33
+ *
34
+ * Share-view (`csv_*`) principals never reach this code — they are gated
35
+ * earlier by the fail-closed allowlist in `share-view-access.ts`, which does
36
+ * not expose `getDevice` at all and projects `listAll` down to seven presentation
37
+ * fields. This module is the gate for the tier BETWEEN a share link and an admin.
38
+ *
39
+ * Pure module — no tRPC machinery — so the spec exercises it directly (same
40
+ * pattern as `scope-access.ts` and `share-view-access.ts`).
41
+ */
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.NON_ADMIN_CONFIG_REDACTED_METHODS = exports.REDACTED_SECRET = void 0;
44
+ exports.isSecretConfigKey = isSecretConfigKey;
45
+ exports.redactDeviceInfoSecrets = redactDeviceInfoSecrets;
46
+ exports.redactSettingsSections = redactSettingsSections;
47
+ exports.redactSettingsAggregate = redactSettingsAggregate;
48
+ exports.redactConfigEntries = redactConfigEntries;
49
+ /** What a non-admin sees instead of the value. Matches the `***` convention
50
+ * `maskUrlCredentials` already uses for credentials in log output, so one
51
+ * redaction marker means one thing across the system. */
52
+ exports.REDACTED_SECRET = '***';
53
+ /**
54
+ * Config keys whose VALUE is a credential.
55
+ *
56
+ * Deliberately the same rule the settings UI uses to decide that a field
57
+ * renders as a password input (`zodEntriesToConfigUI` in
58
+ * `packages/types/src/device/zod-to-config-ui.ts`). The two are pinned
59
+ * together by an assertion in `device-config-secret-redaction.spec.ts`: a
60
+ * keyword added to one and forgotten in the other fails the suite instead of
61
+ * leaking. It is duplicated rather than imported because `@camstack/types` is
62
+ * a framework package on the publish train, and a redaction fix must not have
63
+ * to wait for one.
64
+ */
65
+ const SECRET_KEY_SUBSTRINGS = [
66
+ 'password',
67
+ 'secret',
68
+ 'token',
69
+ 'apikey',
70
+ 'api_key',
71
+ ];
72
+ /** True when a config key names a credential. Case-insensitive substring match. */
73
+ function isSecretConfigKey(key) {
74
+ const lower = key.toLowerCase();
75
+ return SECRET_KEY_SUBSTRINGS.some((needle) => lower.includes(needle));
76
+ }
77
+ /** Non-null, non-array plain object. */
78
+ function isRecord(x) {
79
+ return x !== null && typeof x === 'object' && !Array.isArray(x);
80
+ }
81
+ /**
82
+ * Replace the value of every credential-named key with {@link REDACTED_SECRET}.
83
+ *
84
+ * An EMPTY value stays empty: "not configured" and "configured, withheld" are
85
+ * different facts, and flattening them would make a broken camera look set up.
86
+ * A non-string value is left alone — a numeric `maxTokens` is not a secret, and
87
+ * the only credentials this repo persists are strings.
88
+ */
89
+ function redactRecord(config) {
90
+ const out = {};
91
+ for (const [key, value] of Object.entries(config)) {
92
+ out[key] =
93
+ isSecretConfigKey(key) && typeof value === 'string' && value.length > 0
94
+ ? exports.REDACTED_SECRET
95
+ : value;
96
+ }
97
+ return out;
98
+ }
99
+ /**
100
+ * Project a `DeviceInfo` (or an array of them) with its `config` redacted.
101
+ * Anything that is not a device row passes through untouched — a route that
102
+ * errored upstream must not be rewritten into a plausible-looking shape.
103
+ */
104
+ function redactDeviceInfoSecrets(data) {
105
+ if (Array.isArray(data))
106
+ return data.map((row) => redactDeviceInfoSecrets(row));
107
+ if (!isRecord(data))
108
+ return data;
109
+ const config = data['config'];
110
+ if (!isRecord(config))
111
+ return data;
112
+ return { ...data, config: redactRecord(config) };
113
+ }
114
+ /** One settings field as the aggregate emits it: `{ key, value, … }`. */
115
+ function redactField(field) {
116
+ if (!isRecord(field))
117
+ return field;
118
+ const key = field['key'];
119
+ if (typeof key !== 'string' || !isSecretConfigKey(key))
120
+ return field;
121
+ const value = field['value'];
122
+ if (typeof value !== 'string' || value.length === 0)
123
+ return field;
124
+ return { ...field, value: exports.REDACTED_SECRET };
125
+ }
126
+ /**
127
+ * Project a `SettingsSchemaWithValues` (`{ sections: [{ fields: [...] }] }`).
128
+ * Used by `getDeviceSettingsAggregate` and `getSettingsSchema`.
129
+ */
130
+ function redactSettingsSections(data) {
131
+ if (!isRecord(data))
132
+ return data;
133
+ const sections = data['sections'];
134
+ if (!Array.isArray(sections))
135
+ return data;
136
+ return {
137
+ ...data,
138
+ sections: sections.map((section) => {
139
+ if (!isRecord(section))
140
+ return section;
141
+ const fields = section['fields'];
142
+ if (!Array.isArray(fields))
143
+ return section;
144
+ return { ...section, fields: fields.map(redactField) };
145
+ }),
146
+ };
147
+ }
148
+ /**
149
+ * Project the combined aggregate, whose settings live one level down under
150
+ * `settings`. The live half carries no config blob.
151
+ */
152
+ function redactSettingsAggregate(data) {
153
+ if (!isRecord(data))
154
+ return data;
155
+ const settings = data['settings'];
156
+ if (!isRecord(settings))
157
+ return data;
158
+ return { ...data, settings: redactSettingsSections(settings) };
159
+ }
160
+ /** Project a flat `ConfigEntry[]` — `getConfigSchema`'s `{ key, value }` rows. */
161
+ function redactConfigEntries(data) {
162
+ if (!Array.isArray(data))
163
+ return data;
164
+ return data.map(redactField);
165
+ }
166
+ /**
167
+ * Every device-manager read whose response carries a device `config` blob, and
168
+ * the projection that cuts it.
169
+ *
170
+ * ENUMERATED rather than applied blanket-wise on purpose: a structural
171
+ * "redact anything credential-named" pass over every non-admin response would
172
+ * also strip `turnProvider.getTurnServers().credential` (the browser needs it),
173
+ * `auth.listShareTokens().tokenPrefix`, and a notification rule's
174
+ * `conditions.eventTypeTokens` — breaking live surfaces to fix a leak that
175
+ * lives in one cap.
176
+ *
177
+ * `deviceManager.listPersistedByAddon` is absent by inspection, not omission:
178
+ * `SavedDeviceRowSchema` has no `config` field, confirmed against the live hub.
179
+ * A new device-manager method returning `DeviceInfoSchema` must be added here —
180
+ * `device-config-secret-redaction.spec.ts` pins the list so the addition is a
181
+ * failing test rather than a silent leak.
182
+ */
183
+ exports.NON_ADMIN_CONFIG_REDACTED_METHODS = new Map([
184
+ ['deviceManager.getDevice', redactDeviceInfoSecrets],
185
+ ['deviceManager.listAll', redactDeviceInfoSecrets],
186
+ ['deviceManager.getChildren', redactDeviceInfoSecrets],
187
+ ['deviceManager.getDeviceSettingsAggregate', redactSettingsSections],
188
+ ['deviceManager.getSettingsSchema', redactSettingsSections],
189
+ ['deviceManager.getDeviceAggregate', redactSettingsAggregate],
190
+ ['deviceManager.getConfigSchema', redactConfigEntries],
191
+ ]);
@@ -10,6 +10,7 @@ const system_1 = require("@camstack/system");
10
10
  const server_1 = require("@trpc/server");
11
11
  const superjson_1 = __importDefault(require("superjson"));
12
12
  const cap_route_error_formatter_js_1 = require("./cap-route-error-formatter.js");
13
+ const device_config_secret_redaction_js_1 = require("./device-config-secret-redaction.js");
13
14
  const scope_access_js_1 = require("./scope-access.js");
14
15
  const share_view_access_js_1 = require("./share-view-access.js");
15
16
  const t = server_1.initTRPC.context().create({
@@ -136,23 +137,37 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
136
137
  if (ctx.user.isAdmin) {
137
138
  return next({ ctx: { ...ctx, user: ctx.user } });
138
139
  }
139
- // Hand-written core route no cap entry. Authentication has already
140
- // passed; defer further gating to any explicit `adminProcedure`
141
- // chained on top of this one.
142
- if (!(path in system_1.METHOD_ACCESS_MAP)) {
143
- return next({ ctx: { ...ctx, user: ctx.user } });
140
+ // Everything below this line is a NON-ADMIN principal.
141
+ //
142
+ // A hand-written core route has no cap entry. Authentication has
143
+ // already passed; defer further gating to any explicit
144
+ // `adminProcedure` chained on top of this one. A cap route is
145
+ // scope-matched first.
146
+ if (path in system_1.METHOD_ACCESS_MAP) {
147
+ // Device-scope caps may be gated by a `device:N` scope. Resolve the
148
+ // raw input once so the matcher can read `input.deviceId` without
149
+ // re-doing the Zod parse (tRPC caches the parsed input downstream).
150
+ // The `getDeviceAncestors` hook lets the matcher walk parent → child
151
+ // accessory inheritance (grant on Reolink also covers its siren / PIR).
152
+ const rawInput = await getRawInput();
153
+ const result = (0, scope_access_js_1.checkScopeAccess)(ctx.user.scopes ?? [], path, rawInput, ctx.getDeviceAncestors);
154
+ if (!result.ok) {
155
+ throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
156
+ }
144
157
  }
145
- // Device-scope caps may be gated by a `device:N` scope. Resolve the
146
- // raw input once so the matcher can read `input.deviceId` without
147
- // re-doing the Zod parse (tRPC caches the parsed input downstream).
148
- // The `getDeviceAncestors` hook lets the matcher walk parent child
149
- // accessory inheritance (grant on Reolink also covers its siren / PIR).
150
- const rawInput = await getRawInput();
151
- const result = (0, scope_access_js_1.checkScopeAccess)(ctx.user.scopes ?? [], path, rawInput, ctx.getDeviceAncestors);
152
- if (!result.ok) {
153
- throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
158
+ // A non-admin who is allowed to READ a device is not thereby allowed to
159
+ // read the credential that opens it. `deviceManager.getDevice` &co. are
160
+ // `auth: 'protected'`, so a read-only `category:system` grant reaches
161
+ // them and the raw row carries `config.password` in plaintext for
162
+ // every camera in the deployment. The value is withheld HERE, at the
163
+ // transport boundary, so in-process callers (`ctx.api`, the pipeline
164
+ // orchestrator, the drivers that must actually connect) are unaffected.
165
+ const redactConfigSecrets = device_config_secret_redaction_js_1.NON_ADMIN_CONFIG_REDACTED_METHODS.get(path);
166
+ const out = await next({ ctx: { ...ctx, user: ctx.user } });
167
+ if (redactConfigSecrets && out.ok) {
168
+ return { ...out, data: redactConfigSecrets(out.data) };
154
169
  }
155
- return next({ ctx: { ...ctx, user: ctx.user } });
170
+ return out;
156
171
  });
157
172
  /**
158
173
  * Destructive-ops gate. Adds an explicit admin check on top of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.78",
3
+ "version": "1.2.79",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -33,19 +33,19 @@
33
33
  ]
34
34
  },
35
35
  "dependencies": {
36
- "@camstack/addon-admin-ui": "1.2.38",
36
+ "@camstack/addon-admin-ui": "1.2.39",
37
37
  "@camstack/addon-agent-ui": "1.2.10",
38
38
  "@camstack/addon-auth": "1.2.11",
39
39
  "@camstack/addon-decoder-nodeav": "1.2.9",
40
40
  "@camstack/addon-notifiers": "1.2.13",
41
- "@camstack/addon-pipeline": "1.2.49",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.32",
41
+ "@camstack/addon-pipeline": "1.2.51",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.33",
43
43
  "@camstack/addon-post-analysis": "1.2.54",
44
44
  "@camstack/sdk": "1.2.10",
45
45
  "@camstack/shm-ring": "1.1.9",
46
- "@camstack/system": "1.2.64",
47
- "@camstack/types": "1.2.48",
48
- "@camstack/ui-library": "1.2.35",
46
+ "@camstack/system": "1.2.66",
47
+ "@camstack/types": "1.2.50",
48
+ "@camstack/ui-library": "1.2.36",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",
51
51
  "@fastify/cors": "^11.2.0",