一、本质是权限边界问题
跨境电商系统的敏感数据分布在三层:数据库里(证件、地址、支付流水)、浏览器里(会话 Cookie、令牌)、资金链路里(收款账户、退款指令)。这三类漏洞恰好各针对其中一层:
| 漏洞 | 攻击面所在层 | 攻击者最终想拿到的东西 |
|---|---|---|
| SQL 注入 | 应用层到数据库层 | 库里的数据、库的写权限 |
| XSS | 服务端渲染到用户浏览器 | 用户的会话、用户在页面上的操作权 |
| CSRF | 用户浏览器到服务端 | 借用户的身份发起操作 |
SQL 注入属于信任边界丢失 :用户输入被当作代码执行;XSS 是上下文边界丢失 :数据被当作代码渲染;CSRF 是主体边界丢失:浏览器自动附带的凭证被第三方站点借用。理解这一点,后面所有措施都只是同一件事的具体实现:设定边界,并在边界处做类型和上下文检查。
二、SQL 注入
先看跨境场景里最容易出错的地方
注入不一定发生在登录框。跨境电商系统里有几类输入是"看起来不像参数"的:
- 排序与分页 :
ORDER BY ${sortField} ${sortOrder}是参数化查询无法绑定的部分(列名不能用占位符),这是最常见的注入点。 - 多语言字段与表名 :部分系统按语言分表(
product_en/product_de),表名由前端传语言码拼接。 - 报表导出与对账查询 :为了支持任意维度筛选,后端用字符串拼
WHERE子句。 - 后台搜索:运营后台按"模糊匹配 + 多条件组合",拼接风险最高。
正确的做法
1)参数化查询,但要知道它无法处理什么
ORDER BY、GROUP BY、表名、列名不能用占位符绑定。这类只能用白名单映射处理后拼进语句:
// 错误:直接拼接
String sql = "SELECT * FROM product ORDER BY " + sortField + " " + sortOrder;
// 正确:白名单映射
private static final Map<String, String> SORT_MAP = Map.of(
"price", "price_amount",
"sales", "sale_count",
"created", "create_time"
);
String column = SORT_MAP.get(sortField);
if (column == null) {
throw new BizException("非法的排序字段");
}
String direction = "asc".equalsIgnoreCase(sortOrder) ? "ASC" : "DESC";
String sql = "SELECT * FROM product ORDER BY " + column + " " + direction;
MyBatis 的 ${} 就是字符串拼接,#{} 才是预编译。可以用 Sonar 或 Checkstyle 自定义规则,禁止 ${} 出现在 mapper 的 SQL 语句里,只允许在动态表名等明确场景加注释豁免。
2)不要用黑名单拦关键字
原文说拦截 union、select、and、or,这在跨境电商里会直接出故障:商品标题里有 "Union Jack 卫衣"、品牌名里有 "SELECTED",全被拦掉;而攻击者用 sel/**/ect、URL 编码、十六进制、大小写混合即可绕过。黑名单只能拦没有经验的攻击者,同时会误伤业务。
正确的做法是做白名单式的类型校验:
// 商品 ID、订单号、用户 ID 这类参数,只允许数字与下划线
private static final Pattern ID_PATTERN = Pattern.compile("^[A-Za-z0-9_]{1,32}$");
public void checkId(String id) {
if (id == null || !ID_PATTERN.matcher(id).matches()) {
throw new BizException("参数格式异常");
}
}
业务参数则交给参数化查询,输入是否含有 SQL 关键字无关紧要,因为它并不会变成 SQL 语法的一部分。
3)ORM 的进阶设置
启用 MyBatis safeResultHandlerEnabled、关闭 allowMultiQueries(JDBC URL 参数,防止堆叠查询);JPA 场景避免使用 @Query 的 nativeQuery = true 拼接参数,改用 EntityManager.createNativeQuery(sql).setParameter(...)。
权限与运维层
数据库账号按业务拆分,这是防止注入影响扩大的最后一道保障:
| 账号 | 权限 | 使用方 |
|---|---|---|
app_ro |
只读核心表 | 前台商品、搜索查询 |
app_order |
订单、库存、支付流程表的 CRUD | 订单服务 |
app_report |
只读从库、仅限指定视图 | 报表、对账 |
dba_admin |
全部权限,但禁止应用使用 | 运维人工操作 |
app_* 账号一律禁止 DDL 权限和 FILE 权限(FILE 可读写服务器文件,是提权跳板)。开启 skip_name_resolve,只允许应用服务器内网段连接;云数据库用安全组而非 IP 白名单兜底,因为海外节点 IP 会变动。
WAF 的注入规则可以开,但一定要开启观察模式运行一段时间再启用拦截,否则商品搜索这类正常请求会被误拦,导致订单量下降。日志里按命中规则统计误杀率,高于 0.5% 的规则要调整。
三、XSS
三种类型在跨境场景中应该怎么区分
- 存储型:商品评论、店铺公告、客服消息、买家秀。危害最大,一次植入长期有效,而且跨境场景下的审核链路更长(多语言内容机审准确率低),攻击者常利用小语种绕过审核。
- 反射型 :搜索结果页回显关键字、语言切换参数、支付完成后的跳转提示页(
?msg=支付成功)。 - DOM 型 :单页应用里用
innerHTML、document.write、location.hash渲染的路由与富文本预览。
关键纠正:输出转义只发生在渲染时,不在输入端
原文说"对所有用户提交内容过滤 <script>",这条单独用是错的:数据库里存的是原始内容,同一份数据要渲染到 HTML、JS、URL、属性四种上下文,转义规则完全不同。在输入时转义一次,等于假设只有一个输出位置,而且会把用户内容改坏(比如代码分享类内容被转义后还原不了)。
正确做法是"输入只校验长度与类型,输出按上下文转义":
// 按上下文选择转义器(以 OWASP Java Encoder 为例)
// HTML 正文
out.print(Encode.forHtml(userContent));
// HTML 属性值
out.print("<input value=\"" + Encode.forHtmlAttribute(value) + "\">");
// JS 字符串
out.print("var msg = '" + Encode.forJavaScript(msg) + "';");
// URL 参数
out.print("<a href=\"/search?q=" + Encode.forUriComponent(keyword) + "\">");
模板引擎默认都开转义(Thymeleaf 的 th:text、Vue 的 {``{ }}、React 的 JSX),出问题的都是主动关掉转义的地方:th:utext、v-html、dangerouslySetInnerHTML。把这些 API 加进代码审查的强制检查项,每次使用必须在 PR 里说明数据来源与过滤方式。
富文本必须经过服务端 HTML 净化(推荐 OWASP Java HTML Sanitizer 或 DOMPurify 的服务端版本),使用标签和属性白名单而不是黑名单:
// 只允许这几种标签和属性,其余一律剥离
PolicyFactory policy = new HtmlPolicyBuilder()
.allowElements("p", "br", "strong", "em", "ul", "ol", "li", "img", "a")
.allowAttributes("src", "alt").onElements("img")
.allowAttributes("href").onElements("a")
.allowUrlProtocols("https") // 只允许 https,挡掉 javascript: 与 data:
.requireRelNofollowOnLinks()
.toFactory();
String safe = policy.sanitize(rawComment);
为什么同时需要 HttpOnly 和 CSP,说清楚这两条各自的适用范围:
HttpOnly 只能防"偷 Cookie",挡不住 XSS 本身。攻击者拿不到 Cookie,但可以在页面里直接发起已登录状态的请求;下单、改地址都不需要读 Cookie。所以 HttpOnly 只是降低影响,并不是完整防护。
CSP 才是从根源阻断的手段,而且原文说的"禁止内联脚本"只是第一步,真正能挡住 XSS 的是 nonce 或 hash 机制:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{每次请求随机生成}' 'strict-dynamic';
object-src 'none';
base-uri 'self';
frame-ancestors 'self';
每次响应生成一个随机 nonce 注入到合法的 <script nonce="..."> 上,攻击者注入的脚本因为没有 nonce 而被拒绝执行。frame-ancestors 'self' 顺便把点击劫持也一起挡了。CSP 上线一定用 Content-Security-Policy-Report-Only 先跑两周,收集违规报告,把所有第三方脚本(海外统计、客服 SDK、支付 SDK)都纳入白名单后再切到强制模式,否则结账页会因为支付 SDK 被拦而无法付款。
业务侧补充
客服聊天窗口是跨境电商的特殊高风险区:买家和卖家可以互发消息和图片链接,且往往是独立的 iframe 或 WebSocket 渲染。这里要做三件事:消息渲染一律走文本节点(textContent)而不是 innerHTML;外链强制 rel="noopener noreferrer",并经过跳转确认页;图片 URL 做协议与域名校验,禁止 data: 与 javascript: 协议。
四、CSRF
先说一个容易被忽略的前提
CSRF 能成立需要三个条件同时满足:浏览器会自动带上凭证(Cookie)、参数可被攻击者预测、服务端不校验请求是否来自本站。因此防护也要从这三条分别入手,任何单一措施都有覆盖不到的情况。
1)Token 的正确实现方式
原文说 Token 存"会话与前端页面",这里有个细节:Token 如果存在 Session 里,跨域/多实例部署时要保证 Session 一致性;更稳妥的方式是双提交 Cookie 或基于签名的无状态 Token。
前后端分离架构下推荐这样:
// 登录成功后签发一个与用户绑定、有时效的 CSRF Token
public String issueCsrfToken(String userId, String sessionId) {
String raw = userId + "|" + sessionId + "|" + System.currentTimeMillis();
String sign = hmacSha256(raw, csrfSecret); // 服务端密钥签名
return Base64.getUrlEncoder().encodeToString((raw + "|" + sign).getBytes());
}
// 校验:拆出原始部分,重算签名比对,再校验时效
public boolean verify(String token, String userId, String sessionId) {
String decoded = new String(Base64.getUrlDecoder().decode(token));
String[] parts = decoded.split("\\|");
if (parts.length != 4) return false;
if (!parts[0].equals(userId) || !parts[1].equals(sessionId)) return false;
long ts = Long.parseLong(parts[2]);
if (System.currentTimeMillis() - ts > 2 * 3600_000L) return false; // 2 小时过期
String expect = hmacSha256(parts[0] + "|" + parts[1] + "|" + parts[2], csrfSecret);
return MessageDigest.isEqual(expect.getBytes(), parts[3].getBytes()); // 常量时间比较
}
用 MessageDigest.isEqual 而不是 equals 比较签名,避免时序攻击。Token 通过自定义请求头(X-CSRF-Token)传递,天然不受跨域表单提交影响,因为普通 HTML 表单无法设置自定义头。
2)SameSite 是被低估的一道防护措施
Cookie 的 SameSite 属性在主流浏览器(含 Safari 与 Chrome)上已经默认 Lax,这本身就挡掉了大部分跨站 POST。建议显式声明:
Set-Cookie: SESSION=xxx; HttpOnly; Secure; SameSite=Lax; Path=/
注意两个例外:支付回调 和跨站单点登录 需要 SameSite=None; Secure。支付渠道的回调来自第三方域名,如果 Cookie 是 Lax,回调里的会话会丢失。这类接口不要依靠 Cookie 鉴权,而应该用渠道签名验签(微信、PayPal、Stripe 都有自己的签名机制),这既是安全要求也是合规要求。
3)Referer / Origin 校验的真实局限
原文已提到隐私浏览器会清空 Referer,补充一点:Origin 头在现代浏览器里对跨域 POST 一定会有,比 Referer 可靠,但服务端到服务端的调用、旧版浏览器、部分海外代理可能不带。所以正确的策略是"有 Origin 就严格校验,没有则回落到 Token 校验",把两者作为组合条件,而不是二选一。
4)敏感操作二次校验,要说清校验什么
改收款账户、退款、改支付密码这类操作,仅短信验证码不够:如果攻击者已经通过 XSS 获得会话,他可以在受害者不知情时触发验证码下发,再通过社工套取。更稳的是:
| 操作 | 二次校验方式 |
|---|---|
| 修改收款账户 / 提现 | 原支付密码 + 短信验证码 + 冷却期(24 小时内不可再次修改) |
| 大额退款 | 审批流(双人复核),不做单点自助 |
| 修改登录密码 / 绑定邮箱 | 原密码 + 邮箱验证码,改密后强制全端下线 |
| 批量发布商品 | 频率限制 + 滑块验证 |
冷却期这一条常被忽略但很有效:资金类配置变更后 24 小时内不允许再次变更,给风控留出人工介入的时间窗。
五、四层防御的落地与验证
架构分层
接入层 WAF(注入/XSS 规则,观察期 2 周后转拦截)+ CDN 回源校验
网关层 统一鉴权、CSRF Token 校验、请求频率限制、Origin 校验
应用层 参数化查询、输出转义、富文本净化、权限最小化
数据层 分账号权限、禁 DDL、内网访问、审计日志
业务层 内容审核、二次验证、异常交易监控、资金变更冷却期
验证方式,重点不是"有没有做",而是"做了是否有效"
安全措施上线后必须验证,否则容易认为已经开启、实际没有生效:
- SQL 注入 :用 sqlmap 对订单查询、后台搜索、报表导出入参跑一遍,确认全部返回"参数异常"而非数据库报错。检查线上响应体里是否泄漏堆栈(搜索
SQLException、ORA-、You have an error in your SQL)。 - XSS :在评论、店铺公告、客服消息各投一条
<img src=x onerror=alert(document.domain)>,确认存储后在所有端(PC、H5、APP 内嵌 WebView、海外小程序)都被转义。检查 CSP 报告端点上是否出现blocked-uri记录。 - CSRF:写一个第三方域名的 HTML 页面,用表单 POST 到你们的退款接口,确认返回 403。这个测试要在删除 Token 的情况下做,验证 Token 确实是拦截原因。
常态化节奏
| 频率 | 动作 |
|---|---|
| 每次提交 | SAST 扫描(注入、${} 使用、innerHTML、v-html)+ 依赖漏洞扫描 |
| 每周 | DAST 扫描 + WAF 日志分析,统计拦截量 Top 规则与误杀率 |
| 每月 | 人工渗透测试(重点:资金链路、后台管理、第三方回调) |
| 每季度 | 权限复核(数据库账号、云平台 IAM)、CSP 白名单清理 |
| 大促前 | 全量回归渗透 + 限流熔断验证 |
跨境电商特有的三条
- 不能只靠 IP 白名单。海外用户走代理、VPN 是常态,IP 黑名单会误伤大量正常用户;风控应以设备指纹、行为特征、Token 校验为主,IP 只作为辅助信号。
- 欧盟 GDPR、加州 CCPA 与《个人信息保护法》的处罚是实打实的。泄露事件要在 72 小时内向监管报告(GDPR 第 33 条),所以日志留存、入侵检测、事件响应预案要在平时就准备好,否则出事后连"泄露了什么"都说不清。
- 第三方 SDK 是供应链风险点 。客服、统计、支付、物流追踪 SDK 都在页面里执行脚本,任何一个被投毒都等于给了攻击者 XSS 能力。要求引入前做代码审计,用 SRI(
integrity属性)锁定外部脚本版本,并纳入 CSP 白名单单独管控。
六、结语
三类漏洞的共同点是:修复成本极低(参数化查询、输出转义、Token 校验都是几行代码),但一旦漏掉,代价极高。它们反复出现的真实原因不是技术难,而是默认安全没有成为开发习惯 :新接口赶工期直接用字符串拼 SQL,新页面为了快速上线用 v-html,新微服务忘了加 Token 校验。
所以真正的加固重点在工程侧:把这些检查写进代码规约、CI 流水线、代码审查清单和上线卡点,让"不安全"的写法在提交阶段就失败。技术方案只是第一步,让它变成不能绕过的流程,才是长期有效的防护。