效率工具

用 n8n 构建安全的报名表单 Webhook:数据校验、鉴权与错误响应

n8n 是开源的工作流自动化工具,节点之间用连线传数据。 这篇做一个报名表单后端:Webhook 收 POST → Code 节点校验和清洗 → IF 分支 → 用 Respond to Webhook 返回 200 或 422,并用 Header Auth 挡掉没带 token 的请求。整个工作流是一份 JSON,用 CLI 导入和发布,可以放进 Git。

BINBHO · 图文教程

n8n 是开源的工作流自动化工具,节点之间用连线传数据。 这篇做一个报名表单后端:Webhook 收 POST → Code 节点校验和清洗 → IF 分支 → 用 Respond to Webhook 返回 200 或 422,并用 Header Auth 挡掉没带 token 的请求。整个工作流是一份 JSON,用 CLI 导入和发布,可以放进 Git。

先看清成品

  • 在 box 上真实跑通:不带 token 403,合法数据 200(邮箱已转小写、去空格),不合法 422 并列出全部错误。
  • 测试中发现真实漏洞:count: true 和 count: [3] 都被当成合法人数(JS 里 Number(true)=1、Number([3])=3),已修复。
  • 还发现默认模式下 404 返回带服务器路径的 stacktrace;设 NODE_ENV=production 后不再显示。

合规提醒:n8n 用 Sustainable Use License(fair-code):自用或公司内部自托管可以免费用,把它作为服务卖给别人有限制,以官方 LICENSE.md 为准。本文只用官方 npm 包和官方 CLI。接收的报名信息属于个人信息,只收必要字段,并告知用途。

运行说明:全部在 box 上真实运行(n8n 2.44.5,Node.js 24.22.1,2026-10-21 21:19–21:22,UTC+8)。box 不能用 Docker(没有守护进程权限),所以按官方 npm 方式安装。第一次用 Node 22 安装失败,npm 只报 Tracker "idealTree" already exists,看不到真正原因。npm view n8n engines 显示 2.44.5 要求 node >=24.0.0,而官方安装页写的是 20.19 到 24.x。换成官方 Node 24 后安装成功。

做完之后你会得到什么?

成果 说明
lead_form.json 工作流(5 个节点)
import_and_publish.sh 导入凭据和工作流并发布;凭据文件用完即删

需要准备哪些环境?

项目 说明
Node.js 24.x(以 npm view n8n engines 为准)
n8n npm install n8n(本文 2.44.5),或按官方文档用 Docker
数据目录 N8N_USER_FOLDER 指定;里面有加密密钥,删掉后已存凭据无法解密
Python 3.9 以上 + pytest,用来写测试
curl 测试 webhook

关键事实

事实 来源
Webhook 节点有 Test URL 和 Production URL;正式 URL 要工作流发布(激活)后才生效 官方 Webhook 节点
Webhook 认证可选 Basic auth、Header auth、JWT auth,也可以设置 IP 白名单 同上
responseMode: responseNode 时由 Respond to Webhook 节点决定返回内容和状态码 官方 Respond to Webhook
CLI:import:workflow、publish:workflow、export:workflow;n8n 运行中改动需重启才生效(CLI 自己的提示) 官方 CLI 文档 + 实际输出

文件结构

82-n8n-A-src/
├── lead_form.json
└── import_and_publish.sh

第 1 步:工作流 JSON

{
  "id": "leadForm0000001",
  "name": "活动报名表单校验",
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "nodes": [
    {
      "id": "a1",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "webhookId": "7b3f0d6e-1c2a-4b8e-9a51-2f6c3d1e0a01",
      "parameters": {
        "httpMethod": "POST",
        "path": "signup",
        "responseMode": "responseNode",
        "options": {},
        "authentication": "headerAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "id": "signupHeaderAuth1",
          "name": "报名表单 Header Auth"
        }
      }
    },
    {
      "id": "a2",
      "name": "校验并清洗",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        0
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const b = $json.body || {};\nconst errors = [];\nconst name = String(b.name || '').trim();\nconst email = String(b.email || '').trim().toLowerCase();\nconst raw = b.count ?? 1;\n// 只接受数字或纯数字字符串;Number(true)=1、Number([3])=3 会被误判为合法\nconst count = (typeof raw === 'number' || (typeof raw === 'string' && /^\\d+$/.test(raw.trim()))) ? Number(raw) : NaN;\nif (!name) errors.push('name 必填');\nif (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(email)) errors.push('email 格式不对');\nif (!Number.isInteger(count) || count < 1 || count > 5) errors.push('count 需为 1-5 的整数');\nreturn { json: { ok: errors.length === 0, errors, lead: { name, email, count, received_at: $now.toISO() } } };"
      }
    },
    {
      "id": "a3",
      "name": "是否通过",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        440,
        0
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.ok }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "a4",
      "name": "回复成功",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        660,
        -100
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { status: 'ok', lead: $json.lead } }}",
        "options": {
          "responseCode": 200
        }
      }
    },
    {
      "id": "a5",
      "name": "回复错误",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        660,
        100
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { status: 'invalid', errors: $json.errors } }}",
        "options": {
          "responseCode": 422
        }
      }
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "校验并清洗",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "校验并清洗": {
      "main": [
        [
          {
            "node": "是否通过",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "是否通过": {
      "main": [
        [
          {
            "node": "回复成功",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "回复错误",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

第 2 步:导入并发布

#!/usr/bin/env bash
# 导入 Header Auth 凭据和工作流并发布。凭据文件只在临时目录里存在,导入后立即删除。
set -euo pipefail
: "${SIGNUP_TOKEN:?先 export SIGNUP_TOKEN=\$(openssl rand -hex 24)}"
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
cat > "$tmp/cred.json" <<JSON
[{"id": "signupHeaderAuth1", "name": "报名表单 Header Auth", "type": "httpHeaderAuth",
  "data": {"name": "X-Signup-Token", "value": "$SIGNUP_TOKEN"}}]
JSON
npx n8n import:credentials --input="$tmp/cred.json"
npx n8n import:workflow --input=lead_form.json
npx n8n publish:workflow --id=leadForm0000001
echo "完成。如果 n8n 正在运行,需要重启后才生效。"

第 3 步:运行

$ npm install n8n          # 2.44.5,added 2342 packages in 1m
$ export N8N_USER_FOLDER=$PWD/.n8n-data TZ=Asia/Shanghai GENERIC_TIMEZONE=Asia/Shanghai
$ export SIGNUP_TOKEN=$(openssl rand -hex 24)
$ ./import_and_publish.sh
Successfully imported 1 credential.
Importing 1 workflows...
Deactivating workflow "活动报名表单校验".
Successfully imported 1 workflow.
Publishing workflow with ID: leadForm0000001 (current version)
$ NODE_ENV=production npx n8n start &
Editor is now accessible via:
http://localhost:5678

2026-10-21 21:20 UTC+8
$ curl -s -X POST http://localhost:5678/webhook/signup -H 'Content-Type: application/json' -d '{"name":"王小明","email":"xm@example.com"}'   # 不带 token
Authorization data is wrong!  ← HTTP 403
$ curl -s -X POST http://localhost:5678/webhook/signup -H "X-Signup-Token: $SIGNUP_TOKEN" -H 'Content-Type: application/json' -d '{"name":"王小明","email":"Xiaoming@Example.com ","count":2}'
{"status":"ok","lead":{"name":"王小明","email":"xiaoming@example.com","count":2,"received_at":"2026-10-21T21:20:36.605+08:00"}}  ← HTTP 200
$ curl -s -X POST http://localhost:5678/webhook/signup -H "X-Signup-Token: $SIGNUP_TOKEN" -H 'Content-Type: application/json' -d '{"name":"韩梅梅","email":"han@example.com","count":true}'
{"status":"invalid","errors":["count 需为 1-5 的整数"]}  ← HTTP 422
$ curl -s -X POST http://localhost:5678/webhook/signup -H "X-Signup-Token: $SIGNUP_TOKEN" -H 'Content-Type: application/json' -d '{"name":"李雷","email":"li@example.com","count":[3]}'
{"status":"invalid","errors":["count 需为 1-5 的整数"]}  ← HTTP 422
$ curl -s -X POST http://localhost:5678/webhook/signup -H "X-Signup-Token: $SIGNUP_TOKEN" -H 'Content-Type: application/json' -d '{"name":"李雷","email":"li@example.com","count":"3"}'
{"status":"ok","lead":{"name":"李雷","email":"li@example.com","count":3,"received_at":"2026-10-21T21:20:36.829+08:00"}}  ← HTTP 200
$ curl -s http://localhost:5678/webhook/signup   # GET
{"code":404,"message":"This webhook is not registered for GET requests. Did you mean to make a POST request?"}  ← HTTP 404

怎么检查结果?

  • 用浏览器打开 http://localhost:5678 → Executions,能看到每次请求的输入和各节点输出。
  • 改了 JSON 重新导入后,日志会显示 Deactivating workflow:一定要再 publish 并重启。
  • 对外开放前放在 HTTPS 反向代理后面,token 定期更换。

调试示例

1. 人数校验被布尔值和数组绕过。 第一版写的是 const count = Number(b.count ?? 1),测试时:

$ curl ... -d '{"name":"韩梅梅","email":"han@example.com","count":true}'
{"status":"ok","lead":{"name":"韩梅梅","email":"han@example.com","count":1,...}}  ← HTTP 200
$ curl ... -d '{"name":"  ","email":"a@b.c","count":[3]}'
{"status":"invalid","errors":["name 必填"]}  ← HTTP 422(count 没报错)

JavaScript 的 Number(true) 是 1,Number([3]) 是 3,都能通过 Number.isInteger。修复:只接受 number 类型或纯数字字符串,其他一律当 NaN。修复后两条都返回 422 count 需为 1-5 的整数(见运行输出)。

2. 404 把 stacktrace 返回给了调用方。 默认启动时 GET 请求得到:

{"code":404,"message":"This webhook is not registered for GET requests. Did you mean to make a POST request?","stacktrace":"ResponseError: ...\n    at LiveWebhooks.findWebhook (/tmp/n8n/node_modules/n8n/src/webhooks/live-webhooks.ts:296:10)..."}

暴露了安装路径。用 NODE_ENV=production 启动后只返回 code 和 message。

3. 一开始任何人都能提交。 第一版 Webhook 没有认证。改成 Header Auth:凭据由脚本从环境变量生成临时文件导入,导入后删除;工作流 JSON 里只有凭据 id 和名称,可以放心提交(C 篇有导出检查)。

常见问题

现象 原因 处理
404 webhook not registered 工作流没发布,或改完没重启 publish:workflow 后重启 n8n
403 Authorization data is wrong! 没带或带错 X-Signup-Token 检查请求头
返回 200 但内容为空 responseMode 不是 responseNode 设成 responseNode 并接 Respond to Webhook
npm 报 idealTree already exists 真实原因被掩盖,常见于 Node 版本不对 先 npm view n8n engines,换对版本

模板规则

  • 工作流用 JSON 管理,用 CLI 导入发布。
  • Webhook 一定加认证。
  • 类型检查要严格,别依赖 Number()。
  • 生产环境 NODE_ENV=production。

项目资源

用途 地址
n8n npm 安装(官方) https://docs.n8n.io/deploy/host-n8n/install-options/install-with-npm
n8n CLI 命令(官方) https://docs.n8n.io/deploy/host-n8n/configure-n8n/use-the-command-line
n8n GitHub https://github.com/n8n-io/n8n
n8n 许可证 https://github.com/n8n-io/n8n/blob/master/LICENSE.md
n8n Webhook 节点(官方) https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook
n8n Respond to Webhook(官方) https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.respondtowebhook

先在本机把每种错误输入都试一遍,再接到真实表单上。