PHP 表单验证有哪些原则?不要让浏览器提交的数据直接进入数据库
图片说明:示意图 图片来源:Public Domain(公有领域)
在 PHP 网站开发中,表单验证看起来是一个很基础的问题,实际上却是应用安全的第一道边界。浏览器提交过来的数据,从服务器的角度看都属于不可信输入。无论页面上的 HTML、JavaScript 还是浏览器开发者工具显示了什么,都不能作为服务器端安全判断的依据。
用户可以绕过网页上的 JavaScript,直接向 PHP 接口发送 HTTP 请求,也可以修改隐藏字段、改变价格参数、伪造用户 ID、提交超长字符串,甚至完全不按照网页表单原本设计的格式发送数据。因此,PHP 后端必须重新验证每一个来自客户端的数据。
这里有一个非常重要的原则:验证、规范化、输出编码和数据库安全是四件不同的事情。把它们全部称为“过滤输入”,很容易让开发者建立错误的安全模型。
浏览器提交的数据默认都不可信
例如一个注册表单可能包含:
username
email
password
age
phone
这些数据进入 PHP 后,不能因为 HTML 表单已经写了 required、maxlength 或 JavaScript 已经验证过,就直接写入数据库。
HTML 的 required 只是浏览器端行为,JavaScript 验证也属于客户端逻辑。攻击者完全可以绕过它们,直接构造 POST 请求。
服务器应该根据业务规则重新检查数据。
例如年龄应该是允许范围内的整数,邮箱应该符合应用接受的邮箱格式,用户名应该符合规定的字符和长度,订单金额不能由客户端随意决定,用户 ID 也不能因为来自隐藏字段就被认为可信。
因此,后端验证的核心不是“把危险字符删掉”,而是判断“这个字段是否符合服务器预期”。
验证和过滤不是一回事
PHP 提供的 filter_var() 很适合处理一些标准格式,例如:
FILTER_VALIDATE_EMAIL
FILTER_VALIDATE_IP
FILTER_VALIDATE_URL
它可以作为验证工具使用,但不能把它理解成万能安全过滤器。
例如邮箱地址可以使用:
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($email === false) {
// 验证失败
}
对于电话号码、用户名、订单编号等业务字段,则通常需要根据自己的业务规则进行验证。
例如一个订单编号可能要求只能包含大写字母、数字和短横线,这种规则就应该明确写出来,而不是简单地把“不认识的字符”全部删除。
preg_match() 可以用于自定义格式验证,但正则表达式也不应该被滥用。对于特别复杂、可能触发高计算量的正则表达式,还需要考虑回溯和 ReDoS 风险。验证规则应该尽量明确、有限、可预测。
不要用 htmlspecialchars() 来“清洗输入”
这是 PHP 初学者最容易形成的错误习惯之一。
htmlspecialchars() 的主要用途是 HTML 输出编码,而不是数据库输入过滤。
例如数据库中允许保存:
Tom & Jerry
这类普通文本完全可能是合法业务数据。
当以后把它输出到 HTML 页面时,才应该根据 HTML 上下文进行编码:
echo htmlspecialchars(
$name,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
这样浏览器看到的是安全的文本,而不是把用户输入中的 HTML 标签当成页面代码执行。
因此正确的思路是“存储数据”和“输出数据”分开处理。
不要为了防止 XSS,在用户提交时就把所有 <、>、引号等字符永久改掉。
否则数据库里的原始业务数据可能被破坏,后续输出到 JSON、CSV、邮件、纯文本或者其他系统时也会产生新的问题。
SQL 注入应该由预处理语句解决
防止 SQL 注入不能靠删除单引号,也不能靠 htmlspecialchars()。
PHP 使用 PDO 时,应该采用预处理语句:
$stmt = $pdo->prepare(
'SELECT * FROM users WHERE email = :email'
);
$stmt->execute([
':email' => $email
]);
用户输入作为参数传递,而不是直接拼接进 SQL 字符串。
错误的做法类似:
$sql = "SELECT * FROM users WHERE email = '$email'";
即使前面已经做过邮箱格式检查,也不能因此把字符串拼接 SQL 当成安全方案。
验证的作用是保证数据符合业务规则;预处理语句的作用是建立 SQL 代码和参数之间的边界。这两个安全机制不能互相替代。
数据库字段本身也应该有约束
应用程序验证并不是数据库安全的最后一层。
例如用户名要求唯一,那么数据库应该建立 UNIQUE 约束;某个字段不允许为空,可以使用 NOT NULL;数值字段应该使用合理的数据类型;外键关系应该根据实际业务建立约束。
原因很简单:今天只有一个 PHP 页面写数据库,不代表半年以后仍然只有一个入口。
未来可能出现 API、后台管理系统、命令行脚本、定时任务、移动端程序或者第三方服务。如果数据完整性完全依赖某一个 PHP 表单验证函数,系统很容易出现“某个入口忘记验证”的问题。
应用层负责业务验证,数据库负责数据完整性,两者应该形成不同层次的防线。
不要相信隐藏字段、价格字段和用户 ID
安全开发中有一条非常实用的经验:客户端提交的业务关键数据,服务器不要直接相信。
例如购物车页面提交:
product_id=1001
price=9.99
quantity=1
服务器不能简单地接受 price=9.99 并创建订单。
客户端可以把它改成:
price=0.01
正确做法是根据服务器保存的商品 ID重新读取价格,再根据服务器端规则计算订单金额。
用户 ID也是类似的问题。
如果当前登录用户是通过服务器端 Session 或安全认证机制确定的,就不要因为 POST 中出现了:
user_id=123
就认为当前请求有权修改用户 123 的资料。
权限判断必须建立在服务器端身份和授权规则之上。
这比单纯讨论“过滤特殊字符”更加重要。
长度限制同样属于安全措施
字符串长度不能无限制地接受。
例如用户名可能限制为几十个字符,标题可能限制为几百个字符,评论可能限制为几千个字符。
PHP 可以先进行长度检查:
if (mb_strlen($title, 'UTF-8') > 200) {
$errors['title'] = '标题过长';
}
处理中文等多字节字符时,不能机械地把 strlen() 当作“用户看到的字符数”。
如果业务规则要求限制字符数量,通常应该考虑 mb_strlen()。
但也不要简单地使用 substr() 把超长内容截断后直接保存。截断可能破坏 UTF-8 字符、URL、代码、JSON或者其他结构化数据,也可能改变用户提交的数据语义。
更合理的方式通常是拒绝超过业务限制的数据,并向用户返回明确错误。
POST 不是安全机制
很多 PHP 开发者会产生一个误解:GET 不安全,POST 安全。
实际上,POST 只是 HTTP 请求方法,并不自动提供加密或安全保护。
GET 参数通常出现在 URL 中,而 POST 数据通常位于 HTTP 请求体中。两者都可能被客户端构造,都必须进行服务器端验证。
真正保护传输内容的是 HTTPS。
密码绝对不应该因为“POST比较安全”就明文处理。服务器应该通过:
password_hash()
生成密码哈希,并使用:
password_verify()
进行验证。
数据库里不应该保存用户的明文密码。
同样,CSRF 也不是因为使用 POST 就自动消失。对于会改变服务器状态的请求,例如修改账户、删除文章、提交订单、修改密码等,仍然需要根据应用架构实施 CSRF 防护或采用其他有效的请求来源验证机制。
文件上传需要单独处理
文件上传不能只检查扩展名。
攻击者可以把一个恶意文件命名成:
photo.jpg
甚至伪造 MIME 类型。
服务器应该根据具体业务检查文件大小、实际文件类型、扩展名、文件内容以及保存位置,并避免让用户上传的文件直接成为可执行脚本。
上传目录的 Web 服务器执行权限也应该根据实际部署环境进行控制。
对于图片,还可以使用图像处理库重新解码并生成新的文件,而不是盲目相信客户端提供的 MIME 类型。
文件上传属于独立的安全边界,不能简单套用普通文本字段的验证逻辑。
错误信息要对用户友好,对攻击者克制
验证失败时,应该告诉用户下一步该怎么修改。
例如:
“请输入有效的邮箱地址。”
比直接显示数据库异常要合适得多。
更不能把 SQL 查询、数据库用户名、服务器路径、PHP 堆栈或者内部文件名直接输出给普通用户。
开发环境可以记录详细异常,生产环境则应该把技术细节写入服务器日志,并向用户返回经过控制的错误信息。
另外,错误信息本身也必须经过正确的输出编码。如果错误信息包含用户提交的内容,就不能直接把原始输入拼接进 HTML。
复杂表单不要只返回第一个错误
原稿中“某个验证失败后立即终止后续验证”的说法也需要修正。
对于复杂表单,更好的用户体验通常是一次完成全部字段检查,把多个错误集中返回。
例如一次提交同时发现邮箱格式错误、密码长度不足、用户名已经存在,就可以分别记录:
$errors['email'] = '请输入有效的邮箱地址';
$errors['password'] = '密码长度不足';
$errors['username'] = '用户名已经存在';
最后判断:
if (!empty($errors)) {
// 返回表单和错误信息
}
这样用户修改一次就可以重新提交,而不是每次只发现一个问题。
当然,如果某个条件属于流程级致命错误,例如身份认证失败、请求签名错误、CSRF 校验失败或者关键资源不存在,就可以直接终止请求。
因此应该区分“字段验证错误”和“请求级安全错误”。
客户端验证负责体验,服务器验证负责安全
JavaScript 验证非常有价值。
用户输入邮箱时,前端可以立即提示格式错误;密码太短,可以马上显示原因;数量超过库存,也可以即时提示。
这样可以减少无效请求,让页面体验更加流畅。
但这些检查都不能成为服务器最终的安全依据。
用户完全可以关闭 JavaScript,也可以修改请求内容,更可以绕过网页直接调用 PHP API。
因此一个成熟的 Web 应用应该允许前端验证和后端验证拥有部分重复逻辑。
这种重复并不是浪费,而是不同安全边界上的职责分工。
数据规范化应该和验证分开考虑
某些字段需要在存储前进行规范化。
例如日期可以统一成:
YYYY-MM-DD
邮箱地址可以按照业务规则进行统一处理,用户名可以去除不必要的首尾空白,枚举字段可以限制为服务器允许的固定值。
但是规范化不能变成“看到奇怪字符就删除”。
如果一个字段要求保留原始文本,就应该保留其语义。
特别是姓名、地址、国际电话号码、多语言文本等数据,很容易因为过度过滤而破坏合法内容。
对于 Unicode 数据,也应该统一使用 UTF-8,并确保数据库连接、数据库字段、PHP 输出以及 HTML 页面之间的字符集配置一致。
敏感数据不能靠 Base64 保护
原稿中提到使用 base64_encode() 处理二进制数据,这一点需要特别强调。
Base64 是编码,不是加密。
任何人都可以很容易把 Base64 数据解码回来。因此绝不能把:
密码
API 密钥
身份认证令牌
信用卡等敏感信息
当成普通字符串进行 Base64 编码后,就认为它已经安全。
密码应该使用适合密码存储的单向哈希,例如 PHP 的 password_hash()。
如果业务确实需要以后恢复原文,则需要使用正规的加密方案和密钥管理机制,而不是 Base64。
日志也不能什么都记
验证日志对于排查问题和安全审计很有价值,但“所有验证失败的数据全部记录下来”同样可能造成新的安全问题。
日志里不应该随意保存密码、Session Token、完整身份认证凭据以及其他不必要的敏感信息。
更合理的日志通常记录:
请求时间
用户或会话标识
接口名称
验证失败类型
请求结果
IP及其他必要的审计信息
以及用于关联请求的 trace ID 或 request ID。
这样既能帮助排查异常,也不会因为日志本身变成新的敏感数据泄漏源。
最后一道防线应该是整个数据链,而不是一个验证函数
一个成熟的 PHP 表单处理流程通常应该包含多个层次。
首先确认请求方法和认证状态,然后读取输入数据,对字段进行类型、长度、格式和业务规则验证;必要时进行规范化;涉及权限的数据重新从服务器端读取;数据库操作使用 PDO 预处理语句;密码使用 password_hash();文件上传按照独立的安全规则处理;输出 HTML 时再根据上下文使用 htmlspecialchars();数据库本身通过字段类型、NOT NULL、UNIQUE、外键等约束保证数据完整性。
这些机制各自解决不同问题。
filter_var() 解决部分标准格式验证。
preg_match() 可以处理明确的自定义格式。
htmlspecialchars() 主要解决 HTML 输出编码。
PDO 预处理语句解决 SQL 参数和 SQL 代码之间的边界问题。
password_hash() 解决密码存储问题。
CSRF 防护解决部分状态改变请求的伪造问题。
数据库约束解决数据完整性问题。
HTTPS保护传输过程。
没有哪个 PHP 函数能够单独承担全部 Web 安全责任。
因此,PHP 表单开发最应该建立的习惯并不是“把用户输入过滤得干干净净”,而是从一开始就把客户端输入视为不可信数据,并根据它最终进入什么系统、什么语境,分别进行验证、规范化、参数化和输出编码。
浏览器可以帮助用户填写正确的数据,但浏览器没有资格决定服务器应该接受什么数据。服务器才是最终的信任边界;数据库则应该继续承担数据完整性的最后一道防线。
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP