From 5c4d5f6686f446d2e92221eed1d6b01e89782ec8 Mon Sep 17 00:00:00 2001
From: Michael
Date: Fri, 24 Apr 2026 18:58:51 +0000
Subject: [PATCH] release: v1.0.4
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Security
- Add looks_like_secret() entropy heuristic: vendor regex (AIza, sk-,
ghp_, gho_, Slack xox, Bearer) + length/char-class fallback +
path/whitespace denylist. Defensible hybrid: zero false-positives
on known token formats, catches custom tokens without tripping on
URLs or slugs.
- Gate generic 'key'-named fields and ?key= URL params with the
entropy heuristic. Closes the n8n queryParameters Google-API-key
bypass without false-positives on benign values.
- Entropy fallback in mask_name_value_pair for custom-header value
patterns (X-App-Token etc.) whose names we cannot enumerate.
- Redact credentials[].name per node (id retained), clear
meta.instanceId so exports no longer correlate to the source n8n
instance.
- Opt-in tag clearing at publish time: wizard step 3 checkbox with
the current tag list inline, only shown when tags exist.
- Wizard step 3 now renders a collapsible Reason / Key / Note table
so publishers can verify exactly what was masked before publishing.
Mobile
- touch-action: none on .breznflow-svg to stop the
browser-vs-plugin gesture tug-of-war.
- Rewrote pointer handling as a Map-based multi-pointer state
machine with { passive: false } listeners: single-finger pan is
now smooth on iOS and Android, pinch-to-zoom anchored at the
finger midpoint, double-tap toggles 100/200 % zoom.
- Minimap ported to pointer events + setPointerCapture — tap and
drag navigation work on touch.
Docs
- Expand Sensitive Data Masking section of both READMEs to describe
the 1.0.4 passes and the opt-in tag removal.
- Version badge 1.0.3 -> 1.0.4.
Co-Authored-By: Claude Opus 4.7
---
README.de.md | 14 +-
README.md | 14 +-
breznflow/assets/renderer.css | 7 +-
breznflow/assets/renderer.js | 187 +++++++++++++++---
breznflow/breznflow.php | 4 +-
breznflow/includes/Admin/WizardPage.php | 18 ++
.../includes/Admin/views/wizard-step-3.php | 65 +++++-
breznflow/includes/Security/MaskingRules.php | 98 +++++++++
.../includes/Security/WorkflowSanitizer.php | 95 +++++++++
breznflow/readme.txt | 15 +-
10 files changed, 479 insertions(+), 38 deletions(-)
diff --git a/README.de.md b/README.de.md
index b667f96..925cd41 100644
--- a/README.de.md
+++ b/README.de.md
@@ -3,7 +3,7 @@



-
+
🇬🇧 [English version → README.md](README.md)
@@ -167,17 +167,25 @@ Jede Aktion ist global in den Einstellungen steuerbar und per Shortcode übersch
### Maskierung sensibler Daten
-BreznFlow speichert nie das rohe Workflow-JSON. Vor dem Speichern läuft eine Zwei-Pass-Sanitierung:
+BreznFlow speichert nie das rohe Workflow-JSON. Vor dem Speichern läuft eine Drei-Pass-Sanitierung:
**Pass 1 — String-Sanitierung:** Alle Strings durchlaufen `sanitize_text_field()`. Ausnahme: `jsCode`-Felder bleiben erhalten, werden aber mit `esc_html()` ausgegeben (nie ausgeführt).
**Pass 2 — Secret-Erkennung:**
- **URL-Parameter:** `api_key`, `token`, `secret`, `password`, `access_token`, `auth`, `client_secret` in Query-Strings → `[REDACTED]`
+- **Generische `?key=`-URL-Parameter** *(1.0.4)*: werden nur redactet, wenn der gefangene Wert `looks_like_secret()` matcht — schließt die Google-API-Key-Lücke ohne False-Positives auf harmlosen Werten.
- **Header-Werte:** Authorization, Bearer, X-API-Key und ähnliche Header-Namen in `{name, value}`-Paaren → Wert maskiert
+- **Wert-Entropie-Fallback** *(1.0.4)*: `{name, value}`-Paare, deren Name die Allowlist nicht matcht, werden dennoch maskiert, wenn der Wert selbst secret-förmig aussieht — deckt Custom-Header (`X-App-Token`) und n8ns generisches `queryParameters.key`-Pattern ab.
+- **Bekannte Vendor-Token** *(1.0.4)*: `looks_like_secret()` matcht `AIza…`, `sk-…`, `ghp_…`, `gho_…`, Slack `xox…`, `Bearer …` (JWT) — plus Längen- und Zeichenklassen-Entropie-Fallback sowie Pfad-/Whitespace-Denylist gegen False-Positives.
- **Hochentropie-Bedingungen:** Werte in IF/Switch-Conditions, die UUID-Muster, Groß-/Kleinschreibung+Ziffern oder lange Strings ohne Leerzeichen matchen → per Entropie-Heuristik maskiert
+- **Credential-Anzeigenamen** *(1.0.4)*: `credentials[].name` pro Node wird durch `[REDACTED]` ersetzt (die `id` bleibt — sie referenziert die n8n-DB und ist ohne den Server wertlos).
-Ein **Maskierungs-Protokoll** zeichnet jedes maskierte Element mit Grund, Key und Hinweis auf — sichtbar in der Wizard-Vorschau (Schritt 3).
+**Pass 3 — Identifizierende Metadaten** *(1.0.4)*: `meta.instanceId` wird geleert, damit Workflow-Exporte nicht mehr der ausgebenden n8n-Instanz zugeordnet werden können.
+
+**Optional — Tag-Entfernung** *(1.0.4)*: Wizard Schritt 3 bietet eine Opt-in-Checkbox zum Entfernen von Workflow-Tags. Tags sind oft harmlos (`production`, `v2`), manchmal aber identifizierend — der Publisher entscheidet pro Workflow.
+
+Ein **Maskierungs-Protokoll** zeichnet jedes maskierte Element mit Grund, Key und Hinweis auf. Schritt 3 zeigt es als ausklappbare Grund / Key / Hinweis-Tabelle, damit der Publisher vor Publish genau prüfen kann was veröffentlicht wird.
---
diff --git a/README.md b/README.md
index 4ddc48f..c1f5041 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@



-
+
🇩🇪 [Deutsche Version → README.de.md](README.de.md)
@@ -167,17 +167,25 @@ Each action can be toggled globally in settings and overridden per shortcode.
### Sensitive Data Masking
-BreznFlow never stores raw workflow JSON. Before saving, a two-pass sanitization runs:
+BreznFlow never stores raw workflow JSON. Before saving, a three-pass sanitization runs:
**Pass 1 — String sanitization:** All string values pass through `sanitize_text_field()`. Exception: `jsCode` fields are preserved as-is but displayed with `esc_html()` (never executed).
**Pass 2 — Secret detection:**
- **URL parameters:** `api_key`, `token`, `secret`, `password`, `access_token`, `auth`, `client_secret` in query strings → `[REDACTED]`
+- **Generic `?key=` URL params** *(1.0.4)*: redacted only when the captured value matches `looks_like_secret()` — closes the Google API key bypass without false-positives on harmless values.
- **Header values:** Authorization, Bearer, X-API-Key and similar header names in `{name, value}` pairs → value masked
+- **Value-entropy fallback** *(1.0.4)*: `{name, value}` pairs whose name does not match the allowlist are still masked when the value itself looks secret-shaped — covers custom headers (`X-App-Token`) and n8n's `queryParameters` generic-`key` pattern.
+- **Known vendor tokens** *(1.0.4)*: `looks_like_secret()` matches `AIza…`, `sk-…`, `ghp_…`, `gho_…`, Slack `xox…`, `Bearer …` (JWT) — with a length+char-class entropy fallback and a path/whitespace denylist for false-positive control.
- **High-entropy conditions:** Values in IF/Switch conditions that match UUID patterns, mixed-case+digits, or long strings without spaces → masked via entropy heuristic
+- **Credential display names** *(1.0.4)*: `credentials[].name` is replaced with `[REDACTED]` per node (the credential `id` is retained — it references the n8n DB and is useless without the server).
-A **mask log** records every masked item with the reason, key, and note — shown in the wizard's Step 3 preview.
+**Pass 3 — Identifying metadata** *(1.0.4)*: `meta.instanceId` is cleared so workflow exports cannot be correlated to the originating n8n instance.
+
+**Optional — Tag removal** *(1.0.4)*: Wizard step 3 offers an opt-in checkbox to strip workflow tags. Tags are often innocuous (`production`, `v2`) but sometimes identifying — the publisher decides per workflow.
+
+A **mask log** records every masked item with reason, key, and note. Step 3 shows it as a collapsible Reason / Key / Note table so the publisher can review exactly what will be published.
---
diff --git a/breznflow/assets/renderer.css b/breznflow/assets/renderer.css
index 32adca9..1e2f03f 100644
--- a/breznflow/assets/renderer.css
+++ b/breznflow/assets/renderer.css
@@ -84,6 +84,11 @@
display: block;
width: 100%;
overflow: visible;
+ /* Claim all touch gestures on the diagram: single-finger pan, two-finger
+ pinch, double-tap. Trade-off: starting a touch on the SVG means page
+ scroll is blocked until the finger lifts. Page scroll around the
+ diagram (container margins) keeps default behavior. */
+ touch-action: none;
}
/* Canvas (zoom/pan transform applied here) */
@@ -407,7 +412,7 @@
z-index: 5;
}
-.breznflow-minimap svg { display: block; }
+.breznflow-minimap svg { display: block; touch-action: none; }
/* Infobox node badges — interactive highlight */
.breznflow-infobox-node {
diff --git a/breznflow/assets/renderer.js b/breznflow/assets/renderer.js
index 41182cf..04ec92f 100644
--- a/breznflow/assets/renderer.js
+++ b/breznflow/assets/renderer.js
@@ -368,9 +368,16 @@
onNavigate(pos.x / fitScale, pos.y / fitScale);
}
- svg.addEventListener('mousedown', function(e) { dragging = true; navigate(e); e.stopPropagation(); });
- svg.addEventListener('mousemove', function(e) { if (dragging) navigate(e); });
- document.addEventListener('mouseup', function() { dragging = false; });
+ svg.addEventListener('pointerdown', function(e) {
+ dragging = true;
+ navigate(e);
+ e.stopPropagation();
+ if (e.cancelable) e.preventDefault();
+ try { svg.setPointerCapture(e.pointerId); } catch (_) { /* iOS quirk */ }
+ }, { passive: false });
+ svg.addEventListener('pointermove', function(e) { if (dragging) navigate(e); }, { passive: false });
+ svg.addEventListener('pointerup', function() { dragging = false; });
+ svg.addEventListener('pointercancel', function() { dragging = false; });
return {
el: wrap,
@@ -1095,25 +1102,11 @@
self._applyTransform();
}, { passive: false });
- svg.addEventListener('pointerdown', function(e) {
- const t = e.target;
- const isBg = t === svg ||
- (t.getAttribute && (t.getAttribute('class') === 'breznflow-canvas' || t.getAttribute('class') === 'breznflow-connection-path'));
- if (!isBg) return;
- self.isPanning = true;
- self.panStart = { x: e.clientX - self.tx, y: e.clientY - self.ty };
- svg.setPointerCapture(e.pointerId);
- });
-
- svg.addEventListener('pointermove', function(e) {
- if (!self.isPanning) return;
- self.tx = e.clientX - self.panStart.x;
- self.ty = e.clientY - self.panStart.y;
- self._applyTransform();
- });
-
- svg.addEventListener('pointerup', function() { self.isPanning = false; });
- svg.addEventListener('pointercancel', function() { self.isPanning = false; });
+ self._initPointerState();
+ svg.addEventListener('pointerdown', function(e) { self._onPointerDown(e); }, { passive: false });
+ svg.addEventListener('pointermove', function(e) { self._onPointerMove(e); }, { passive: false });
+ svg.addEventListener('pointerup', function(e) { self._onPointerUp(e); }, { passive: false });
+ svg.addEventListener('pointercancel', function(e) { self._onPointerUp(e); }, { passive: false });
svg.addEventListener('click', function(e) {
const t = e.target;
@@ -1132,6 +1125,156 @@
});
};
+ BreznFlowRenderer.prototype._initPointerState = function() {
+ this._pointers = new Map();
+ this._lastTap = { t: 0, x: 0, y: 0 };
+ this._pinchStart = null;
+ };
+
+ BreznFlowRenderer.prototype._isBgTarget = function(t) {
+ if (!t) return false;
+ if (t === this._svg) return true;
+ if (!t.getAttribute) return false;
+ const cls = t.getAttribute('class');
+ return cls === 'breznflow-canvas' || cls === 'breznflow-connection-path';
+ };
+
+ BreznFlowRenderer.prototype._onPointerDown = function(e) {
+ const isBg = this._isBgTarget(e.target);
+ const rect = this._svg.getBoundingClientRect();
+ const px = e.clientX - rect.left;
+ const py = e.clientY - rect.top;
+
+ // Double-tap on empty canvas toggles zoom. Must run before pointer is
+ // added to the map so pointers.size===0 check is meaningful.
+ if (isBg && this._pointers.size === 0) {
+ const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
+ const dt = now - this._lastTap.t;
+ const dx = px - this._lastTap.x;
+ const dy = py - this._lastTap.y;
+ if (dt < 300 && (dx * dx + dy * dy) < 900) {
+ this._lastTap = { t: 0, x: 0, y: 0 };
+ if (e.cancelable) e.preventDefault();
+ this._handleDoubleTap(px, py);
+ return;
+ }
+ this._lastTap = { t: now, x: px, y: py };
+ }
+
+ this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+
+ // Non-bg targets (nodes, badges) fall through to default click handling.
+ if (!isBg) return;
+ if (e.cancelable) e.preventDefault();
+
+ try { this._svg.setPointerCapture(e.pointerId); } catch (_) { /* iOS Safari quirk */ }
+
+ if (this._pointers.size === 1) {
+ this.isPanning = true;
+ this.panStart = { x: e.clientX - this.tx, y: e.clientY - this.ty };
+ } else if (this._pointers.size === 2) {
+ this.isPanning = false;
+ this._pinchStart = this._computePinchState();
+ }
+ };
+
+ BreznFlowRenderer.prototype._onPointerMove = function(e) {
+ if (!this._pointers.has(e.pointerId)) return;
+ this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+
+ if (this._pinchStart && this._pointers.size === 2) {
+ this._applyPinch();
+ } else if (this.isPanning && this._pointers.size === 1) {
+ this.tx = e.clientX - this.panStart.x;
+ this.ty = e.clientY - this.panStart.y;
+ this._applyTransform();
+ }
+ };
+
+ BreznFlowRenderer.prototype._onPointerUp = function(e) {
+ if (!this._pointers.delete(e.pointerId)) return;
+
+ if (this._pinchStart && this._pointers.size < 2) {
+ this._pinchStart = null;
+ // Transitioning 2→1 fingers: re-seat pan-start on the remaining
+ // pointer so the surviving finger doesn't cause a jump.
+ if (this._pointers.size === 1) {
+ const remaining = this._pointers.values().next().value;
+ this.isPanning = true;
+ this.panStart = { x: remaining.x - this.tx, y: remaining.y - this.ty };
+ }
+ }
+
+ if (this._pointers.size === 0) {
+ this.isPanning = false;
+ }
+ };
+
+ BreznFlowRenderer.prototype._computePinchState = function() {
+ const pts = Array.from(this._pointers.values());
+ const p1 = pts[0], p2 = pts[1];
+ const dx = p2.x - p1.x;
+ const dy = p2.y - p1.y;
+ return {
+ d: Math.max(1, Math.sqrt(dx * dx + dy * dy)),
+ cx: (p1.x + p2.x) / 2,
+ cy: (p1.y + p2.y) / 2,
+ tx: this.tx,
+ ty: this.ty,
+ userZoom: this.userZoom
+ };
+ };
+
+ BreznFlowRenderer.prototype._applyPinch = function() {
+ if (!this._pinchStart) return;
+ const pts = Array.from(this._pointers.values());
+ if (pts.length !== 2) return;
+ const p1 = pts[0], p2 = pts[1];
+ const dx = p2.x - p1.x;
+ const dy = p2.y - p1.y;
+ const d = Math.max(1, Math.sqrt(dx * dx + dy * dy));
+ const cx = (p1.x + p2.x) / 2;
+ const cy = (p1.y + p2.y) / 2;
+
+ const ratio = d / this._pinchStart.d;
+ const newZoom = Math.max(MIN_SCALE * 100, Math.min(MAX_SCALE * 100, this._pinchStart.userZoom * ratio));
+ const baseScale = this.layout && this.layout.scale ? this.layout.scale : 1;
+ const oldS = baseScale * (this._pinchStart.userZoom / 100);
+ const newS = baseScale * (newZoom / 100);
+ if (oldS === 0) return;
+
+ // Two moves compounded: (1) zoom anchored at the start-center in SVG
+ // coords, (2) pan by how far the current center drifted from the
+ // start-center. Order matters — zoom first, then translate.
+ const rect = this._svg.getBoundingClientRect();
+ const sx = this._pinchStart.cx - rect.left;
+ const sy = this._pinchStart.cy - rect.top;
+ const dxCenter = cx - this._pinchStart.cx;
+ const dyCenter = cy - this._pinchStart.cy;
+
+ this.tx = sx - (sx - this._pinchStart.tx) * (newS / oldS) + dxCenter;
+ this.ty = sy - (sy - this._pinchStart.ty) * (newS / oldS) + dyCenter;
+ this.userZoom = newZoom;
+ if (this.zoomLabel) this.zoomLabel.textContent = Math.round(newZoom) + '%';
+ this._applyTransform();
+ };
+
+ BreznFlowRenderer.prototype._handleDoubleTap = function(px, py) {
+ // Toggle 100↔200 %, zoom-anchored at tap point (identical math to
+ // wheel-zoom above — keeps the world point under the cursor fixed).
+ const newZoom = this.userZoom > 150 ? 100 : 200;
+ const baseScale = this.layout && this.layout.scale ? this.layout.scale : 1;
+ const oldS = baseScale * (this.userZoom / 100);
+ const newS = baseScale * (newZoom / 100);
+ if (oldS === 0) return;
+
+ this.tx = px - (px - this.tx) * (newS / oldS);
+ this.ty = py - (py - this.ty) * (newS / oldS);
+ this.userZoom = newZoom;
+ if (this.zoomLabel) this.zoomLabel.textContent = Math.round(newZoom) + '%';
+ this._applyTransform();
+ };
+
BreznFlowRenderer.prototype._toggleFullscreen = function(btn) {
if (this._fsActive) {
this._exitFullscreen(btn);
diff --git a/breznflow/breznflow.php b/breznflow/breznflow.php
index e6a205e..a1ac94b 100644
--- a/breznflow/breznflow.php
+++ b/breznflow/breznflow.php
@@ -8,7 +8,7 @@
* Plugin Name: BreznFlow
* Plugin URI: https://breznflow.com/
* Description: Display n8n automation workflows with an interactive SVG diagram, node detail panel, and sensitive data masking.
- * Version: 1.0.3
+ * Version: 1.0.4
* Requires at least: 6.0
* Requires PHP: 8.0
* Author: NoSchmarrn.dev
@@ -23,7 +23,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
-define( 'BREZNFLOW_VERSION', '1.0.3' );
+define( 'BREZNFLOW_VERSION', '1.0.4' );
define( 'BREZNFLOW_FILE', __FILE__ );
define( 'BREZNFLOW_DIR', plugin_dir_path( __FILE__ ) );
define( 'BREZNFLOW_URL', plugin_dir_url( __FILE__ ) );
diff --git a/breznflow/includes/Admin/WizardPage.php b/breznflow/includes/Admin/WizardPage.php
index 833d5e4..ab4fcf5 100644
--- a/breznflow/includes/Admin/WizardPage.php
+++ b/breznflow/includes/Admin/WizardPage.php
@@ -404,6 +404,24 @@ class WizardPage {
wp_die( esc_html__( 'Invalid workflow ID.', 'breznflow' ) );
}
+ // Opt-in tag removal. Runs against the already-sanitized stored JSON,
+ // so there is no risk of undoing the pass-1/2/3 masking from step 1.
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above
+ if ( ! empty( $_POST['breznflow_strip_tags'] ) ) {
+ $raw_json = get_post_meta( $post_id, '_breznflow_raw_json', true );
+ $decoded = $raw_json ? json_decode( $raw_json, true ) : null;
+ if ( is_array( $decoded ) ) {
+ $mask_log_raw = get_post_meta( $post_id, '_breznflow_mask_log', true );
+ $mask_log = $mask_log_raw ? json_decode( $mask_log_raw, true ) : array();
+ if ( ! is_array( $mask_log ) ) {
+ $mask_log = array();
+ }
+ $stripped = WorkflowSanitizer::strip_workflow_tags( $decoded, $mask_log );
+ update_post_meta( $post_id, '_breznflow_raw_json', wp_slash( wp_json_encode( $stripped ) ) );
+ update_post_meta( $post_id, '_breznflow_mask_log', wp_slash( wp_json_encode( $mask_log ) ) );
+ }
+ }
+
wp_update_post(
array(
'ID' => $post_id,
diff --git a/breznflow/includes/Admin/views/wizard-step-3.php b/breznflow/includes/Admin/views/wizard-step-3.php
index d6594d6..7fee8e7 100644
--- a/breznflow/includes/Admin/views/wizard-step-3.php
+++ b/breznflow/includes/Admin/views/wizard-step-3.php
@@ -30,15 +30,25 @@ $meta_zoom = (int) get_post_meta( $post_id, '_breznflow_default_zoom', true )
$zoom = $meta_zoom ? $meta_zoom : 100;
$show_infobox = (int) get_post_meta( $post_id, '_breznflow_show_infobox', true );
-// Check for code nodes with jsCode.
+// Check for code nodes with jsCode and collect workflow tag names.
$has_code_nodes = false;
+$workflow_tags = array();
if ( $raw_json ) {
$data = json_decode( $raw_json, true );
- if ( is_array( $data ) && ! empty( $data['nodes'] ) ) {
- foreach ( $data['nodes'] as $node ) {
- if ( isset( $node['parameters']['jsCode'] ) ) {
- $has_code_nodes = true;
- break;
+ if ( is_array( $data ) ) {
+ if ( ! empty( $data['nodes'] ) ) {
+ foreach ( $data['nodes'] as $node ) {
+ if ( isset( $node['parameters']['jsCode'] ) ) {
+ $has_code_nodes = true;
+ break;
+ }
+ }
+ }
+ if ( ! empty( $data['tags'] ) && is_array( $data['tags'] ) ) {
+ foreach ( $data['tags'] as $tag ) {
+ if ( is_array( $tag ) && isset( $tag['name'] ) && '' !== $tag['name'] ) {
+ $workflow_tags[] = (string) $tag['name'];
+ }
}
}
}
@@ -142,6 +152,29 @@ $preview_theme = in_array( $saved_theme, $allowed_themes, true ) ? $saved_them
);
?>
+
+
+
+
+
+
+ |
+ |
+ |
+
+
+
+
+
+ |
+ |
+ |
+
+
+
+
+
+
@@ -154,6 +187,26 @@ $preview_theme = in_array( $saved_theme, $allowed_themes, true ) ? $saved_them
+
+
+
+
+
+
+
+
+
+
+
'generic_key_with_entropy',
+ 'key' => $field_key,
+ 'note' => 'Generic key-named field holds secret-shaped value.',
+ );
+ return '[REDACTED]';
+ }
+
return $value;
}
+ /**
+ * Heuristically detects whether a string value looks like a secret.
+ *
+ * Defense-in-depth: complements the name-based filter in
+ * mask_sensitive_field() by catching generic fields (e.g. n8n's
+ * queryParameters `{name:"key"}`) and custom headers whose names we
+ * cannot enumerate.
+ *
+ * Matches:
+ * - Known vendor tokens: `AIzaSy…`, `sk-proj-…`, `ghp_…`, `Bearer eyJ…`
+ * - Opaque high-entropy strings: ≥30 chars, mixed classes,
+ * no whitespace, no path separators.
+ *
+ * Skips (false-positive control):
+ * - URLs / filesystem paths (contain `/`)
+ * - Strings with whitespace (not vendor-matched)
+ * - Simple slugs like `weather_berlin`, `my-plugin`
+ *
+ * @since 1.0.4
+ * @param string $value Candidate value.
+ * @return bool True if the value has secret-like characteristics.
+ */
+ public static function looks_like_secret( string $value ): bool {
+ $len = strlen( $value );
+ if ( $len < 20 ) {
+ return false;
+ }
+
+ foreach ( self::HIGH_ENTROPY_PATTERNS as $pattern ) {
+ if ( 1 === preg_match( $pattern, $value ) ) {
+ return true;
+ }
+ }
+
+ if ( $len < 30 ) {
+ return false;
+ }
+ if ( 1 === preg_match( '/[\s\/]/', $value ) ) {
+ return false;
+ }
+
+ $classes = (int) (bool) preg_match( '/[a-z]/', $value );
+ $classes += (int) (bool) preg_match( '/[A-Z]/', $value );
+ $classes += (int) (bool) preg_match( '/[0-9]/', $value );
+
+ return $classes >= 2;
+ }
+
/**
* Applies condition rightValue heuristic masking.
* Used specifically for condition node parameter values.
diff --git a/breznflow/includes/Security/WorkflowSanitizer.php b/breznflow/includes/Security/WorkflowSanitizer.php
index eb5a6ad..13969a3 100644
--- a/breznflow/includes/Security/WorkflowSanitizer.php
+++ b/breznflow/includes/Security/WorkflowSanitizer.php
@@ -39,16 +39,100 @@ class WorkflowSanitizer {
if ( isset( $sanitized['nodes'] ) && is_array( $sanitized['nodes'] ) ) {
foreach ( $sanitized['nodes'] as &$node ) {
$node = $this->mask_node( $node );
+ $node = $this->mask_node_credentials( $node );
}
unset( $node );
}
+ // Pass 3: Strip identifying metadata that ties a workflow to a
+ // specific n8n instance. The value is not a secret per se, but it
+ // correlates workflows across leaks.
+ if ( isset( $sanitized['meta'] ) && is_array( $sanitized['meta'] )
+ && array_key_exists( 'instanceId', $sanitized['meta'] ) ) {
+ $this->mask_log[] = array(
+ 'reason' => 'meta_instance_id',
+ 'key' => 'meta.instanceId',
+ 'note' => 'n8n instance identifier cleared.',
+ );
+ $sanitized['meta']['instanceId'] = null;
+ }
+
return array(
'data' => $sanitized,
'mask_log' => $this->mask_log,
);
}
+ /**
+ * Removes workflow tags (opt-in from wizard step 3).
+ *
+ * Tags in n8n are organisational labels (e.g. "production", "Donau") that
+ * can be either innocuous or identifying. Unlike credentials, there is no
+ * reliable heuristic to separate the two, so this is user-controlled and
+ * runs only when the publisher explicitly opts in.
+ *
+ * Static and stateless so callers (WizardPage::handle_publish) can invoke
+ * it without constructing a new Sanitizer instance.
+ *
+ * @since 1.0.4
+ * @param array $data Workflow data.
+ * @param array $mask_log Passed by reference — removal entries appended.
+ * @return array Data with tags cleared.
+ */
+ public static function strip_workflow_tags( array $data, array &$mask_log ): array {
+ if ( empty( $data['tags'] ) || ! is_array( $data['tags'] ) ) {
+ return $data;
+ }
+
+ foreach ( $data['tags'] as $tag ) {
+ $name = is_array( $tag ) && isset( $tag['name'] ) ? (string) $tag['name'] : '';
+ $mask_log[] = array(
+ 'reason' => 'tags_cleared',
+ 'key' => 'tags[].name',
+ 'note' => '' !== $name
+ ? 'Tag "' . esc_html( $name ) . '" removed.'
+ : 'Tag removed.',
+ );
+ }
+
+ $data['tags'] = array();
+ return $data;
+ }
+
+ /**
+ * Redacts credential display names on a node.
+ *
+ * n8n stores node credentials as an associative map keyed by credential
+ * type, each with `{ id, name }`. The `id` refers to the n8n DB and is
+ * useless without that server — we keep it so round-tripping stays intact.
+ * The `name` is user-chosen (e.g. "Donau", "Privater OpenAI") and leaks
+ * organisational context. We replace it with `[REDACTED]`.
+ *
+ * @since 1.0.4
+ * @param array $node Single workflow node array.
+ * @return array Node with credential names redacted.
+ */
+ private function mask_node_credentials( array $node ): array {
+ if ( ! isset( $node['credentials'] ) || ! is_array( $node['credentials'] ) ) {
+ return $node;
+ }
+
+ foreach ( $node['credentials'] as $cred_type => &$cred ) {
+ if ( is_array( $cred ) && isset( $cred['name'] ) && is_string( $cred['name'] )
+ && '' !== $cred['name'] && '[REDACTED]' !== $cred['name'] ) {
+ $this->mask_log[] = array(
+ 'reason' => 'credential_name',
+ 'key' => (string) $cred_type . '.name',
+ 'note' => 'Credential display name redacted (id retained).',
+ );
+ $cred['name'] = '[REDACTED]';
+ }
+ }
+ unset( $cred );
+
+ return $node;
+ }
+
/**
* Recursively sanitizes all string values in the data.
* jsCode is preserved as-is (displayed with esc_html(), never executed).
@@ -156,5 +240,16 @@ class WorkflowSanitizer {
return;
}
}
+
+ // Entropy fallback: catches custom-header names we cannot enumerate
+ // (e.g. X-App-Token) when the value itself looks secret-shaped.
+ if ( MaskingRules::looks_like_secret( (string) $item['value'] ) ) {
+ $this->mask_log[] = array(
+ 'reason' => 'value_entropy',
+ 'key' => 'value',
+ 'note' => 'Parameter "' . esc_html( $item['name'] ) . '" has secret-shaped value.',
+ );
+ $item['value'] = '[REDACTED]';
+ }
}
}
diff --git a/breznflow/readme.txt b/breznflow/readme.txt
index a7ecb1e..389f277 100644
--- a/breznflow/readme.txt
+++ b/breznflow/readme.txt
@@ -3,7 +3,7 @@ Contributors: mifupadev
Tags: n8n, workflow, automation, diagram, svg
Requires at least: 6.0
Tested up to: 6.9
-Stable tag: 1.0.3
+Stable tag: 1.0.4
Requires PHP: 8.0
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -164,6 +164,16 @@ For security, requests to private and internal network addresses are blocked: lo
== Changelog ==
+= 1.0.4 =
+* Security: Detect generic `key` fields (n8n Google API pattern `queryParameters.parameters[].name = "key"`) and redact them when the value looks secret-shaped. Closes a gap where API keys bypassed the name-based filter.
+* Security: Defense-in-depth entropy heuristic (`looks_like_secret()`) with vendor regex for AIza / sk- / ghp_ / Bearer plus a length+char-class fallback — catches custom tokens the name allowlist can't enumerate.
+* Security: Redact credential display names and `meta.instanceId` so workflow exports can no longer be correlated to the originating n8n instance or team.
+* Security: Optional tag removal at publish time (opt-in checkbox in wizard step 3). Workflow tags are often harmless but occasionally identifying — publisher decides per workflow.
+* Security: Wizard step 3 now shows a collapsible Reason / Key / Note table listing exactly what was masked, so publishers can verify before clicking Publish.
+* Mobile: Rewrote the SVG touch handling. Single-finger pan is now smooth; pinch-to-zoom and double-tap-to-zoom work on iOS and Android. `touch-action: none` on the diagram SVG ends the browser-vs-plugin gesture tug-of-war that caused the "finger loses tracking" stutter.
+* Mobile: Minimap now responds to touch — tap or drag to navigate.
+* Note: Starting a touch on the diagram SVG blocks page scroll until the finger lifts. This is intentional so gestures are unambiguous; scroll around the diagram still works.
+
= 1.0.3 =
* Fixed double rendering when "Easy Table of Contents" (or any plugin that re-runs `the_content` filters) is active. The shortcode now silently deduplicates re-entrant invocations via a fingerprint of post id + resolved render settings.
* Wrapper `id` is now unique per instance (`breznflow-wrap--`), enabling multiple embeds of the same workflow with different attributes in one post.
@@ -208,6 +218,9 @@ For security, requests to private and internal network addresses are blocked: lo
== Upgrade Notice ==
+= 1.0.4 =
+Closes a secret-masking gap for n8n's generic `key` query-parameter pattern (Google API keys), redacts credential names and instance IDs, and rewrites mobile touch handling so pan / pinch / double-tap work on iOS and Android.
+
= 1.0.3 =
Fixes duplicate workflow rendering when "Easy Table of Contents" is active, and enables reliable multi-embed of the same workflow in one post.