Skip to content

常见安全漏洞:沿着输入找到危险操作 ​

审查一个上传、搜索或回调接口时,先追踪三件事:哪些值来自不可信输入,这些值最终控制了什么操作,中间有没有真正限制这种控制。用户输入进入 SQL 语法、HTML、命令行、网络地址或文件路径时,风险不同,修复方法也不同。

本文按这条路径讲 SQL 注入、XSS、CSRF、SSRF、路径穿越、命令注入、反序列化与开放重定向。所有运行实验都只接触本地临时文件、内存或 loopback 测试服务器,没有外部扫描,也没有真实受害目标。

OWASP Top 10 要带版本看 ​

截至本文查证日 2026-10-13,本文采用 OWASP Top 10:2025 的正式列表。它是帮助开发者建立风险意识的分类,不是列完十项就能证明系统安全的验收标准。

2025 编号官方类别审查时先问的问题
A01Broken Access Control,访问控制失效主体是否有权操作这份对象?
A02Security Misconfiguration,安全配置错误默认配置、暴露端口和错误页面是否扩大边界?
A03Software Supply Chain Failures,软件供应链失效依赖、构建和制品来自哪里?
A04Cryptographic Failures,密码学失效哪些数据需要保密和完整性保护?
A05Injection,注入数据是否被解释成了指令或语法?
A06Insecure Design,不安全设计业务流程本身是否允许滥用?
A07Authentication Failures,认证失效系统是否认错人或保留了失效会话?
A08Software or Data Integrity Failures,软件或数据完整性失效更新、消息、反序列化对象能否被替换?
A09Security Logging and Alerting Failures,安全日志和告警失效发生违规后能否发现并追溯?
A10Mishandling of Exceptional Conditions,异常条件处理不当超时、错误和资源耗尽时是否意外放行?

这不是 2021 版的原样重排。例如 SSRF 在 2021 版是单列的 A10,2025 版的 A10 已变成异常条件处理。不要拿旧编号解释新报告;具体漏洞也可能涉及多个类别。下面按代码边界组织,而不是强行把每个漏洞塞进唯一格子。

先画输入到危险操作的路线 ​

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    I["不可信输入<br/>HTTP、文件、队列、第三方响应"] --> P["解析与结构校验"]
    P --> B["业务校验与授权"]
    B --> S{"最终操作是什么?"}
    S --> Q["数据库:参数与SQL结构分离"]
    S --> H["HTML:按输出上下文编码"]
    S --> N["网络:限制目标与实际连接"]
    S --> F["文件:受限目录中安全打开"]
    S --> C["进程:固定程序与参数边界"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    I["不可信输入<br/>HTTP、文件、队列、第三方响应"] --> P["解析与结构校验"]
    P --> B["业务校验与授权"]
    B --> S{"最终操作是什么?"}
    S --> Q["数据库:参数与SQL结构分离"]
    S --> H["HTML:按输出上下文编码"]
    S --> N["网络:限制目标与实际连接"]
    S --> F["文件:受限目录中安全打开"]
    S --> C["进程:固定程序与参数边界"]

这里常把危险操作叫 sink,把输入传播叫污点传播。记住这个词不是目的;要能指出“这个请求字段最后改变了哪一个操作”。校验姓名长度不能修复 SQL 拼接,HTML 转义也不能让文件路径安全。

漏洞输入控制了什么危险落点主要防线防线仍需配合什么
SQL 注入SQL 语法的一部分查询或写入执行参数化值,结构部分允许列表对象授权、数据库最小权限
XSS页面中的可执行上下文HTML/DOM/脚本解释上下文编码、安全 DOM APICSP、可信模板、依赖治理
CSRF借用户浏览器发起操作自动附带凭据的状态变更接口会话绑定 token、来源验证SameSite、无副作用 GET、XSS 防护
SSRF服务器请求的目的地出站网络连接目标允许列表、解析后连接约束出站隔离、重定向和代理治理
路径穿越文件解析后的真实位置打开、覆盖、解压文件受限根目录 API、授权符号链接、挂载与设备边界
命令注入命令或选项shell / 子进程不经 shell,固定程序和参数参数语义、权限、资源限额
反序列化风险还原对象和资源使用解码器、类型回调、业务字段赋值明确 schema、限制资源和可写字段完整性验证、更新解码库
开放重定向浏览器下一跳Location / 前端跳转服务器定义的目标映射授权回调精确匹配

SQL 注入:把数据交给参数,不让它变成语法 ​

假设搜索框里的姓名被拼进 WHERE name = '输入'。数据库接到的是一整条 SQL,无法知道哪一部分原本被程序员当作“姓名”。一旦输入改变了引号或表达式结构,实际查询就偏离了原意。

Go 的做法是把 SQL 文本和参数值分别传给 database/sql,例如 db.QueryContext(ctx, "SELECT id FROM users WHERE name = ?", name)。PostgreSQL 驱动常用 $1,占位符要按驱动选择。不要用 fmt.Sprintf 或字符串拼接把值嵌进去,再期待驱动识别它原本是参数。Go 官方 SQL 注入指南

参数只能代替值,不能普遍代替表名、列名、排序方向。用户想选择排序字段时,应把 name、created_at 这样的外部选择映射到服务端写死的 SQL 片段,拒绝未知选项。ORM 的原始 SQL 接口、拼接排序语句和动态查询也要检查,不能以“用了 ORM”结束审查。OWASP SQL Injection Prevention

本文没有连接数据库,因此不展示伪造的“注入成功/修复成功”数据库输出。参数化调用说明依据官方 API;目标数据库、驱动与查询的集成验证仍需单独完成。

XSS:同一个字符串,落在不同位置就有不同含义 ​

XSS 是不可信数据最终变成浏览器可执行内容。反射型通常来自当前请求,存储型先写进数据库再出现在其他页面,DOM 型则由前端代码把数据送进危险 DOM 操作。来源不同,关键仍是最后的解释上下文。

下面只用无害的 <b> 标签,比较同一模板交给 text/template 和 html/template。没有运行脚本,也没有打开浏览器。

go
const source = `<p>{{.}}</p>`
input := "<b>guest</b>"

完整程序分别执行两种模板,本机实际输出:

text
text: <p><b>guest</b></p>
html: <p>&lt;b&gt;guest&lt;/b&gt;</p>

第一行保留了 HTML 结构;第二行把输入保留为文本。html/template 会根据模板上下文进行转义,前提是模板本身可信。把输入强转成 template.HTML、template.JS 或 template.URL,是在告诉库“这段已经可信”,不能把它当作消除警告的方法。Go 1.27.2 html/template 安全模型

HTML 文本、属性、URL、JavaScript、CSS 的规则不一样。一份“通用 escape”不能到处用。前端显示普通文本时优先使用 textContent;富文本必须按允许的标签、属性和 URL 协议做净化。CSP 可以减少部分执行机会,但不能替代正确编码;它的 nonce 需要按响应生成,不是写在代码里永远不变。OWASP XSS 防护、CSP Level 3

CSRF:凭据是浏览器自动带上的,操作却不是用户想发的 ​

浏览器可能根据 Cookie 规则自动给目标站点附带凭据。攻击站点不一定需要读取响应,只要能诱使浏览器发出会改变状态的请求,就可能造成影响。因此“不允许跨域读响应”与“阻止跨站发起有凭据的操作”是两回事。OWASP CSRF 防护

常见设计是在服务端会话里保存不可预测的 CSRF token,由页面通过表单或自定义请求头回传,并检查请求的 Origin;缺少 Origin 时如何处理,要按客户端类型与 Referer/Fetch Metadata 等策略明确决定。GET 不应承担状态变更,SameSite Cookie 提供额外保护。无状态双提交 Cookie 方案也需要正确绑定会话并防止攻击者注入可伪造 Cookie,不能只比较两个同样由客户端控制的字段。

实验函数选择严格策略:Origin 必须等于 https://app.example,token 必须匹配受信会话中的值。测试中重复的 t 是公开占位数据,不是生产随机 token。

go
csrfAllowed("https://app.example", token, token)
csrfAllowed("https://other.example", token, token)
text
csrf-good=true csrf-foreign=false

它只是验证决策函数,未测试浏览器 Cookie 的 SameSite 行为。真正接口还要限制方法、绑定用户会话、限制请求体并处理缺失/过期 token。XSS 往往能在同源上下文发请求,不能指望 CSRF token 抵消 XSS。纯粹由客户端显式添加 Bearer 头且不自动携带的凭据,一般不具有相同的 Cookie CSRF 条件,但登录回调、Cookie 刷新和混合认证路径仍需逐项检查。

SSRF:检查过 URL,不等于限制住了连接 ​

服务端替用户下载头像或抓取链接时,输入控制的是服务器的网络能力。服务器可能能访问用户无法直接访问的内部服务;SSRF 的危险来自这份权限被借用。只过滤 URL 中的 localhost 或几个字符串,无法覆盖地址表示、IPv6、DNS 变化、代理和重定向。OWASP SSRF 防护

对于只访问几个固定服务的业务,优先让用户提交资源 ID,服务端选择已配置地址。确实要接收 URL 时,应解析后检查 scheme、host、port,拒绝不需要的 userinfo 和其他格式;精确允许列表比域名后缀拼接更容易检查。

DNS 和重定向是两次容易漏掉的“重新选目标” ​

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart TD
    U["输入 URL"] --> V["检查协议、主机、端口"]
    V --> R["解析 A/AAAA,检查全部候选地址"]
    R --> P["选定已通过检查的地址"]
    P --> D["连接该地址,保留原主机的 TLS 身份验证"]
    D --> H{"返回重定向?"}
    H -- "默认拒绝" --> X["停止"]
    H -- "业务必须允许" --> V
    H -- "否" --> B["按时间和大小上限读取响应"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart TD
    U["输入 URL"] --> V["检查协议、主机、端口"]
    V --> R["解析 A/AAAA,检查全部候选地址"]
    R --> P["选定已通过检查的地址"]
    P --> D["连接该地址,保留原主机的 TLS 身份验证"]
    D --> H{"返回重定向?"}
    H -- "默认拒绝" --> X["停止"]
    H -- "业务必须允许" --> V
    H -- "否" --> B["按时间和大小上限读取响应"]

如果校验阶段解析一次 DNS,连接时再按主机名解析一次,后一次答案可能不同。防护需要让实际拨号使用已验证的地址,或者通过具备相同约束的出站代理完成;TLS 仍要验证原服务主机名,不能为了连接固定 IP 而关闭证书验证。多地址结果要检查每个可能被拨号的地址。维护网络策略时还要处理回环、链路本地、私有地址、IPv4-mapped IPv6、云元数据及部署中的其他禁入网段;“不是 RFC1918 私网”不是“可以安全访问”的充分条件。Go Transport 拨号入口、netip 地址判断

实验只实现“固定主机 + 固定地址集合”的策略函数。192.0.2.10 来自文档专用 TEST-NET-1,只当公开测试值,程序绝不会连接它。相同主机的解析结果改为 loopback 时,实际输出为:

text
approved-address: true
changed-dns: false

这证明函数检查了传入的解析结果,不证明真实 DNS rebinding 已被端到端防住。完整拨号固定、TLS、连接池、代理、DNS TTL 与目标轮换没有在这个函数中实现。若共享连接池,策略改变后旧连接仍可能复用,也要一并处理。

Go http.Client 默认会跟随部分重定向。实验用两个本地测试服务器,第一台只返回 302 指向第二台;客户端配置:

go
CheckRedirect: func(*http.Request, []*http.Request) error {
    return http.ErrUseLastResponse
},
text
redirect-stopped=true target-hits=0

结果说明第二个本地服务没被访问。生产下载器若允许重定向,每一跳都必须重新执行同样策略并限制跳数;不能只验证第一跳。还要约束总超时、响应头/体大小、解压后大小、并发量,避免“安全目标”返回无限内容造成资源耗尽。出站网络隔离是另一道防线,不应仅依赖 URL 校验。

路径穿越:清理字符串与限制文件能力是两件事 ​

filepath.Clean 只整理路径的词法形式,不检查文件系统真实解析结果。先检查字符串前缀,再用 os.Open 打开,还可能被符号链接和检查后替换路径的竞态影响。压缩包解压同样要检查每个条目的路径与链接,不能因为压缩包文件本身在上传目录里就信任内部名称。

Go 1.24 引入的 os.Root 提供在指定目录树下操作文件的 API。本例在临时目录中创建一个受限根目录,根目录外放一个无害文本,再在根目录内创建指向该文本的相对符号链接。

go
r, err := os.OpenRoot(root)
// 省略的错误处理在完整代码中。
r.Open("../outside.txt")
r.Open("escape")

本次 Linux/amd64 实测:

text
dotdot-rejected=true symlink-rejected=true

Root 可以跟随仍在根内的相对符号链接,但拒绝逃出根。它不是操作系统沙箱:文档明确说它不禁止跨文件系统、Linux bind mount、/proc 特殊文件和设备文件。平台也有差异,例如 GOOS=js 的符号链接验证不能提供同等 TOCTOU 保证。应选受控上传目录,限制目录里能出现什么,并继续做对象授权。Go os.Root 文档、官方设计说明

命令注入:不经过 shell 只是第一道边界 ​

Go exec.Command 默认不经过 shell,也不会自动解释分号、管道或通配符。若把输入拼进 sh -c 的命令文本,就重新引入 shell 语法解释。能用 Go 库完成文件、压缩或文本处理时,优先直接调用库,减少一层解释器。os/exec 包说明

本地实验调用固定的 /usr/bin/printf,格式串也是固定的 %s,输入作为独立参数:

go
exec.Command("/usr/bin/printf", "%s", "hello; printf INJECTED").Output()
text
literal-argument: hello; printf INJECTED

分号被原样打印,没有第二条命令被执行。即便不经过 shell,参数仍可能被目标程序解释成危险选项,所以要固定可执行文件、限制参数语义,并在目标程序支持时使用 -- 结束选项。不要允许输入指定程序路径;对确实需要的子进程设置时间、权限、工作目录、环境和输出上限。本例只验证参数边界,没有执行任何恶意命令。

反序列化:类型安全不等于输入安全 ​

某些平台的反序列化可以触发类型构造、魔术方法或回调,形成危险执行链。Go 的普通 JSON 解码不会凭空获得其他语言的全部对象执行机制,但仍存在资源消耗、类型自定义解码逻辑和业务字段覆盖的问题。把请求直接解码到含 Role、TenantID 的数据库对象,再整体保存,就是常见的越权字段赋值。

应定义只包含客户端可写字段的输入结构,在读取前限制体积,解码后检查数值、长度、枚举和业务条件。下面的实验只接受一个 JSON 值,拒绝未知字段与尾随第二份 JSON:

go
d := json.NewDecoder(strings.NewReader(raw))
d.DisallowUnknownFields()
// Decode 输入结构后,再 Decode 一次,必须得到 io.EOF。
text
unknown-field-rejected=true trailing-json-rejected=true

这不是“严格 JSON 的全部要求”:传统 encoding/json 仍有大小写不敏感匹配、重复键处理、非法 UTF-8 替换等兼容行为。如果签名、网关和后端用了不同解析器,必须统一语义或拒绝歧义,不能假定 DisallowUnknownFields 也拒绝重复键。HTTP 接口应先用 http.MaxBytesReader 等限制读取,而不是先读进内存再检查长度;本例输入已在内存中,只验证解码逻辑。Go JSON 安全注意事项

encoding/gob 的官方文档明确指出它不是为对抗恶意输入加固的解码器,资源上限也不可配置,因此不应把它随手暴露给不可信网络输入。gob Security 对其他序列化格式,要确认 schema、允许类型、深度/体积限制和库的安全维护状态;给不可信数据签名也不能挽救已泄漏的签名密钥。OWASP 反序列化指南

开放重定向:让输入选目标 ID,比让它提供任意 URL 简单 ​

登录后的 next 参数、邮件中的跳转页和前端路由都可能接受一个目标地址。若服务端把任意输入写进 Location,攻击者可以借可信站点域名包装外部钓鱼跳转;在授权流程里,还可能影响授权码或令牌的去向。OWASP 重定向防护

本例只接受 account、home 这样的键,由服务器映射成 /account、/。用户输入外部 URL 时不生成目标:

text
external-redirect-rejected: true

若业务确实要接收站内路径,必须按解析后的规则限制,防止把 //host 等形式误当普通相对路径;目标页本身也要做授权。OAuth 回调应与预注册地址按协议精确匹配,不能套用一个宽松的“同域名就行”判断。

修复后,测试应当证明哪一条边界 ​

一个漏洞修复至少需要正常输入仍可用、危险输入被拒绝、替代入口也受同样规则约束。测试名称最好指向边界,例如“跨租户不可读”“重定向不访问第二跳”“符号链接不可逃出根”,比笼统的 TestSecurity 更容易在后续改动中保留意图。

本文的测试能复现模板输出、局部地址策略、停止本地重定向、根目录限制、字面参数传递、CSRF 决策、JSON 边界和跳转映射。它们没有代替浏览器、真实网络、数据库和部署测试。审查时继续沿输入追到真正执行点,才能知道修复覆盖了哪一段,而不是看到一个过滤函数就停止。

依据与验证边界 ​

  • 风险分类:OWASP Top 10:2025;各漏洞的官方 Cheat Sheet 已在对应章节就近链接。
  • Go API:Go 1.27.2 的 html/template、net/http、net/netip、os.Root、os/exec、encoding/json、encoding/gob;路径设计另见 Go 官方 osroot 文章。
  • 实验:2026-10-13,官方 Go 1.27.2、Linux/amd64;go run .、go test -race -count=1 ./...、go vet ./... 实际通过。命令行实验需要 Linux 的 /usr/bin/printf;路径实验需要本地符号链接支持。
  • 所有文件操作限于程序创建的临时目录,网络实验限于 httptest 创建的 loopback 服务。未进行外部连接、端口扫描、真实账号攻击或系统安全设置修改。
  • 未测试真实数据库注入、浏览器 XSS/CSRF/CORS、真实 DNS 重绑定、完整安全下载器、压缩包资源炸弹和生产沙箱。不能从本例输出推导“系统不存在这些漏洞”。

完整复现代码 ​

代码中的域名、地址、token 都是公开测试夹具。allowedTarget 只验证传入的地址列表,不执行 DNS 解析或拨号;不要把它单独当作 SSRF 完整防护。

go.mod ​

go.mod
module example.com/security03

go 1.27.2

main.go ​

代码块 · 160 行 · 点击收起 / 展开
go
package main

import (
	"bytes"
	"crypto/subtle"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"io"
	"net/http"
	"net/http/httptest"
	"net/netip"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync/atomic"
	texttemplate "text/template"
)

func render() (string, string, error) {
	const source = `<p>{{.}}</p>`
	input := "<b>guest</b>"
	var raw, safe bytes.Buffer
	if err := texttemplate.Must(texttemplate.New("raw").Parse(source)).Execute(&raw, input); err != nil {
		return "", "", err
	}
	if err := template.Must(template.New("safe").Parse(source)).Execute(&safe, input); err != nil {
		return "", "", err
	}
	return raw.String(), safe.String(), nil
}

// A deliberately narrow destination policy. 192.0.2.10 is a public TEST FIXTURE
// address from TEST-NET-1; nothing here dials it or resolves external DNS.
func allowedTarget(raw string, resolved []netip.Addr) bool {
	u, err := url.Parse(raw)
	if err != nil || u.Scheme != "https" || u.User != nil || u.Fragment != "" || u.Hostname() != "assets.example" || (u.Port() != "" && u.Port() != "443") {
		return false
	}
	if len(resolved) == 0 {
		return false
	}
	expected := netip.MustParseAddr("192.0.2.10")
	for _, ip := range resolved {
		if ip.Unmap() != expected {
			return false
		}
	}
	return true
}
func csrfAllowed(origin, got, sessionToken string) bool {
	return origin == "https://app.example" && len(sessionToken) >= 32 && len(got) == len(sessionToken) && subtle.ConstantTimeCompare([]byte(got), []byte(sessionToken)) == 1
}

type Input struct {
	Name string `json:"name"`
}

func decode(raw string) error {
	if len(raw) > 128 {
		return errors.New("body too large")
	}
	d := json.NewDecoder(strings.NewReader(raw))
	d.DisallowUnknownFields()
	var in Input
	if err := d.Decode(&in); err != nil {
		return err
	}
	var extra any
	if err := d.Decode(&extra); err != io.EOF {
		return errors.New("expected one JSON value")
	}
	if len(in.Name) == 0 || len(in.Name) > 20 {
		return errors.New("invalid name")
	}
	return nil
}
func destination(key string) (string, bool) {
	p, ok := map[string]string{"account": "/account", "home": "/"}[key]
	return p, ok
}
func pathChecks() (bool, bool, error) {
	dir, err := os.MkdirTemp("", "path-boundary-")
	if err != nil {
		return false, false, err
	}
	defer os.RemoveAll(dir)
	root := filepath.Join(dir, "root")
	if err = os.Mkdir(root, 0700); err != nil {
		return false, false, err
	}
	if err = os.WriteFile(filepath.Join(dir, "outside.txt"), []byte("public fixture"), 0600); err != nil {
		return false, false, err
	}
	if err = os.Symlink("../outside.txt", filepath.Join(root, "escape")); err != nil {
		return false, false, err
	}
	r, err := os.OpenRoot(root)
	if err != nil {
		return false, false, err
	}
	defer r.Close()
	f, e1 := r.Open("../outside.txt")
	if e1 == nil {
		f.Close()
	}
	g, e2 := r.Open("escape")
	if e2 == nil {
		g.Close()
	}
	return e1 != nil, e2 != nil, nil
}
func redirectChecks() (bool, int32, error) {
	var hits atomic.Int32
	target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1); w.WriteHeader(204) }))
	defer target.Close()
	source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusFound) }))
	defer source.Close()
	c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
	resp, err := c.Get(source.URL)
	if err != nil {
		return false, hits.Load(), err
	}
	defer resp.Body.Close()
	return resp.StatusCode == http.StatusFound, hits.Load(), nil
}
func literalArg() (string, error) {
	b, err := exec.Command("/usr/bin/printf", "%s", "hello; printf INJECTED").Output()
	return string(b), err
}
func main() {
	raw, safe, err := render()
	must(err)
	fmt.Println("text:", raw)
	fmt.Println("html:", safe)
	fmt.Println("approved-address:", allowedTarget("https://assets.example/a", []netip.Addr{netip.MustParseAddr("192.0.2.10")}))
	fmt.Println("changed-dns:", allowedTarget("https://assets.example/a", []netip.Addr{netip.MustParseAddr("127.0.0.1")}))
	blocked, hits, err := redirectChecks()
	must(err)
	fmt.Printf("redirect-stopped=%v target-hits=%d\n", blocked, hits)
	dot, link, err := pathChecks()
	must(err)
	fmt.Printf("dotdot-rejected=%v symlink-rejected=%v\n", dot, link)
	arg, err := literalArg()
	must(err)
	fmt.Println("literal-argument:", arg)
	token := strings.Repeat("t", 32)
	fmt.Printf("csrf-good=%v csrf-foreign=%v\n", csrfAllowed("https://app.example", token, token), csrfAllowed("https://other.example", token, token))
	fmt.Printf("unknown-field-rejected=%v trailing-json-rejected=%v\n", decode(`{"name":"a","role":"admin"}`) != nil, decode(`{"name":"a"}{"name":"b"}`) != nil)
	_, ok := destination("https://other.example")
	fmt.Println("external-redirect-rejected:", !ok)
}
func must(err error) {
	if err != nil {
		panic(err)
	}
}

main_test.go ​

代码块 · 57 行 · 点击收起 / 展开
go
package main

import (
	"net/netip"
	"strings"
	"testing"
)

func TestBoundaries(t *testing.T) {
	raw, safe, err := render()
	if err != nil || raw != "<p><b>guest</b></p>" || safe != "<p>&lt;b&gt;guest&lt;/b&gt;</p>" {
		t.Fatal(raw, safe, err)
	}
	dot, link, err := pathChecks()
	if err != nil || !dot || !link {
		t.Fatal(dot, link, err)
	}
	blocked, hits, err := redirectChecks()
	if err != nil || !blocked || hits != 0 {
		t.Fatal(blocked, hits, err)
	}
	arg, err := literalArg()
	if err != nil || arg != "hello; printf INJECTED" {
		t.Fatal(arg, err)
	}
}
func TestTargetPolicy(t *testing.T) {
	good := []netip.Addr{netip.MustParseAddr("192.0.2.10")}
	for _, raw := range []string{"http://assets.example/a", "https://assets.example:444/a", "https://assets.example.other/a", "https://user@assets.example/a", "https://127.0.0.1/a", "https://assets.example/a#fragment"} {
		if allowedTarget(raw, good) {
			t.Fatal("accepted", raw)
		}
	}
	if allowedTarget("https://assets.example", nil) {
		t.Fatal("empty DNS")
	}
	if allowedTarget("https://assets.example", append(good, netip.MustParseAddr("::1"))) {
		t.Fatal("mixed DNS")
	}
}
func TestInputAndCSRF(t *testing.T) {
	if decode(`{"name":"ok"}`) != nil {
		t.Fatal("valid rejected")
	}
	for _, raw := range []string{`{"name":"a","role":"admin"}`, `{"name":"a"}{}`, `{"name":""}`, strings.Repeat("x", 129)} {
		if decode(raw) == nil {
			t.Fatal("accepted", raw)
		}
	}
	token := strings.Repeat("t", 32)
	if !csrfAllowed("https://app.example", token, token) || csrfAllowed("https://app.example", "", token) || csrfAllowed("", token, token) {
		t.Fatal("csrf")
	}
	if p, ok := destination("account"); !ok || p != "/account" {
		t.Fatal("redirect")
	}
}

运行命令 ​

sh
go mod download
go run .
go test -race -count=1 ./...
go vet ./...

保留各书来源与许可 · 示例运行条件以正文为准