@homebridge-wyze-node24/homebridge-wyze-node24 1.1.0 → 1.1.1
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/config.schema.json +28 -6
- package/package.json +1 -1
- package/src/WyzeSmartHome.js +43 -21
- package/src/accessories/WyzeAccessory.js +5 -3
- package/src/accessories/WyzeMeshLight.js +7 -2
- package/src/security.js +255 -0
package/config.schema.json
CHANGED
|
@@ -16,30 +16,43 @@
|
|
|
16
16
|
},
|
|
17
17
|
"username": {
|
|
18
18
|
"title": "Username (E-Mail Address)",
|
|
19
|
-
"description": "The e-mail address used for your Wyze account",
|
|
19
|
+
"description": "The e-mail address used for your Wyze account. You can also set WYZE_USERNAME env var or use secretsFile.",
|
|
20
20
|
"type": "string",
|
|
21
21
|
"default": "",
|
|
22
22
|
"required": true
|
|
23
23
|
},
|
|
24
24
|
"password": {
|
|
25
25
|
"title": "Password",
|
|
26
|
-
"description": "The password used for your Wyze account",
|
|
26
|
+
"description": "The password used for your Wyze account. You can also set WYZE_PASSWORD env var or use secretsFile.",
|
|
27
27
|
"type": "string",
|
|
28
|
+
"format": "password",
|
|
28
29
|
"default": "",
|
|
29
30
|
"required": true
|
|
30
31
|
},
|
|
31
32
|
"keyId": {
|
|
32
33
|
"title": "Key ID",
|
|
33
|
-
"description": "API Key/ID available from the official Wyze Portal https://developer-api-console.wyze.com",
|
|
34
|
+
"description": "API Key/ID available from the official Wyze Portal https://developer-api-console.wyze.com. You can also set WYZE_KEY_ID env var or use secretsFile.",
|
|
34
35
|
"type": "string",
|
|
36
|
+
"format": "password",
|
|
35
37
|
"required": true
|
|
36
38
|
},
|
|
37
39
|
"apiKey": {
|
|
38
40
|
"title": "API Key",
|
|
39
|
-
"description": "API Key/ID available from the official Wyze Portal https://developer-api-console.wyze.com
|
|
41
|
+
"description": "API Key/ID available from the official Wyze Portal https://developer-api-console.wyze.com. You can also set WYZE_API_KEY env var or use secretsFile.",
|
|
40
42
|
"type": "string",
|
|
43
|
+
"format": "password",
|
|
41
44
|
"required": true
|
|
42
45
|
},
|
|
46
|
+
"secretsFile": {
|
|
47
|
+
"title": "Secrets file path (optional)",
|
|
48
|
+
"description": "Path to a local JSON file containing secrets (e.g., username/password/apiKey/etc). File must be chmod 600 (owner-only) or the plugin will refuse to start.",
|
|
49
|
+
"type": "string",
|
|
50
|
+
"default": "",
|
|
51
|
+
"required": false,
|
|
52
|
+
"condition": {
|
|
53
|
+
"functionBody": "return model.showAdvancedOptions === true;"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
43
56
|
"refreshInterval": {
|
|
44
57
|
"title": "Refresh Interval",
|
|
45
58
|
"description": "Specify the number of milliseconds to wait between updates, default is 60000 ms (60 seconds)",
|
|
@@ -63,7 +76,7 @@
|
|
|
63
76
|
},
|
|
64
77
|
"apiLogEnabled": {
|
|
65
78
|
"title": "Enable API logging",
|
|
66
|
-
"description": "",
|
|
79
|
+
"description": "WARNING: may log request/response details from upstream libraries. Keep OFF unless you understand the risk.",
|
|
67
80
|
"type": "boolean",
|
|
68
81
|
"default": false,
|
|
69
82
|
"condition": {
|
|
@@ -79,6 +92,15 @@
|
|
|
79
92
|
"functionBody": "return model.showAdvancedOptions === true;"
|
|
80
93
|
}
|
|
81
94
|
},
|
|
95
|
+
"dangerouslyAllowCustomBaseUrls": {
|
|
96
|
+
"title": "Dangerously allow custom API base URLs",
|
|
97
|
+
"description": "SECURITY WARNING: If enabled, the plugin will accept authBaseUrl/apiBaseUrl overrides. This can enable SSRF and credential exfiltration. Only enable if you know exactly what you're doing.",
|
|
98
|
+
"type": "boolean",
|
|
99
|
+
"default": false,
|
|
100
|
+
"condition": {
|
|
101
|
+
"functionBody": "return model.showAdvancedOptions === true;"
|
|
102
|
+
}
|
|
103
|
+
},
|
|
82
104
|
"lowBatteryPercentage": {
|
|
83
105
|
"title": "Low Battery Percentage",
|
|
84
106
|
"description": "Specify the Percentage of battery when consider low, default is 30%",
|
|
@@ -204,4 +226,4 @@
|
|
|
204
226
|
},
|
|
205
227
|
"form": null,
|
|
206
228
|
"display": null
|
|
207
|
-
}
|
|
229
|
+
}
|
package/package.json
CHANGED
package/src/WyzeSmartHome.js
CHANGED
|
@@ -3,6 +3,13 @@ const { OutdoorPlugModels, PlugModels, CommonModels, CameraModels, LeakSensorMod
|
|
|
3
3
|
TemperatureHumidityModels, LockModels, MotionSensorModels, ContactSensorModels, LightModels,
|
|
4
4
|
LightStripModels, MeshLightModels, ThermostatModels, S1GatewayModels } = require('./enums')
|
|
5
5
|
|
|
6
|
+
const {
|
|
7
|
+
getValidatedBaseUrls,
|
|
8
|
+
resolveSecrets,
|
|
9
|
+
sanitizeDeviceName,
|
|
10
|
+
wrapLogger
|
|
11
|
+
} = require('./security')
|
|
12
|
+
|
|
6
13
|
const WyzeAPI = require('wyze-api') // Uncomment for Release
|
|
7
14
|
//const WyzeAPI = require('./wyze-api/src') // Comment for Release
|
|
8
15
|
const WyzePlug = require('./accessories/WyzePlug')
|
|
@@ -29,8 +36,8 @@ function delay(ms) {
|
|
|
29
36
|
|
|
30
37
|
module.exports = class WyzeSmartHome {
|
|
31
38
|
constructor(log, config, api) {
|
|
32
|
-
this.log = log
|
|
33
|
-
this.config = config
|
|
39
|
+
this.log = wrapLogger(log)
|
|
40
|
+
this.config = resolveSecrets(config || {}, this.log)
|
|
34
41
|
this.api = api
|
|
35
42
|
this.client = this.getClient()
|
|
36
43
|
|
|
@@ -44,6 +51,8 @@ module.exports = class WyzeSmartHome {
|
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
getClient() {
|
|
54
|
+
const { authBaseUrl, apiBaseUrl } = getValidatedBaseUrls(this.config, this.log)
|
|
55
|
+
|
|
47
56
|
return new WyzeAPI({
|
|
48
57
|
// User login parameters
|
|
49
58
|
username: this.config.username,
|
|
@@ -57,9 +66,9 @@ module.exports = class WyzeSmartHome {
|
|
|
57
66
|
lowBatteryPercentage: this.config.lowBatteryPercentage,
|
|
58
67
|
//Storage Path
|
|
59
68
|
persistPath: homebridge.user.persistPath(),
|
|
60
|
-
//URLs
|
|
61
|
-
authBaseUrl
|
|
62
|
-
apiBaseUrl
|
|
69
|
+
//URLs (strictly validated)
|
|
70
|
+
authBaseUrl,
|
|
71
|
+
apiBaseUrl,
|
|
63
72
|
// App emulation constants
|
|
64
73
|
authApiKey: this.config.authApiKey,
|
|
65
74
|
phoneId: this.config.phoneId,
|
|
@@ -83,14 +92,24 @@ module.exports = class WyzeSmartHome {
|
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
async runLoop() {
|
|
86
|
-
const
|
|
95
|
+
const baseInterval = this.config.refreshInterval || DEFAULT_REFRESH_INTERVAL
|
|
96
|
+
let failures = 0
|
|
97
|
+
|
|
87
98
|
// eslint-disable-next-line no-constant-condition
|
|
88
99
|
while (true) {
|
|
89
100
|
try {
|
|
90
101
|
await this.refreshDevices()
|
|
91
|
-
|
|
102
|
+
failures = 0
|
|
103
|
+
} catch (e) {
|
|
104
|
+
failures += 1
|
|
105
|
+
const message = e?.message || String(e)
|
|
106
|
+
this.log.error(`Refresh loop error: ${message}`)
|
|
107
|
+
}
|
|
92
108
|
|
|
93
|
-
|
|
109
|
+
// simple backoff to avoid noisy retry loops on auth/network failures
|
|
110
|
+
const backoffMultiplier = Math.min(6, failures) // caps at 64x
|
|
111
|
+
const delayMs = baseInterval * (failures === 0 ? 1 : Math.pow(2, backoffMultiplier))
|
|
112
|
+
await delay(delayMs)
|
|
94
113
|
}
|
|
95
114
|
}
|
|
96
115
|
|
|
@@ -102,10 +121,10 @@ module.exports = class WyzeSmartHome {
|
|
|
102
121
|
const timestamp = objectList.ts
|
|
103
122
|
const devices = objectList.data.device_list
|
|
104
123
|
|
|
105
|
-
if (this.config.pluginLoggingEnabled) this.log(`Found ${devices.length} device(s)`)
|
|
124
|
+
if (this.config.pluginLoggingEnabled) this.log(`Found ${devices.length} device(s)`)
|
|
106
125
|
await this.loadDevices(devices, timestamp)
|
|
107
126
|
} catch (e) {
|
|
108
|
-
this.log.error(`Error getting devices: ${e}`)
|
|
127
|
+
this.log.error(`Error getting devices: ${e?.message || e}`)
|
|
109
128
|
throw e
|
|
110
129
|
}
|
|
111
130
|
}
|
|
@@ -122,7 +141,7 @@ module.exports = class WyzeSmartHome {
|
|
|
122
141
|
|
|
123
142
|
const removedAccessories = this.accessories.filter(a => !foundAccessories.includes(a))
|
|
124
143
|
if (removedAccessories.length > 0) {
|
|
125
|
-
if (this.config.pluginLoggingEnabled) this.log(`Removing ${removedAccessories.length} device(s)`)
|
|
144
|
+
if (this.config.pluginLoggingEnabled) this.log(`Removing ${removedAccessories.length} device(s)`)
|
|
126
145
|
const removedHomeKitAccessories = removedAccessories.map(a => a.homeKitAccessory)
|
|
127
146
|
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, removedHomeKitAccessories)
|
|
128
147
|
}
|
|
@@ -131,28 +150,30 @@ module.exports = class WyzeSmartHome {
|
|
|
131
150
|
}
|
|
132
151
|
|
|
133
152
|
async loadDevice(device, timestamp) {
|
|
134
|
-
const
|
|
153
|
+
const safeNickname = sanitizeDeviceName(device.nickname)
|
|
154
|
+
|
|
155
|
+
const accessoryClass = this.getAccessoryClass(device.product_type, device.product_model, device.mac, safeNickname)
|
|
135
156
|
if (!accessoryClass) {
|
|
136
|
-
if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Unsupported device type: (Name: ${
|
|
157
|
+
if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Unsupported device type: (Name: ${safeNickname}) (MAC: ${device.mac}) (Model: ${device.product_model})`)
|
|
137
158
|
return
|
|
138
159
|
}
|
|
139
160
|
else if (this.config.filterByMacAddressList?.find(d => d === device.mac) || this.config.filterDeviceTypeList?.find(d => d === device.product_type)) {
|
|
140
|
-
if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Ignoring (${
|
|
161
|
+
if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Ignoring (${safeNickname}) (MAC: ${device.mac}) because it is in the Ignore Device list`)
|
|
141
162
|
return
|
|
142
163
|
}
|
|
143
164
|
else if (device.product_type == 'S1Gateway' && this.config.hms == false) {
|
|
144
|
-
if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Ignoring (${
|
|
165
|
+
if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Ignoring (${safeNickname}) (MAC: ${device.mac}) because it is not enabled`)
|
|
145
166
|
return
|
|
146
167
|
}
|
|
147
168
|
|
|
148
169
|
|
|
149
170
|
let accessory = this.accessories.find(a => a.matches(device))
|
|
150
171
|
if (!accessory) {
|
|
151
|
-
const homeKitAccessory = this.createHomeKitAccessory(device)
|
|
172
|
+
const homeKitAccessory = this.createHomeKitAccessory(device, safeNickname)
|
|
152
173
|
accessory = new accessoryClass(this, homeKitAccessory)
|
|
153
174
|
this.accessories.push(accessory)
|
|
154
175
|
} else {
|
|
155
|
-
if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Loading accessory from cache ${
|
|
176
|
+
if (this.config.pluginLoggingEnabled) this.log(`[${device.product_type}] Loading accessory from cache ${safeNickname} (MAC: ${device.mac})`)
|
|
156
177
|
}
|
|
157
178
|
accessory.update(device, timestamp)
|
|
158
179
|
|
|
@@ -192,16 +213,16 @@ module.exports = class WyzeSmartHome {
|
|
|
192
213
|
}
|
|
193
214
|
}
|
|
194
215
|
|
|
195
|
-
createHomeKitAccessory(device) {
|
|
216
|
+
createHomeKitAccessory(device, safeNickname) {
|
|
196
217
|
const uuid = UUIDGen.generate(device.mac)
|
|
197
218
|
|
|
198
|
-
const homeKitAccessory = new Accessory(
|
|
219
|
+
const homeKitAccessory = new Accessory(safeNickname, uuid)
|
|
199
220
|
|
|
200
221
|
homeKitAccessory.context = {
|
|
201
222
|
mac: device.mac,
|
|
202
223
|
product_type: device.product_type,
|
|
203
224
|
product_model: device.product_model,
|
|
204
|
-
nickname:
|
|
225
|
+
nickname: safeNickname
|
|
205
226
|
}
|
|
206
227
|
|
|
207
228
|
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [homeKitAccessory])
|
|
@@ -224,7 +245,8 @@ module.exports = class WyzeSmartHome {
|
|
|
224
245
|
try {
|
|
225
246
|
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [homeKitAccessory])
|
|
226
247
|
} catch (error) {
|
|
227
|
-
|
|
248
|
+
const safeName = sanitizeDeviceName(homeKitAccessory.context.nickname)
|
|
249
|
+
this.log.error(`Error removing accessory ${safeName} (MAC: ${homeKitAccessory.context.mac}) : ${error?.message || error}`)
|
|
228
250
|
}
|
|
229
251
|
}
|
|
230
252
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { Service, Characteristic } = require("../types");
|
|
2
|
+
const { sanitizeDeviceName } = require("../security");
|
|
2
3
|
|
|
3
4
|
// Responses from the Wyze API can lag a little after a new value is set
|
|
4
5
|
const UPDATE_THROTTLE_MS = 1000;
|
|
@@ -33,6 +34,7 @@ module.exports = class WyzeAccessory {
|
|
|
33
34
|
|
|
34
35
|
async update(device, timestamp) {
|
|
35
36
|
const productType = device.product_type;
|
|
37
|
+
const safeNickname = sanitizeDeviceName(device.nickname);
|
|
36
38
|
|
|
37
39
|
switch (productType) {
|
|
38
40
|
default:
|
|
@@ -40,14 +42,14 @@ module.exports = class WyzeAccessory {
|
|
|
40
42
|
mac: device.mac,
|
|
41
43
|
product_type: device.product_type,
|
|
42
44
|
product_model: device.product_model,
|
|
43
|
-
nickname:
|
|
45
|
+
nickname: safeNickname,
|
|
44
46
|
};
|
|
45
47
|
break;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
this.homeKitAccessory
|
|
49
51
|
.getService(Service.AccessoryInformation)
|
|
50
|
-
.updateCharacteristic(Characteristic.Name,
|
|
52
|
+
.updateCharacteristic(Characteristic.Name, safeNickname)
|
|
51
53
|
.updateCharacteristic(Characteristic.Manufacturer, "Wyze")
|
|
52
54
|
.updateCharacteristic(Characteristic.Model, device.product_model)
|
|
53
55
|
.updateCharacteristic(Characteristic.SerialNumber, device.mac)
|
|
@@ -82,4 +84,4 @@ module.exports = class WyzeAccessory {
|
|
|
82
84
|
sleep(ms) {
|
|
83
85
|
return new Promise((resolve) => setTimeout(resolve, ms * 1000));
|
|
84
86
|
}
|
|
85
|
-
};
|
|
87
|
+
};
|
|
@@ -81,7 +81,12 @@ module.exports = class WyzeMeshLight extends WyzeAccessory {
|
|
|
81
81
|
) {
|
|
82
82
|
return true;
|
|
83
83
|
} else {
|
|
84
|
-
|
|
84
|
+
// Avoid logging raw API objects; they can contain device IDs / other sensitive fields.
|
|
85
|
+
if (this.plugin.config.pluginLoggingEnabled) {
|
|
86
|
+
this.plugin.log(
|
|
87
|
+
`Encountered invalid property value (pid=${property?.pid}, value=${String(property?.value)})`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
85
90
|
return false;
|
|
86
91
|
}
|
|
87
92
|
}
|
|
@@ -288,4 +293,4 @@ module.exports = class WyzeMeshLight extends WyzeAccessory {
|
|
|
288
293
|
}
|
|
289
294
|
}
|
|
290
295
|
}
|
|
291
|
-
};
|
|
296
|
+
};
|
package/src/security.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const net = require('net')
|
|
3
|
+
|
|
4
|
+
const DEFAULT_AUTH_BASE_URL = 'https://auth-prod.api.wyze.com'
|
|
5
|
+
const DEFAULT_API_BASE_URL = 'https://api.wyzecam.com'
|
|
6
|
+
|
|
7
|
+
const WYZE_ALLOWED_HOSTNAMES = new Set([
|
|
8
|
+
new URL(DEFAULT_AUTH_BASE_URL).hostname,
|
|
9
|
+
new URL(DEFAULT_API_BASE_URL).hostname
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
const SECRET_KEYS = [
|
|
13
|
+
'username',
|
|
14
|
+
'password',
|
|
15
|
+
'mfaCode',
|
|
16
|
+
'keyId',
|
|
17
|
+
'apiKey',
|
|
18
|
+
'authApiKey',
|
|
19
|
+
'phoneId',
|
|
20
|
+
'appName',
|
|
21
|
+
'appVer',
|
|
22
|
+
'appVersion',
|
|
23
|
+
'userAgent',
|
|
24
|
+
'sc',
|
|
25
|
+
'sv',
|
|
26
|
+
'fordAppKey',
|
|
27
|
+
'fordAppSecret',
|
|
28
|
+
'oliveSigningSecret',
|
|
29
|
+
'oliveAppId',
|
|
30
|
+
'appInfo'
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
const ENV_MAP = {
|
|
34
|
+
username: 'WYZE_USERNAME',
|
|
35
|
+
password: 'WYZE_PASSWORD',
|
|
36
|
+
mfaCode: 'WYZE_MFA_CODE',
|
|
37
|
+
keyId: 'WYZE_KEY_ID',
|
|
38
|
+
apiKey: 'WYZE_API_KEY',
|
|
39
|
+
authApiKey: 'WYZE_AUTH_API_KEY',
|
|
40
|
+
phoneId: 'WYZE_PHONE_ID',
|
|
41
|
+
appName: 'WYZE_APP_NAME',
|
|
42
|
+
appVer: 'WYZE_APP_VER',
|
|
43
|
+
appVersion: 'WYZE_APP_VERSION',
|
|
44
|
+
userAgent: 'WYZE_USER_AGENT',
|
|
45
|
+
sc: 'WYZE_SC',
|
|
46
|
+
sv: 'WYZE_SV',
|
|
47
|
+
fordAppKey: 'WYZE_FORD_APP_KEY',
|
|
48
|
+
fordAppSecret: 'WYZE_FORD_APP_SECRET',
|
|
49
|
+
oliveSigningSecret: 'WYZE_OLIVE_SIGNING_SECRET',
|
|
50
|
+
oliveAppId: 'WYZE_OLIVE_APP_ID',
|
|
51
|
+
appInfo: 'WYZE_APP_INFO'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stripControlChars(input) {
|
|
55
|
+
return String(input)
|
|
56
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
57
|
+
.replace(/[\u0000-\u001F\u007F]/g, '')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function boundLength(input, maxLen) {
|
|
61
|
+
const s = String(input)
|
|
62
|
+
if (s.length <= maxLen) return s
|
|
63
|
+
return s.slice(0, maxLen) + '…'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function redactMacs(str) {
|
|
67
|
+
return str.replace(/\b([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}\b/g, (mac) => {
|
|
68
|
+
const parts = mac.split(':')
|
|
69
|
+
return 'xx:xx:xx:xx:xx:' + parts[5]
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function redactBearerTokens(str) {
|
|
74
|
+
return str.replace(/\bBearer\s+[-._~+/0-9a-zA-Z]+=*\b/g, 'Bearer [REDACTED]')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function redactKeyValueSecrets(str) {
|
|
78
|
+
const keys = [
|
|
79
|
+
'password',
|
|
80
|
+
'apiKey',
|
|
81
|
+
'keyId',
|
|
82
|
+
'access_token',
|
|
83
|
+
'refresh_token',
|
|
84
|
+
'accessToken',
|
|
85
|
+
'refreshToken',
|
|
86
|
+
'authorization',
|
|
87
|
+
'fordAppSecret',
|
|
88
|
+
'fordAppKey',
|
|
89
|
+
'oliveSigningSecret',
|
|
90
|
+
'oliveAppId',
|
|
91
|
+
'authApiKey'
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
const keyGroup = keys.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
|
95
|
+
|
|
96
|
+
// Covers JSON-ish and plain logs like `password: ...` or `"password":"..."`
|
|
97
|
+
const re = new RegExp(`(\\b(?:${keyGroup})\\b"?\\s*[:=]\\s*)("?)([^"\\s,}]+)("?)`, 'gi')
|
|
98
|
+
return str.replace(re, (_m, prefix) => `${prefix}[REDACTED]`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sanitizeLogMessage(input) {
|
|
102
|
+
let s = stripControlChars(input)
|
|
103
|
+
s = redactBearerTokens(s)
|
|
104
|
+
s = redactKeyValueSecrets(s)
|
|
105
|
+
s = redactMacs(s)
|
|
106
|
+
return boundLength(s, 2000)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sanitizeDeviceName(name) {
|
|
110
|
+
return boundLength(stripControlChars(name).trim(), 64) || 'Wyze Device'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function wrapLogger(log) {
|
|
114
|
+
const baseFn = typeof log === 'function' ? log : (...args) => log.info(...args)
|
|
115
|
+
|
|
116
|
+
const wrapped = (...args) => baseFn(...args.map(sanitizeLogMessage))
|
|
117
|
+
|
|
118
|
+
for (const level of ['error', 'warn', 'info', 'debug']) {
|
|
119
|
+
const underlying = log?.[level]
|
|
120
|
+
if (typeof underlying === 'function') {
|
|
121
|
+
wrapped[level] = (...args) => underlying(...args.map(sanitizeLogMessage))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return wrapped
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function loadSecretsFromFile(secretsFile, log) {
|
|
129
|
+
const stat = fs.statSync(secretsFile)
|
|
130
|
+
// Must not be readable/writable by group/others
|
|
131
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Secrets file permissions too open: ${secretsFile}. ` +
|
|
134
|
+
'Set mode to 600 (owner read/write only).'
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const raw = fs.readFileSync(secretsFile, 'utf8')
|
|
139
|
+
const parsed = JSON.parse(raw)
|
|
140
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolveSecrets(config, log) {
|
|
144
|
+
const merged = { ...config }
|
|
145
|
+
|
|
146
|
+
if (config?.secretsFile) {
|
|
147
|
+
try {
|
|
148
|
+
const fromFile = loadSecretsFromFile(config.secretsFile, log)
|
|
149
|
+
for (const k of SECRET_KEYS) {
|
|
150
|
+
if (fromFile[k] != null && fromFile[k] !== '') merged[k] = fromFile[k]
|
|
151
|
+
}
|
|
152
|
+
log('Loaded secrets from secretsFile')
|
|
153
|
+
} catch (e) {
|
|
154
|
+
// Refuse to start: this prevents accidentally using a world-readable secrets file.
|
|
155
|
+
log.error(`Failed to load secretsFile: ${e?.message || e}`)
|
|
156
|
+
throw e
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const [key, envName] of Object.entries(ENV_MAP)) {
|
|
161
|
+
const v = process.env[envName]
|
|
162
|
+
if (v == null || v === '') continue
|
|
163
|
+
|
|
164
|
+
if (key === 'appInfo') {
|
|
165
|
+
try {
|
|
166
|
+
merged.appInfo = JSON.parse(v)
|
|
167
|
+
} catch {
|
|
168
|
+
merged.appInfo = v
|
|
169
|
+
}
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
merged[key] = v
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return merged
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isIpLiteral(hostname) {
|
|
180
|
+
return net.isIP(hostname) !== 0
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isClearlyLocalHostname(hostname) {
|
|
184
|
+
const lower = String(hostname).toLowerCase()
|
|
185
|
+
return (
|
|
186
|
+
lower === 'localhost' ||
|
|
187
|
+
lower.endsWith('.localhost') ||
|
|
188
|
+
lower.endsWith('.local')
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validateBaseUrl(urlString) {
|
|
193
|
+
const u = new URL(urlString)
|
|
194
|
+
if (u.protocol !== 'https:') {
|
|
195
|
+
throw new Error(`Base URL must use https: ${urlString}`)
|
|
196
|
+
}
|
|
197
|
+
if (isIpLiteral(u.hostname)) {
|
|
198
|
+
throw new Error(`IP literals are not allowed in base URLs: ${urlString}`)
|
|
199
|
+
}
|
|
200
|
+
if (isClearlyLocalHostname(u.hostname)) {
|
|
201
|
+
throw new Error(`Local hostnames are not allowed in base URLs: ${urlString}`)
|
|
202
|
+
}
|
|
203
|
+
return u
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getValidatedBaseUrls(config, log) {
|
|
207
|
+
const hasCustom = Boolean(config?.authBaseUrl || config?.apiBaseUrl)
|
|
208
|
+
const allowCustom = Boolean(config?.dangerouslyAllowCustomBaseUrls)
|
|
209
|
+
|
|
210
|
+
if (hasCustom && !allowCustom) {
|
|
211
|
+
log.error(
|
|
212
|
+
'Custom authBaseUrl/apiBaseUrl are ignored for security. ' +
|
|
213
|
+
'If you understand the risks and still want this, set dangerouslyAllowCustomBaseUrls=true.'
|
|
214
|
+
)
|
|
215
|
+
return {
|
|
216
|
+
authBaseUrl: DEFAULT_AUTH_BASE_URL,
|
|
217
|
+
apiBaseUrl: DEFAULT_API_BASE_URL
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!hasCustom) {
|
|
222
|
+
return {
|
|
223
|
+
authBaseUrl: DEFAULT_AUTH_BASE_URL,
|
|
224
|
+
apiBaseUrl: DEFAULT_API_BASE_URL
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Custom base URLs only when explicitly opted-in.
|
|
229
|
+
const authUrl = config.authBaseUrl ? validateBaseUrl(config.authBaseUrl) : new URL(DEFAULT_AUTH_BASE_URL)
|
|
230
|
+
const apiUrl = config.apiBaseUrl ? validateBaseUrl(config.apiBaseUrl) : new URL(DEFAULT_API_BASE_URL)
|
|
231
|
+
|
|
232
|
+
// Even when opted-in, refuse non-Wyze hosts by default.
|
|
233
|
+
// This keeps the escape-hatch useful for path tweaks, but blocks credential exfiltration/SSRF.
|
|
234
|
+
if (!WYZE_ALLOWED_HOSTNAMES.has(authUrl.hostname) || !WYZE_ALLOWED_HOSTNAMES.has(apiUrl.hostname)) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Custom base URL hostnames must be one of: ${Array.from(WYZE_ALLOWED_HOSTNAMES).join(', ')}. ` +
|
|
237
|
+
'Refusing to start.'
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
authBaseUrl: authUrl.toString().replace(/\/+$/, ''),
|
|
243
|
+
apiBaseUrl: apiUrl.toString().replace(/\/+$/, '')
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = {
|
|
248
|
+
DEFAULT_AUTH_BASE_URL,
|
|
249
|
+
DEFAULT_API_BASE_URL,
|
|
250
|
+
sanitizeLogMessage,
|
|
251
|
+
sanitizeDeviceName,
|
|
252
|
+
wrapLogger,
|
|
253
|
+
resolveSecrets,
|
|
254
|
+
getValidatedBaseUrls
|
|
255
|
+
}
|