diff --git a/.env.example b/.env.example index d70b760..d520a7d 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,12 @@ N8N_PROTOCOL=http WEBHOOK_URL=http://localhost:5678/ GENERIC_TIMEZONE=UTC +# Shared secret for protected webhooks (workflows 01, 02, 03, 04a, 05, 07). +# Clients must send 'X-Webhook-Secret: ' on every call. +# Public routes (Etsy OAuth callback, mockup-view image proxy) are exempt. +# 32-byte hex +N8N_WEBHOOK_SECRET=replace_with_openssl_rand_hex_32 + # ---- ToolJet ---------------------------------------------------------------- # 64-byte hex TOOLJET_SECRET_KEY_BASE=replace_with_openssl_rand_hex_64 diff --git a/README.md b/README.md index 5bc4572..59e9897 100644 --- a/README.md +++ b/README.md @@ -397,6 +397,28 @@ ngrok http 5678 # n8n webhooks (use this URL as ETSY_REDIRECT_URI) ## Security best practices +### Webhook auth + +Workflows 01, 02, 03, 04a, 05, 07 require `X-Webhook-Secret: ` on every call. Without it n8n returns **401**. Public exemptions: workflow 04 (Etsy OAuth callback — Etsy can't send a custom header) and workflow 02b (mockup-view image proxy — used by ``). + +One-time setup after first boot: +1. Set `N8N_WEBHOOK_SECRET` in `.env` (`openssl rand -hex 32`); `docker compose restart n8n`. +2. In the n8n UI: **Credentials → New → Header Auth**. + - Name: `n8n Webhook Secret` (must match the JSON in workflow imports). + - Header name: `X-Webhook-Secret`. + - Header value: paste the same secret. +3. In the ToolJet UI: **Workspace constants → N8N_WEBHOOK_SECRET → set the same value.** + +Direct curl now needs the header: +```bash +curl -X POST http://localhost:5678/webhook/listing-generate \ + -H "x-webhook-secret: $N8N_WEBHOOK_SECRET" \ + -H 'content-type: application/json' \ + -d '{"shop_id":1,"niche":"vintage botanical printables","kind":"printable","cost":0}' +``` + +### Other practices + - Never commit `.env`. `.gitignore` excludes it. - `N8N_ENCRYPTION_KEY` encrypts every credential stored in n8n. **Rotating it invalidates all stored credentials** — only rotate on a fresh install. @@ -408,6 +430,9 @@ ngrok http 5678 # n8n webhooks (use this URL as ETSY_REDIRECT_URI) superuser for simplicity. - Rotate Etsy / Printify / Printful tokens periodically and revoke them in the provider UIs when removing a shop. +- `shops.oauth_token` / `refresh_token` are stored in plaintext today. + Encrypting at rest with `pgcrypto` is a recommended follow-on; for now, + restrict Postgres access to the n8n container's network. --- diff --git a/docker-compose.yml b/docker-compose.yml index 6d342fd..b70569a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,7 @@ services: PINTEREST_WEBHOOK_URL: ${PINTEREST_WEBHOOK_URL} X_WEBHOOK_URL: ${X_WEBHOOK_URL} INSTAGRAM_WEBHOOK_URL: ${INSTAGRAM_WEBHOOK_URL} + N8N_WEBHOOK_SECRET: ${N8N_WEBHOOK_SECRET} volumes: - n8n_data:/home/node/.n8n - ./workflows:/workflows:ro diff --git a/tooljet-apps/etsy_dashboard.json b/tooljet-apps/etsy_dashboard.json index e31ab95..d575350 100644 --- a/tooljet-apps/etsy_dashboard.json +++ b/tooljet-apps/etsy_dashboard.json @@ -13,6 +13,12 @@ "scope": "global", "_note": "Override per-environment. Use the Docker service name when ToolJet calls n8n inside the compose network." }, + { + "name": "N8N_WEBHOOK_SECRET", + "value": "", + "scope": "global", + "_note": "Operator MUST set this in the ToolJet UI to match the N8N_WEBHOOK_SECRET env var on the n8n container. Sent as X-Webhook-Secret on every n8n_api call." + }, { "name": "DEFAULT_SHOP_ID", "value": "1", @@ -37,7 +43,10 @@ "kind": "restapi", "options": { "base_url": "{{constants.N8N_URL}}", - "headers": [{ "key": "Content-Type", "value": "application/json" }] + "headers": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "X-Webhook-Secret", "value": "{{constants.N8N_WEBHOOK_SECRET}}" } + ] } } ], @@ -199,6 +208,19 @@ }, "run_on_page_load": false }, + { + "name": "start_etsy_connect", + "data_source": "n8n_api", + "kind": "restapi", + "options": { + "method": "get", + "url_path": "/webhook/etsy-connect", + "url_params": [ + { "key": "shop_name", "value": "{{components.newShopName.value || 'My Shop'}}" } + ] + }, + "run_on_page_load": false + }, { "name": "save_shop_pod_config", "data_source": "etsy_app_db", @@ -325,10 +347,11 @@ { "id": "newShopName", "type": "TextInput", "props": { "label": "Shop label (free text)", "placeholder": "My Test Shop" } }, { "id": "connectShopBtn", "type": "Button", - "props": { "text": "Authorise on Etsy" }, + "props": { "text": "Authorise on Etsy", "loading_state": "{{queries.start_etsy_connect.isLoading}}" }, "events": [ - { "event": "onClick", "action": "open-url", - "url": "{{constants.N8N_URL}}/webhook/etsy-connect?shop_name={{encodeURIComponent(components.newShopName.value || 'My Shop')}}", + { "event": "onClick", "action": "run-query", "query": "start_etsy_connect" }, + { "event": "onSuccess", "action": "open-url", + "url": "{{queries.start_etsy_connect.data?.auth_url}}", "open_in_new_tab": true } ] }, diff --git a/workflows/01_ai_listing_generator.json b/workflows/01_ai_listing_generator.json index cbf18cc..1c9b19a 100644 --- a/workflows/01_ai_listing_generator.json +++ b/workflows/01_ai_listing_generator.json @@ -6,6 +6,7 @@ "httpMethod": "POST", "path": "listing-generate", "responseMode": "responseNode", + "authentication": "headerAuth", "options": {} }, "id": "wf01-webhook", @@ -13,7 +14,13 @@ "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [240, 300], - "webhookId": "listing-generate" + "webhookId": "listing-generate", + "credentials": { + "httpHeaderAuth": { + "id": "n8n-webhook-secret", + "name": "n8n Webhook Secret" + } + } }, { "parameters": { diff --git a/workflows/02_ai_visual_studio.json b/workflows/02_ai_visual_studio.json index 6956a3f..d783dee 100644 --- a/workflows/02_ai_visual_studio.json +++ b/workflows/02_ai_visual_studio.json @@ -6,6 +6,7 @@ "httpMethod": "POST", "path": "visual-generate", "responseMode": "responseNode", + "authentication": "headerAuth", "options": {} }, "id": "wf02-webhook", @@ -13,7 +14,13 @@ "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [240, 300], - "webhookId": "visual-generate" + "webhookId": "visual-generate", + "credentials": { + "httpHeaderAuth": { + "id": "n8n-webhook-secret", + "name": "n8n Webhook Secret" + } + } }, { "parameters": { diff --git a/workflows/03_ai_ads_studio.json b/workflows/03_ai_ads_studio.json index 69052f2..09ddb7b 100644 --- a/workflows/03_ai_ads_studio.json +++ b/workflows/03_ai_ads_studio.json @@ -6,6 +6,7 @@ "httpMethod": "POST", "path": "ads-generate", "responseMode": "responseNode", + "authentication": "headerAuth", "options": {} }, "id": "wf03-webhook", @@ -13,7 +14,13 @@ "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [240, 300], - "webhookId": "ads-generate" + "webhookId": "ads-generate", + "credentials": { + "httpHeaderAuth": { + "id": "n8n-webhook-secret", + "name": "n8n Webhook Secret" + } + } }, { "parameters": { @@ -64,7 +71,7 @@ { "parameters": { "language": "javaScript", - "jsCode": "// Parse + validate + optionally call workflow 02 for a shared visual.\nconst ollama = $input.first().json;\nconst input = $('Build Prompt').first().json;\n\nlet parsed;\ntry {\n parsed = typeof ollama.response === 'string' ? JSON.parse(ollama.response) : ollama.response;\n} catch (e) {\n throw new Error(`LLM returned non-JSON: ${e.message}`);\n}\n\nconst channelToCopy = (channel) => {\n const c = parsed[channel];\n if (!c) throw new Error(`Missing copy for channel ${channel}`);\n if (channel === 'pinterest') {\n if (!c.title || c.title.length > 100) throw new Error('Pinterest title invalid');\n if (!c.description || c.description.length > 500) throw new Error('Pinterest description invalid');\n const hashtags = Array.isArray(c.hashtags) ? c.hashtags : [];\n return `${c.title}\\n\\n${c.description}\\n\\n${hashtags.map(h => h.startsWith('#') ? h : '#' + h).join(' ')}`;\n }\n if (channel === 'x') {\n if (!c.post || c.post.length > 280) throw new Error('X post invalid');\n return c.post;\n }\n if (channel === 'instagram') {\n if (!c.caption || c.caption.length > 2200) throw new Error('Instagram caption invalid');\n const hashtags = Array.isArray(c.hashtags) ? c.hashtags : [];\n return `${c.caption}\\n\\n${hashtags.map(h => h.startsWith('#') ? h : '#' + h).join(' ')}`;\n }\n return JSON.stringify(c);\n};\n\nlet imagePath = null;\nif (input.generate_visual) {\n try {\n const visual = await this.helpers.httpRequest({\n method: 'POST',\n url: 'http://localhost:5678/webhook/visual-generate',\n body: { listing_id: input.listing_id, width: 1080, height: 1080 },\n json: true,\n timeout: 360000\n });\n imagePath = visual?.mockup?.file_path || null;\n } catch (e) {\n // Soft-fail: ad copy still goes out, just without a generated visual.\n console.warn(`visual-generate failed: ${e.message}`);\n }\n}\n\nconst scheduledFor = input.schedule_at\n ? new Date(input.schedule_at).toISOString()\n : new Date(Date.now() + 60 * 60 * 1000).toISOString();\n\n// Emit one item per channel so the Postgres node inserts each row independently.\nreturn input.channels.map(channel => ({\n json: {\n listing_id: input.listing_id,\n channel,\n copy: channelToCopy(channel),\n image_path: imagePath,\n scheduled_for: scheduledFor,\n raw: parsed[channel] || null\n }\n}));" + "jsCode": "// Parse + validate + optionally call workflow 02 for a shared visual.\nconst ollama = $input.first().json;\nconst input = $('Build Prompt').first().json;\n\nlet parsed;\ntry {\n parsed = typeof ollama.response === 'string' ? JSON.parse(ollama.response) : ollama.response;\n} catch (e) {\n throw new Error(`LLM returned non-JSON: ${e.message}`);\n}\n\nconst channelToCopy = (channel) => {\n const c = parsed[channel];\n if (!c) throw new Error(`Missing copy for channel ${channel}`);\n if (channel === 'pinterest') {\n if (!c.title || c.title.length > 100) throw new Error('Pinterest title invalid');\n if (!c.description || c.description.length > 500) throw new Error('Pinterest description invalid');\n const hashtags = Array.isArray(c.hashtags) ? c.hashtags : [];\n return `${c.title}\\n\\n${c.description}\\n\\n${hashtags.map(h => h.startsWith('#') ? h : '#' + h).join(' ')}`;\n }\n if (channel === 'x') {\n if (!c.post || c.post.length > 280) throw new Error('X post invalid');\n return c.post;\n }\n if (channel === 'instagram') {\n if (!c.caption || c.caption.length > 2200) throw new Error('Instagram caption invalid');\n const hashtags = Array.isArray(c.hashtags) ? c.hashtags : [];\n return `${c.caption}\\n\\n${hashtags.map(h => h.startsWith('#') ? h : '#' + h).join(' ')}`;\n }\n return JSON.stringify(c);\n};\n\nlet imagePath = null;\nif (input.generate_visual) {\n try {\n const visual = await this.helpers.httpRequest({\n method: 'POST',\n url: 'http://localhost:5678/webhook/visual-generate',\n headers: { 'X-Webhook-Secret': process.env.N8N_WEBHOOK_SECRET },\n body: { listing_id: input.listing_id, width: 1080, height: 1080 },\n json: true,\n timeout: 360000\n });\n imagePath = visual?.mockup?.file_path || null;\n } catch (e) {\n // Soft-fail: ad copy still goes out, just without a generated visual.\n console.warn(`visual-generate failed: ${e.message}`);\n }\n}\n\nconst scheduledFor = input.schedule_at\n ? new Date(input.schedule_at).toISOString()\n : new Date(Date.now() + 60 * 60 * 1000).toISOString();\n\n// Emit one item per channel so the Postgres node inserts each row independently.\nreturn input.channels.map(channel => ({\n json: {\n listing_id: input.listing_id,\n channel,\n copy: channelToCopy(channel),\n image_path: imagePath,\n scheduled_for: scheduledFor,\n raw: parsed[channel] || null\n }\n}));" }, "id": "wf03-validate", "name": "Validate + Visual", diff --git a/workflows/04a_etsy_connect.json b/workflows/04a_etsy_connect.json index 8542020..9531a54 100644 --- a/workflows/04a_etsy_connect.json +++ b/workflows/04a_etsy_connect.json @@ -6,6 +6,7 @@ "httpMethod": "GET", "path": "etsy-connect", "responseMode": "responseNode", + "authentication": "headerAuth", "options": {} }, "id": "wf04a-webhook", @@ -13,7 +14,13 @@ "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [240, 300], - "webhookId": "etsy-connect" + "webhookId": "etsy-connect", + "credentials": { + "httpHeaderAuth": { + "id": "n8n-webhook-secret", + "name": "n8n Webhook Secret" + } + } }, { "parameters": { @@ -48,13 +55,8 @@ }, { "parameters": { - "respondWith": "text", - "responseBody": "=Connect Etsy shop

Connect {{ $('Generate PKCE').item.json.shop_name }}

Click below to authorise n8n on Etsy. You'll be redirected back here when it's done.

Authorise on Etsy →

State token: {{ $('Generate PKCE').item.json.state }} (valid 10 min)

", - "options": { - "responseHeaders": { - "entries": [{ "name": "Content-Type", "value": "text/html; charset=utf-8" }] - } - } + "respondWith": "json", + "responseBody": "={{ { ok: true, shop_name: $('Generate PKCE').item.json.shop_name, state: $('Generate PKCE').item.json.state, auth_url: $('Generate PKCE').item.json.auth_url } }}" }, "id": "wf04a-respond", "name": "Respond", diff --git a/workflows/05_etsy_listing_publish.json b/workflows/05_etsy_listing_publish.json index 53c0ef2..96838a4 100644 --- a/workflows/05_etsy_listing_publish.json +++ b/workflows/05_etsy_listing_publish.json @@ -6,6 +6,7 @@ "httpMethod": "POST", "path": "etsy-listing-publish", "responseMode": "responseNode", + "authentication": "headerAuth", "options": {} }, "id": "wf05-webhook", @@ -13,7 +14,13 @@ "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [240, 300], - "webhookId": "etsy-listing-publish" + "webhookId": "etsy-listing-publish", + "credentials": { + "httpHeaderAuth": { + "id": "n8n-webhook-secret", + "name": "n8n Webhook Secret" + } + } }, { "parameters": { diff --git a/workflows/06_etsy_order_sync.json b/workflows/06_etsy_order_sync.json index 47344a5..b2feb37 100644 --- a/workflows/06_etsy_order_sync.json +++ b/workflows/06_etsy_order_sync.json @@ -85,6 +85,12 @@ "parameters": { "method": "POST", "url": "http://localhost:5678/webhook/pod-route", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { "name": "X-Webhook-Secret", "value": "={{ $env.N8N_WEBHOOK_SECRET }}" } + ] + }, "sendBody": true, "specifyBody": "json", "jsonBody": "={ \"order_id\": {{ $json.id }} }", diff --git a/workflows/07_pod_route_order.json b/workflows/07_pod_route_order.json index ad9427b..9ccd59c 100644 --- a/workflows/07_pod_route_order.json +++ b/workflows/07_pod_route_order.json @@ -6,6 +6,7 @@ "httpMethod": "POST", "path": "pod-route", "responseMode": "responseNode", + "authentication": "headerAuth", "options": {} }, "id": "wf07-webhook", @@ -13,7 +14,13 @@ "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [240, 300], - "webhookId": "pod-route" + "webhookId": "pod-route", + "credentials": { + "httpHeaderAuth": { + "id": "n8n-webhook-secret", + "name": "n8n Webhook Secret" + } + } }, { "parameters": {