@jasonwch/nodebb-plugin-meilisearch-r 1.2.0 → 1.2.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/lib/client.js +64 -12
- package/lib/hooks/posts.js +9 -3
- package/lib/hooks/topics.js +8 -0
- package/lib/redact.js +131 -0
- package/lib/search.js +24 -5
- package/lib/settings.js +20 -3
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
EMBEDDER_TASK_TIMEOUT_MS, EMBEDDER_TASK_BG_TIMEOUT_MS, EMBEDDER_TASK_BG_INTERVAL_MS,
|
|
13
13
|
MEILI_HTTP_TIMEOUT_MS,
|
|
14
14
|
} = require('./constants');
|
|
15
|
+
const { redactSecrets } = require('./redact');
|
|
15
16
|
|
|
16
17
|
async function ensureIndex(plugin, uid, primaryKey) {
|
|
17
18
|
try {
|
|
@@ -69,15 +70,24 @@ function startEmbedderPoller(plugin, plan, taskUid) {
|
|
|
69
70
|
});
|
|
70
71
|
embedderPollers.delete(taskUid);
|
|
71
72
|
if (task.status !== 'succeeded') {
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
73
|
+
// C1c fix: redact any apiKey that Meilisearch may echo in task.error.message before
|
|
74
|
+
// sending to admin toasts. Issue 2 admin-room filter already limits toast recipients,
|
|
75
|
+
// but defense-in-depth handles the case where Meilisearch wraps the apiKey in the error.
|
|
76
|
+
// A3-style inner try/catch: if redaction itself fails, fall back to raw reason rather
|
|
77
|
+
// than crashing the poller's failure-handling path.
|
|
78
|
+
const rawReason = task.error?.message || `task ${task.status}`;
|
|
79
|
+
let safeReason;
|
|
80
|
+
try {
|
|
81
|
+
safeReason = await redactSecrets(rawReason, plugin);
|
|
82
|
+
} catch {
|
|
83
|
+
safeReason = rawReason;
|
|
84
|
+
}
|
|
75
85
|
await clearAppliedConfig(plugin, plan.indexName);
|
|
76
86
|
plugin.healthy = false;
|
|
77
87
|
plugin.notifyAdmins(`embedder:${plan.indexName}`, {
|
|
78
88
|
type: 'danger',
|
|
79
89
|
titleKey: '[[meilisearch:admin.semanticEmbedderDelayedFailed]]',
|
|
80
|
-
message: `${plan.indexName}: ${
|
|
90
|
+
message: `${plan.indexName}: ${safeReason}`,
|
|
81
91
|
});
|
|
82
92
|
} else {
|
|
83
93
|
// Delayed success: mark taskUid as null (sync-verified) so reattachPollers
|
|
@@ -98,13 +108,30 @@ function startEmbedderPoller(plugin, plan, taskUid) {
|
|
|
98
108
|
return;
|
|
99
109
|
}
|
|
100
110
|
// Network error or other non-timeout failure — task status is genuinely unknown.
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
// C1 fix: redact any apiKey echoed in err.message before logging.
|
|
112
|
+
// A3-style inner try/catch: fall back to raw err.message if redaction itself fails.
|
|
113
|
+
const rawPollerErr = err.message;
|
|
114
|
+
let safePollerErr;
|
|
115
|
+
try {
|
|
116
|
+
safePollerErr = await redactSecrets(rawPollerErr, plugin);
|
|
117
|
+
} catch {
|
|
118
|
+
safePollerErr = rawPollerErr;
|
|
119
|
+
}
|
|
120
|
+
winston.warn(`[plugin/meilisearch] Embedder poller for "${plan.indexName}" (task ${taskUid}) hit an error (non-fatal, fingerprint preserved): ${safePollerErr}`);
|
|
105
121
|
}
|
|
106
122
|
};
|
|
107
|
-
poll().catch(err =>
|
|
123
|
+
poll().catch(async err => {
|
|
124
|
+
// A3: inner try/catch makes the redaction layer airtight against future regressions
|
|
125
|
+
// where redactSecrets itself might throw (currently fully guarded internally, but
|
|
126
|
+
// defense-in-depth ensures the log path still runs).
|
|
127
|
+
let safeCrashErr;
|
|
128
|
+
try {
|
|
129
|
+
safeCrashErr = await redactSecrets(err.message, plugin);
|
|
130
|
+
} catch {
|
|
131
|
+
safeCrashErr = '<redaction failed>';
|
|
132
|
+
}
|
|
133
|
+
winston.error(`[plugin/meilisearch] embedder poller crashed: ${safeCrashErr}`);
|
|
134
|
+
});
|
|
108
135
|
}
|
|
109
136
|
|
|
110
137
|
// Recover embedder pollers after process restart. embedderPollers (line 42) is module-level
|
|
@@ -135,13 +162,22 @@ async function reattachPollers(plugin) {
|
|
|
135
162
|
continue;
|
|
136
163
|
}
|
|
137
164
|
// Task failed during downtime — clear stale fingerprint + alert
|
|
138
|
-
|
|
165
|
+
// C1b fix: redact any apiKey from task.error.message before logging + alerting.
|
|
166
|
+
// A3-style inner try/catch: fall back to raw reason if redaction itself fails.
|
|
167
|
+
const rawFailReason = task.error?.message || `task ${task.status}`;
|
|
168
|
+
let safeFailReason;
|
|
169
|
+
try {
|
|
170
|
+
safeFailReason = await redactSecrets(rawFailReason, plugin);
|
|
171
|
+
} catch {
|
|
172
|
+
safeFailReason = rawFailReason;
|
|
173
|
+
}
|
|
174
|
+
winston.warn(`[plugin/meilisearch] Embedder task ${taskUid} for "${indexName}" failed during downtime: ${safeFailReason}`);
|
|
139
175
|
await clearAppliedConfig(plugin, indexName);
|
|
140
176
|
plugin.healthy = false;
|
|
141
177
|
plugin.notifyAdmins(`embedder:${indexName}`, {
|
|
142
178
|
type: 'danger',
|
|
143
179
|
titleKey: '[[meilisearch:admin.semanticEmbedderDelayedFailed]]',
|
|
144
|
-
message: `${indexName}: ${
|
|
180
|
+
message: `${indexName}: ${safeFailReason}`,
|
|
145
181
|
});
|
|
146
182
|
} catch (err) {
|
|
147
183
|
// Non-fatal: taskUid may be 404 (purged from Meilisearch's ~24h history), or
|
|
@@ -198,6 +234,10 @@ module.exports = function attachClient(plugin) {
|
|
|
198
234
|
if (!plugin.healthy) {
|
|
199
235
|
winston.warn('[plugin/meilisearch] MeiliSearch host is unhealthy');
|
|
200
236
|
} else {
|
|
237
|
+
// B2b: SDK write/health error path — drainPending replay errors may contain
|
|
238
|
+
// document content snippets from already-indexed posts (rare; only if
|
|
239
|
+
// Meilisearch rejects a batch quoting the bad doc). Accepted risk for
|
|
240
|
+
// low-frequency replay failures; document content is already-in-DB data.
|
|
201
241
|
drainPending(plugin).catch(err => winston.error(`[plugin/meilisearch] drain failed: ${err.message}`));
|
|
202
242
|
}
|
|
203
243
|
return plugin.healthy;
|
|
@@ -219,6 +259,8 @@ module.exports = function attachClient(plugin) {
|
|
|
219
259
|
await ensureIndex(plugin, 'chat_message', 'mid');
|
|
220
260
|
await plugin.client.index('chat_message').updateFilterableAttributes(['roomId', 'uid', 'timestamp']);
|
|
221
261
|
} catch (err) {
|
|
262
|
+
// B2b: SDK settings error path — updateFilterableAttributes errors don't typically
|
|
263
|
+
// echo credentials or document content; accepted risk for low-frequency settings failures.
|
|
222
264
|
winston.error(`[plugin/meilisearch] ensureChatMessageIndex failed: ${err.message}`);
|
|
223
265
|
plugin.healthy = false;
|
|
224
266
|
}
|
|
@@ -423,7 +465,17 @@ module.exports = function attachClient(plugin) {
|
|
|
423
465
|
}
|
|
424
466
|
return { ...plan, taskUid: null }; // sync-verified success — no reconciliation needed
|
|
425
467
|
} catch (err) {
|
|
426
|
-
|
|
468
|
+
// C1a fix: redact any apiKey from err.message before logging + alerting.
|
|
469
|
+
// Meilisearch may echo the configured apiKey in 401/403 error responses.
|
|
470
|
+
// A3-style inner try/catch: fall back to raw err.message if redaction itself fails,
|
|
471
|
+
// preserving the catch block's responsibility (alert + return null).
|
|
472
|
+
const rawReason = err.message;
|
|
473
|
+
let reason;
|
|
474
|
+
try {
|
|
475
|
+
reason = await redactSecrets(rawReason, plugin);
|
|
476
|
+
} catch {
|
|
477
|
+
reason = rawReason;
|
|
478
|
+
}
|
|
427
479
|
winston.error(`[plugin/meilisearch] Failed to apply "${plan.indexName}" embedder (semantic search): ${reason}`);
|
|
428
480
|
plugin.notifyAdmins(`embedder:${plan.indexName}`, {
|
|
429
481
|
type: 'danger',
|
package/lib/hooks/posts.js
CHANGED
|
@@ -146,8 +146,11 @@ module.exports = function attachPostHooks(plugin) {
|
|
|
146
146
|
},
|
|
147
147
|
{ batch: REINDEX_BATCH_SIZE },
|
|
148
148
|
);
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// B2b: SDK write error path — may contain document content snippets from already-indexed
|
|
151
|
+
// posts (rare; only if Meilisearch rejects a batch quoting the bad doc). Accepted risk
|
|
152
|
+
// for low-frequency write failures; document content is already-in-DB data.
|
|
153
|
+
winston.error(`[plugin/meilisearch] reindexTopicPosts failed: ${err.message}`);
|
|
151
154
|
plugin.healthy = false;
|
|
152
155
|
await enqueuePending('reindexTopicPosts', { tid });
|
|
153
156
|
}
|
|
@@ -172,7 +175,10 @@ module.exports = function attachPostHooks(plugin) {
|
|
|
172
175
|
await postIndex.deleteDocuments(mainPidArr);
|
|
173
176
|
}
|
|
174
177
|
} catch (err) {
|
|
175
|
-
|
|
178
|
+
// B2b: SDK write error path — may contain document content snippets from already-indexed
|
|
179
|
+
// posts (rare; only if Meilisearch rejects a batch quoting the bad doc). Accepted risk
|
|
180
|
+
// for low-frequency write failures; document content is already-in-DB data.
|
|
181
|
+
winston.error(`[plugin/meilisearch] deindexTopicPosts failed: ${err.message}`);
|
|
176
182
|
plugin.healthy = false;
|
|
177
183
|
await enqueuePending('deindexTopicPosts', { tid });
|
|
178
184
|
}
|
package/lib/hooks/topics.js
CHANGED
|
@@ -147,6 +147,10 @@ module.exports = function attachTopicHooks(plugin) {
|
|
|
147
147
|
postIndex.deleteDocuments(orphanPids));
|
|
148
148
|
}
|
|
149
149
|
} catch (err) {
|
|
150
|
+
// B2b: SDK write error path — may contain document content snippets from
|
|
151
|
+
// already-indexed posts (rare; only if Meilisearch rejects a batch quoting
|
|
152
|
+
// the bad doc). Accepted risk for low-frequency write failures; document
|
|
153
|
+
// content is already-in-DB data.
|
|
150
154
|
winston.error(`[plugin/meilisearch] onTopicMerge orphan cleanup for tid ${tid} failed: ${err.message}`);
|
|
151
155
|
plugin.healthy = false;
|
|
152
156
|
// Don't enqueue — orphans are cleaned by next reindex. Continue to next tid.
|
|
@@ -202,6 +206,10 @@ module.exports = function attachTopicHooks(plugin) {
|
|
|
202
206
|
);
|
|
203
207
|
}));
|
|
204
208
|
} catch (err) {
|
|
209
|
+
// B2b: SDK write error path — may contain document content snippets from
|
|
210
|
+
// already-indexed posts (rare; only if Meilisearch rejects a batch quoting
|
|
211
|
+
// the bad doc). Accepted risk for low-frequency write failures; document
|
|
212
|
+
// content is already-in-DB data.
|
|
205
213
|
winston.error(`[plugin/meilisearch] deindexTopicsPurge failed: ${err.message}`);
|
|
206
214
|
plugin.healthy = false;
|
|
207
215
|
await enqueuePending('deindexTopicsPurge', reducedPayload);
|
package/lib/redact.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Credential redaction for log/alert paths.
|
|
4
|
+
//
|
|
5
|
+
// Background: M5 + C1 fix. Meilisearch task error messages and settings diff logs may
|
|
6
|
+
// echo the configured apiKey (e.g. "Unauthorized: key sk-abc123" or nested in JSON).
|
|
7
|
+
// Without redaction, these flow to:
|
|
8
|
+
// - winston transports (stdout, logs/output.log, /admin/developer/logs)
|
|
9
|
+
// - log aggregators (Loki/ELK/Datadog) which sit OUTSIDE the admin trust boundary
|
|
10
|
+
// - admin ACP toasts (15s transient — already mitigated by Issue 2 admin-room filter)
|
|
11
|
+
//
|
|
12
|
+
// Strategy: dual-layer redaction
|
|
13
|
+
// 1. Pattern-based regex: catches known API key shapes (OpenAI sk-*, Bearer tokens)
|
|
14
|
+
// even when we can't read the configured value (e.g. message logged before init)
|
|
15
|
+
// 2. Value-based substitution: reads semanticSearchApiKey from settings and string-
|
|
16
|
+
// replaces any literal occurrence — catches custom REST tokens, future OpenAI
|
|
17
|
+
// key formats, and exotic auth schemes that don't match the regex.
|
|
18
|
+
//
|
|
19
|
+
// Value-based redaction requires a settings.getOne DB read on first call; cached for
|
|
20
|
+
// 5 min so throttled error paths (notifyAdmins throttles 60s/key) incur ≤1 DB read/min.
|
|
21
|
+
|
|
22
|
+
const settings = nodebb.require('./src/meta/settings');
|
|
23
|
+
|
|
24
|
+
// Fields whose VALUES must never appear in winston diff logs (lib/settings.js isBreaking).
|
|
25
|
+
// Listed by ACP field name. semanticSearchUrl is intentionally NOT included — it's an
|
|
26
|
+
// internal hostname, not a credential, and admins need it for debugging connectivity.
|
|
27
|
+
const SECRET_SETTINGS = new Set([
|
|
28
|
+
'semanticSearchApiKey',
|
|
29
|
+
'semanticSearchRestRequest',
|
|
30
|
+
'semanticSearchRestResponse',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const REDACTION_PLACEHOLDER = '<redacted>';
|
|
34
|
+
|
|
35
|
+
// Patterns to catch common API key formats in free-form error strings.
|
|
36
|
+
// OpenAI keys: sk-*, sk-proj-*, sk-org-*, sk-svcacct-* — all start with "sk-"
|
|
37
|
+
// and are at least 20 chars long (real keys are 40+ but we redact aggressively at 20).
|
|
38
|
+
const KEY_PATTERNS = [
|
|
39
|
+
/\bsk-[a-zA-Z0-9_-]{20,}\b/g,
|
|
40
|
+
/\bBearer\s+[a-zA-Z0-9_-]{8,}\b/gi,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Min length for value-based redaction — avoids replacing short strings that might
|
|
44
|
+
// legitimately appear in error messages (e.g. an apiKey of "ab" would corrupt many
|
|
45
|
+
// error strings). 8 chars is safely above any plausible short substring.
|
|
46
|
+
const MIN_VALUE_REDACT_LENGTH = 8;
|
|
47
|
+
|
|
48
|
+
// Cached configured semanticSearchApiKey to avoid hitting DB on every redact call.
|
|
49
|
+
// TTL ensures we pick up rotations within 5 min. Cached as the raw string value
|
|
50
|
+
// (or '' when no key configured) so we can distinguish "cached empty" from "not cached".
|
|
51
|
+
let _cachedApiKey = null;
|
|
52
|
+
let _cachedAt = 0;
|
|
53
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
54
|
+
|
|
55
|
+
async function getCachedApiKey(plugin) {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
if (_cachedApiKey !== null && now - _cachedAt < CACHE_TTL_MS) {
|
|
58
|
+
return _cachedApiKey;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
// A1 fix: trim the cached value to match the on-wire key (embedder.js get() trims
|
|
62
|
+
// before sending to Meilisearch, so any echo in errors is the trimmed form).
|
|
63
|
+
// Without this, a padded stored key (" sk-abc ") would defeat value-based redaction
|
|
64
|
+
// when Meilisearch echoes the trimmed key ("sk-abc") in a 401 error.
|
|
65
|
+
_cachedApiKey = ((await settings.getOne(plugin.id, 'semanticSearchApiKey')) || '').trim();
|
|
66
|
+
_cachedAt = now;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// A2 fix: don't set _cachedAt on the failure path — let the next call retry immediately
|
|
69
|
+
// instead of cycling through the full 5-min TTL with a stale empty cache. Pattern-based
|
|
70
|
+
// redaction still runs, so sk-* and Bearer-shaped keys stay protected during the blip.
|
|
71
|
+
_cachedApiKey = '';
|
|
72
|
+
}
|
|
73
|
+
return _cachedApiKey;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Test-only hooks (exported for the test sandbox; not consumed by production code paths).
|
|
77
|
+
function _resetCache() {
|
|
78
|
+
_cachedApiKey = null;
|
|
79
|
+
_cachedAt = 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _setCachedApiKey(value) {
|
|
83
|
+
_cachedApiKey = (value || '').trim();
|
|
84
|
+
_cachedAt = Date.now();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Redact known secret patterns + the configured apiKey value from a free-form string.
|
|
88
|
+
// Used by winston log calls and notifyAdmins payloads so credentials don't leak
|
|
89
|
+
// when Meilisearch echoes them in error messages.
|
|
90
|
+
//
|
|
91
|
+
// Returns the redacted string. If `plugin` is omitted, only pattern-based redaction
|
|
92
|
+
// runs (used in early-init paths before plugin object is fully wired).
|
|
93
|
+
async function redactSecrets(message, plugin) {
|
|
94
|
+
if (typeof message !== 'string' || !message) return message;
|
|
95
|
+
let redacted = message;
|
|
96
|
+
|
|
97
|
+
// Layer 1: pattern-based — catches known formats even without DB access
|
|
98
|
+
for (const pattern of KEY_PATTERNS) {
|
|
99
|
+
redacted = redacted.replace(pattern, REDACTION_PLACEHOLDER);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Layer 2: value-based — catches the exact configured apiKey regardless of format
|
|
103
|
+
if (plugin) {
|
|
104
|
+
const configuredKey = await getCachedApiKey(plugin);
|
|
105
|
+
if (configuredKey && configuredKey.length >= MIN_VALUE_REDACT_LENGTH) {
|
|
106
|
+
// Escape regex special chars in the key (keys can contain +, /, = for base64)
|
|
107
|
+
const escapedKey = configuredKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
108
|
+
redacted = redacted.replace(new RegExp(escapedKey, 'g'), REDACTION_PLACEHOLDER);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return redacted;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Whole-value redaction for winston diff logs (lib/settings.js isBreaking).
|
|
116
|
+
// Returns '<redacted>' for credential-bearing fields, JSON.stringify for others.
|
|
117
|
+
// Preserves the "did it change?" audit signal without leaking the credential value.
|
|
118
|
+
function redactSettingForLog(setting, value) {
|
|
119
|
+
if (SECRET_SETTINGS.has(setting)) return REDACTION_PLACEHOLDER;
|
|
120
|
+
return JSON.stringify(value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
redactSecrets,
|
|
125
|
+
redactSettingForLog,
|
|
126
|
+
SECRET_SETTINGS,
|
|
127
|
+
REDACTION_PLACEHOLDER,
|
|
128
|
+
// Test-only exports (not consumed by production code paths):
|
|
129
|
+
_resetCache,
|
|
130
|
+
_setCachedApiKey,
|
|
131
|
+
};
|
package/lib/search.js
CHANGED
|
@@ -5,6 +5,7 @@ const settings = nodebb.require('./src/meta/settings');
|
|
|
5
5
|
const plugins = nodebb.require('./src/plugins');
|
|
6
6
|
const { resolveMinTermLength } = require('./settings-helpers');
|
|
7
7
|
const { EMBEDDER_NAME, isSemanticSearchEnabled, getSemanticRatio, getSemanticScoreThreshold } = require('./embedder');
|
|
8
|
+
const { redactSecrets } = require('./redact');
|
|
8
9
|
|
|
9
10
|
// rankingScoreThreshold is a top-level search parameter (a sibling of "hybrid", not nested
|
|
10
11
|
// inside it) - Meilisearch defaults it to 0 (no filtering) when omitted, which is why
|
|
@@ -29,12 +30,23 @@ async function safeSearch(plugin, indexName, term, opts) {
|
|
|
29
30
|
return await plugin.client.index(indexName).search(term, opts);
|
|
30
31
|
} catch (err) {
|
|
31
32
|
const reason = err?.message || String(err);
|
|
33
|
+
// B1 fix: redact any apiKey Meilisearch may echo in search error responses.
|
|
34
|
+
// Search errors typically return code+message rather than echoing credentials,
|
|
35
|
+
// but uniform posture with the embedder-task paths is cheap defense-in-depth.
|
|
36
|
+
// A3-style inner try/catch preserves safeSearch's "never throw" contract: if
|
|
37
|
+
// redaction itself fails, fall back to the raw reason rather than rejecting.
|
|
38
|
+
let safeReason;
|
|
39
|
+
try {
|
|
40
|
+
safeReason = await redactSecrets(reason, plugin);
|
|
41
|
+
} catch {
|
|
42
|
+
safeReason = reason;
|
|
43
|
+
}
|
|
32
44
|
if (opts.hybrid) {
|
|
33
|
-
winston.error(`[plugin/meilisearch] Hybrid search on "${indexName}" failed (${
|
|
45
|
+
winston.error(`[plugin/meilisearch] Hybrid search on "${indexName}" failed (${safeReason}); falling back to keyword-only search`);
|
|
34
46
|
plugin.notifyAdmins(`search:${indexName}`, {
|
|
35
47
|
type: 'danger',
|
|
36
48
|
titleKey: '[[meilisearch:admin.semanticSearchDegraded]]',
|
|
37
|
-
message: `${indexName}: ${
|
|
49
|
+
message: `${indexName}: ${safeReason}`,
|
|
38
50
|
});
|
|
39
51
|
// rankingScoreThreshold is tuned for hybrid/vector scores - drop it too, or a
|
|
40
52
|
// keyword-only fallback could wrongly filter out valid BM25-style matches.
|
|
@@ -42,15 +54,22 @@ async function safeSearch(plugin, indexName, term, opts) {
|
|
|
42
54
|
try {
|
|
43
55
|
return await plugin.client.index(indexName).search(term, keywordOnly);
|
|
44
56
|
} catch (fallbackErr) {
|
|
45
|
-
|
|
57
|
+
const fallbackReason = fallbackErr?.message || String(fallbackErr);
|
|
58
|
+
let safeFallbackReason;
|
|
59
|
+
try {
|
|
60
|
+
safeFallbackReason = await redactSecrets(fallbackReason, plugin);
|
|
61
|
+
} catch {
|
|
62
|
+
safeFallbackReason = fallbackReason;
|
|
63
|
+
}
|
|
64
|
+
winston.error(`[plugin/meilisearch] Keyword-only fallback search on "${indexName}" also failed: ${safeFallbackReason}`);
|
|
46
65
|
return null;
|
|
47
66
|
}
|
|
48
67
|
}
|
|
49
|
-
winston.error(`[plugin/meilisearch] Search on "${indexName}" failed: ${
|
|
68
|
+
winston.error(`[plugin/meilisearch] Search on "${indexName}" failed: ${safeReason}`);
|
|
50
69
|
plugin.notifyAdmins(`search:${indexName}`, {
|
|
51
70
|
type: 'danger',
|
|
52
71
|
titleKey: '[[meilisearch:admin.semanticSearchDegraded]]',
|
|
53
|
-
message: `${indexName}: ${
|
|
72
|
+
message: `${indexName}: ${safeReason}`,
|
|
54
73
|
});
|
|
55
74
|
return null;
|
|
56
75
|
}
|
package/lib/settings.js
CHANGED
|
@@ -4,6 +4,7 @@ const winston = nodebb.require('winston');
|
|
|
4
4
|
const settings = nodebb.require('./src/meta/settings');
|
|
5
5
|
const { clampMinTermLength } = require('./settings-helpers');
|
|
6
6
|
const { SEMANTIC_SETTING_KEYS, resetAppliedConfigs } = require('./embedder');
|
|
7
|
+
const { redactSettingForLog, redactSecrets } = require('./redact');
|
|
7
8
|
|
|
8
9
|
function deepCompare(a, b) {
|
|
9
10
|
if (a === null || b === null) return a === b;
|
|
@@ -47,7 +48,10 @@ module.exports = function attachSettings(plugin) {
|
|
|
47
48
|
const stored = await settings.getOne(plugin.id, setting);
|
|
48
49
|
const changed = !deepCompare(stored, value);
|
|
49
50
|
if (changed) {
|
|
50
|
-
|
|
51
|
+
// M5 fix: redact credential-bearing fields (apiKey, REST templates) from winston diff log
|
|
52
|
+
// so admin key rotation doesn't leak old+new paid OpenAI keys to output.log/aggregators
|
|
53
|
+
// via the ACP Logs page and any streaming log aggregator (Loki/ELK/Datadog).
|
|
54
|
+
winston.info(`[plugin/meilisearch] ${setting} changed: ${redactSettingForLog(setting, stored)} -> ${redactSettingForLog(setting, value)}`);
|
|
51
55
|
}
|
|
52
56
|
return changed;
|
|
53
57
|
}
|
|
@@ -100,17 +104,30 @@ module.exports = function attachSettings(plugin) {
|
|
|
100
104
|
}
|
|
101
105
|
} catch (err) {
|
|
102
106
|
// #7: Don't throw — let settings persist to DB even when Meili is unreachable.
|
|
103
|
-
|
|
107
|
+
// C1 fix: redact any apiKey that Meilisearch may echo in its error message before
|
|
108
|
+
// logging. Falls back to pattern-only redaction if settings DB is unreachable.
|
|
109
|
+
// A3-style inner try/catch: fall back to raw err.message if redaction itself fails,
|
|
110
|
+
// so the error still surfaces (just unredacted) rather than silently swallowing it.
|
|
111
|
+
const rawReason = err.message;
|
|
112
|
+
let safeReason;
|
|
113
|
+
try {
|
|
114
|
+
safeReason = await redactSecrets(rawReason, plugin);
|
|
115
|
+
} catch {
|
|
116
|
+
safeReason = rawReason;
|
|
117
|
+
}
|
|
118
|
+
winston.error(`[plugin/meilisearch] Error while saving settings: ${safeReason}`);
|
|
104
119
|
plugin.healthy = false;
|
|
105
120
|
// Surface the failure to admins in the ACP — without this, the client sees the
|
|
106
121
|
// "Settings Saved" success toast even when Meilisearch silently rejected the
|
|
107
122
|
// settings/embedder update. Client-side validation (admin.js) is the primary
|
|
108
123
|
// UX path for invalid configs; this catches connection-level failures (e.g.
|
|
109
124
|
// Meilisearch host unreachable after Fix B's 30s HTTP timeout fires).
|
|
125
|
+
// C1 fix: surface the failure to admins without leaking credentials. The error
|
|
126
|
+
// message may contain the apiKey if Meilisearch echoes it in 401 responses.
|
|
110
127
|
plugin.notifyAdmins('settings:save', {
|
|
111
128
|
type: 'danger',
|
|
112
129
|
titleKey: '[[meilisearch:admin.meilisearchError]]',
|
|
113
|
-
message:
|
|
130
|
+
message: safeReason,
|
|
114
131
|
});
|
|
115
132
|
}
|
|
116
133
|
}
|