Posted in

Go中redirect与forward混淆导致越权漏洞,资深架构师亲授4层防御体系,仅剩最后87位开发者未掌握

第一章:Go中redirect与forward混淆导致越权漏洞的本质剖析

在Go Web开发中,http.Redirect 与服务端内部转发(常被误称为“forward”)存在根本性语义差异,但开发者常因概念混淆而引入垂直/水平越权漏洞。关键在于:Redirect 是客户端重定向(3xx响应),浏览器发起新请求,原始HTTP上下文(如r.Context()、中间件注入的用户身份信息)完全丢失;而真正的“forward”在标准net/http中并不存在——所谓“forward”实为服务端内部逻辑跳转(如通过函数调用或路由复用),需显式传递认证上下文。

重定向场景下的典型漏洞模式

当开发者错误地使用http.Redirect(w, r, "/admin/dashboard", http.StatusFound)从普通用户路由跳转至管理接口时,新请求不携带原会话凭证,若后端未对/admin/dashboard做独立鉴权,攻击者可直接构造该URL绕过前置校验。

服务端逻辑跳转的安全实践

应避免依赖重定向实现权限内跳转。正确方式是复用处理器逻辑,并显式传递上下文:

// ✅ 安全:内部调用,上下文完整保留
func userHandler(w http.ResponseWriter, r *http.Request) {
    // 已验证用户权限
    if hasAdminRole(r.Context()) {
        adminDashboardHandler(w, r) // 直接调用,不丢失r.Context()
        return
    }
    http.Error(w, "Forbidden", http.StatusForbidden)
}

// ❌ 危险:重定向导致上下文丢失
func unsafeUserHandler(w http.ResponseWriter, r *http.Request) {
    if hasAdminRole(r.Context()) {
        http.Redirect(w, r, "/admin/dashboard", http.StatusFound) // 新请求无权限上下文!
        return
    }
}

鉴权检查的强制位置

检查时机 是否安全 原因
处理器入口处 每次请求独立校验
重定向前 无法约束重定向后的请求
中间件统一拦截 覆盖所有路径,含重定向目标

务必确保所有敏感端点(如/admin/*)的处理器自身完成鉴权,而非依赖上游重定向决策。Go的http.ServeMux不提供内置forward机制,任何“转发”都必须手动保障上下文与权限状态的连续性。

第二章:HTTP跳转机制的底层原理与Go标准库实现

2.1 HTTP状态码语义辨析:301/302/307/308在Go中的精确控制

HTTP重定向语义差异直接影响缓存行为、方法保留与客户端兼容性。Go 的 http.Redirect 默认使用 302 Found,但无法区分 307 Temporary Redirect308 Permanent Redirect 的关键特性——是否强制保持原始请求方法和请求体

重定向语义核心差异

状态码 语义 方法保留 缓存默认 Go 原生支持
301 永久移动 ❌(GET) ✅(http.StatusMovedPermanently
302 临时移动 ❌(GET) ✅(http.StatusFound
307 临时重定向 ❌(需手动设置)
308 永久重定向 ❌(需手动设置)

手动构造 307/308 响应

func redirectWithMethodPreserve(w http.ResponseWriter, r *http.Request, location string, statusCode int) {
    w.Header().Set("Location", location)
    w.WriteHeader(statusCode) // 307 or 308
}

此函数绕过 http.Redirect 的方法归一化逻辑,直接写入 Location 头并设状态码,确保 POST/PUT 等非幂等请求在重定向时不被客户端自动转为 GETstatusCode 必须为 307308,否则语义失效。

客户端行为差异示意

graph TD
    A[原始请求 POST /api/v1/data] -->|302| B[客户端转为 GET /new]
    A -->|307/308| C[客户端保持 POST /new]

2.2 net/http.Redirect函数的隐式行为与安全边界分析

隐式状态码选择逻辑

net/http.Redirect 在未显式指定 statusCode 时,默认使用 http.StatusFound(302),而非更语义化的 http.StatusMovedPermanently(301)或 http.StatusSeeOther(303)。该行为易引发缓存、方法变更等副作用。

安全边界关键约束

  • 仅当 req.URL.Scheme"http""https" 时才允许重定向到绝对 URL
  • 相对路径(如 /admin)被自动补全为同源路径,但空字符串 "" 会触发 http.Error panic
  • Location 头值未经 URL 标准化校验,可能注入换行符(CRLF)导致响应头分裂

典型风险代码示例

// ❌ 危险:用户输入直接拼接,无校验
http.Redirect(w, r, r.FormValue("next"), http.StatusFound)

参数说明whttp.ResponseWriterr*http.Request,第三参数 url 若含恶意 \r\n 将破坏 HTTP 头完整性;statusCode 若传 ,Go 会静默替换为 302,掩盖意图。

场景 默认状态码 方法保留 安全建议
POST → 重定向 302 否(转GET) 显式用 303307
绝对 URL 且非 HTTPS 拒绝 强制白名单 Scheme
graph TD
    A[调用 http.Redirect] --> B{url 是否以 / 开头?}
    B -->|是| C[补全为同源绝对路径]
    B -->|否| D{Scheme 是否为 http/https?}
    D -->|否| E[panic: illegal scheme]
    D -->|是| F[写入 Location 头]

2.3 Server-Side Forward(内部转发)在Go中的非标准实现路径

Go 的 net/http 标准库不提供原生的 Server-Side Forward(类似 Servlet 的 RequestDispatcher.forward()),需通过手动请求复用与上下文透传模拟。

核心思路:请求重写 + handler 链式调用

  • 复用原始 *http.Request 对象,修改 URL.PathURL.RawQuery
  • 保留 HeaderBodyContext 等关键字段,避免序列化开销
  • 调用目标 handler 时传入修改后的 request 和原始 response writer

关键限制与规避策略

  • ❌ 无法跨 ServeMux 分发(因 ServeMux 匹配发生在入口)
  • ✅ 替代方案:自定义 ForwardHandler 封装目标 handler 并劫持匹配逻辑
func NewForwardHandler(targetPath string, h http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 深拷贝 URL 以避免副作用
        newURL := *r.URL
        newURL.Path = targetPath
        newURL.RawQuery = r.URL.RawQuery // 保留查询参数

        // 构造新请求(共享 Body 和 Context)
        newReq := r.Clone(r.Context())
        newReq.URL = &newURL

        h.ServeHTTP(w, newReq) // 直接委托
    })
}

逻辑分析r.Clone(r.Context()) 确保 Context 继承(含取消信号、值存储),newReq.URL 替换实现路径重定向;Body 未重置,需确保上游 handler 未读取或已 r.Body.Close()
参数说明targetPath 为绝对路径(如 /api/v2/users),h 是目标业务 handler,支持中间件链嵌套。

特性 标准重定向 Server-Side Forward(本实现)
客户端可见 URL 变更
请求方法/Body 保留
Context 传递 ❌(新请求) ✅(克隆继承)
graph TD
    A[Client Request] --> B[Router Match]
    B --> C[ForwardHandler]
    C --> D[Modify r.URL.Path]
    C --> E[r.Clone Context]
    D & E --> F[Delegate to Target Handler]
    F --> G[Write Response]

2.4 基于http.ServeFile与http.StripPrefix的伪forward陷阱复现

当开发者误将 http.ServeFilehttp.StripPrefix 组合用于“静态资源代理”时,极易触发路径遍历漏洞——表面看似实现了类似 Nginx 的 location /static/ 转发,实则丧失路径安全校验。

问题代码示例

// ❌ 危险写法:StripPrefix 后直接 ServeFile
http.Handle("/static/", http.StripPrefix("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    http.ServeFile(w, r, "./assets"+r.URL.Path) // ⚠️ 未校验 r.URL.Path
})))

r.URL.Path 可含 ../,如请求 /static/../../etc/passwd 将被拼接为 ./assets/../../etc/passwd,绕过前缀剥离逻辑,导致任意文件读取。

安全对比表

方式 路径校验 支持目录列表 推荐场景
http.ServeFile(裸用) 仅限已知单文件
http.FileServer + http.StripPrefix ✅(内置) 安全静态服务
自定义 Handler + path.Clean ✅(需手动) 需定制逻辑

正确修复路径

  • 使用 http.FileServer(http.Dir("./assets")) 替代 ServeFile
  • 或对 r.URL.Path 执行 path.Clean() 并校验是否以 / 开头且无 ..

2.5 Go 1.22+中net/http.HandlerFunc与middleware对跳转链路的劫持风险

中间件隐式覆盖HandlerFunc的ResponseWriter

Go 1.22+中,http.Handler接口实现若未显式包装ResponseWriter,中间件可能意外劫持重定向状态码:

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // ❌ 缺少ResponseWriter包装,WriteHeader(302)将被静默丢弃
        next.ServeHTTP(w, r) // 直接透传原始w
    })
}

w未被ResponseWriter装饰器封装时,中间件无法拦截WriteHeader()调用,导致http.Redirect()等跳转操作失效或被后续中间件覆盖。

跳转链路劫持的三类典型场景

  • ✅ 正确:使用wrapWriter捕获WriteHeader()并记录状态码
  • ⚠️ 风险:http.StripPrefix后未重置Request.URL.Path,引发路径错位跳转
  • ❌ 危险:多个中间件并发调用w.WriteHeader(302),最终仅首个生效

Go 1.22+新增的http.ResponseController缓解机制

组件 作用 是否解决劫持
ResponseController.Pusher 主动推送资源
ResponseController.SetWriteTimeout 控制写超时
ResponseController.WriteHeader 原子化写头控制 ✅ 是
graph TD
    A[Client Request] --> B[Mux Router]
    B --> C[Logging MW]
    C --> D[Auth MW]
    D --> E[HandlerFunc]
    E -->|WriteHeader 302| F[ResponseWriter]
    F -->|劫持点| G[未包装的w.WriteHeader]
    G --> H[跳转丢失/覆盖]

第三章:越权漏洞的典型攻击模式与真实案例还原

3.1 身份上下文丢失导致的跨角色跳转越权(RBAC绕过)

当用户从管理员界面跳转至审计员视图时,若后端未显式重载或校验 roletenant_id 上下文,仅依赖前端传递的路径参数,RBAC策略将失效。

典型漏洞触发链

  • 用户登录后获取 JWT,其中含 role: "admin"org_id: "A"
  • 前端跳转 /audit/logs?target_org=B,服务端未校验当前会话是否具备 B 组审计权限
  • AuthInterceptor 错误地复用原始请求上下文,忽略路径参数引发的租户/角色变更

安全校验缺失示例

// ❌ 危险:仅校验token签名,未验证上下文一致性
String targetOrg = request.getParameter("target_org");
Principal principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// 缺失:principal.getOrgId() != targetOrg 的强制校验

该代码跳过租户边界检查,使管理员可凭合法 token 访问任意租户审计日志,构成跨角色越权。

修复前后对比

检查维度 修复前 修复后
角色有效性 仅校验 token 签名 校验 role + target_org 组合授权
上下文生命周期 复用初始请求上下文 每次路由跳转重建 SecurityContext
graph TD
    A[用户发起 /audit/logs?target_org=B] --> B{AuthInterceptor}
    B --> C[解析JWT 获取 role=admin, org_id=A]
    C --> D[❌ 忽略 target_org=B]
    D --> E[返回 B 组审计日志]

3.2 Referer与Origin校验缺失引发的开放重定向链式攻击

当Web应用仅依赖客户端传入的 redirect_url 参数跳转,且未校验 RefererOrigin 头时,攻击者可构造恶意中间页发起链式重定向。

攻击路径示意

GET /auth/callback?redirect=https://evil.com/steal HTTP/1.1
Host: app.example.com
Referer: https://legit-site.com
Origin: https://legit-site.com

→ 服务端忽略 Referer/Origin,直接 302 Location: https://evil.com/steal
→ 用户浏览器执行跳转,泄露认证态(如带 Authorization 的后续请求)

关键漏洞点

  • 服务端未验证 Referer 是否属于白名单域名
  • Origin 头被绕过(如从非 CORS 上下文发起)
  • 重定向目标未做协议+域名白名单匹配(仅简单字符串包含检测)

防御对比表

方案 有效性 缺陷
Referer 白名单校验 可被移除或伪造(如 <meta referrer="none">
Origin 校验 高(CORS 请求) 对普通 GET 重定向无效
服务端绝对路径白名单 需维护动态可信 URL 列表
graph TD
    A[用户点击第三方链接] --> B[跳转至 /login?r=malicious.com]
    B --> C{服务端是否校验 Origin/Referer?}
    C -->|否| D[302 至恶意地址]
    C -->|是| E[仅允许 trusted.example.com]
    D --> F[窃取 token 或 session]

3.3 Gin/Echo框架中中间件顺序错误引发的forward语义污染

中间件执行顺序直接决定请求上下文(*gin.Context / echo.Context)的状态演进路径。当身份认证中间件置于日志记录之后,未认证请求仍会触发日志写入,造成敏感字段(如空token、伪造IP)被误记为有效流量。

典型错误链路

// ❌ 危险顺序:日志 → 认证 → 路由
r.Use(loggerMiddleware)   // 此时 c.Request.Header 未校验
r.Use(authMiddleware)     // 后续才拒绝,但日志已落盘
r.GET("/admin", adminHandler)

逻辑分析:loggerMiddlewarec.Next() 前读取 c.Request.RemoteIP()c.Request.Header.Get("Authorization"),此时认证尚未执行,c.Get("user_id") 为空,但日志仍以“成功请求”格式输出,污染可观测性语义。

正确顺序对照表

中间件类型 推荐位置 原因
认证/鉴权 靠前 尽早拦截,避免下游处理
日志/监控 靠后 确保上下文已稳定(含用户ID)
CORS/压缩 最外层 作用于最终响应体

请求生命周期污染示意

graph TD
    A[Request] --> B[Logger: 记录未认证IP]
    B --> C[Auth: 拒绝并写401]
    C --> D[Response: 但日志已含“200”语义]

第四章:四层纵深防御体系构建与工程化落地

4.1 第一层:编译期约束——自定义Redirect封装与go:vet规则增强

Go 的 go vet 默认不校验 HTTP 重定向逻辑的语义正确性。我们通过自定义 Redirect 封装类型,配合 go:vet 规则增强,在编译期捕获非法跳转。

自定义安全重定向类型

// SafeRedirect 封装标准 http.Redirect,强制校验 Location 前缀
type SafeRedirect struct {
    AllowPrefixes []string // 如 []string{"https://", "/"}
}

func (r *SafeRedirect) Redirect(w http.ResponseWriter, req *http.Request, urlStr string, code int) {
    for _, prefix := range r.AllowPrefixes {
        if strings.HasPrefix(urlStr, prefix) {
            http.Redirect(w, req, urlStr, code)
            return
        }
    }
    panic("unsafe redirect: " + urlStr) // 编译期不可达,但 vet 可静态分析
}

该封装强制开发者显式声明白名单前缀;panic 虽在运行时触发,但 go vet 可结合 SSA 分析识别未覆盖分支,提示潜在 unsafe redirect。

vet 规则增强点

  • ✅ 检测裸 http.Redirect 调用(绕过 SafeRedirect)
  • ✅ 标记 urlStr 非字面量字符串(如拼接变量)
  • ✅ 报告缺失 AllowPrefixes 初始化
检查项 触发条件 修复建议
裸重定向 直接调用 http.Redirect 替换为 SafeRedirect.Redirect
动态 URL urlStr 为非 const 表达式 引入白名单校验逻辑
graph TD
A[源码解析] --> B[SSA 构建]
B --> C{是否调用 http.Redirect?}
C -->|是| D[标记警告]
C -->|否| E[检查 SafeRedirect 调用]
E --> F[验证 AllowPrefixes 初始化]

4.2 第二层:运行时拦截——HTTP跳转白名单策略与context.Context注入验证

白名单校验逻辑

HTTP重定向跳转需防范开放重定向漏洞。核心是校验 Location 头是否属于预设可信域名:

func isWhitelistedRedirect(u *url.URL) bool {
    // 只允许 HTTPS 协议 + 白名单域名(含子域)
    for _, domain := range []string{"app.example.com", "api.example.com"} {
        if strings.HasSuffix(u.Host, domain) && u.Scheme == "https" {
            return true
        }
    }
    return false
}

逻辑分析:strings.HasSuffix 支持子域匹配(如 dashboard.app.example.com),避免硬编码完整 host;强制 https 防止协议降级。参数 u 来自 http.Redirect() 前解析的跳转目标 URL。

context.Context 注入验证

中间件须将请求上下文注入跳转链路,确保审计可追溯:

字段 类型 用途
request_id string 全链路唯一标识
trace_id string 分布式追踪 ID
allowed_hosts []string 动态白名单源

拦截流程

graph TD
    A[HTTP 请求] --> B{检查 Redirect?}
    B -->|是| C[解析 Location Header]
    C --> D[白名单校验]
    D -->|失败| E[返回 403]
    D -->|成功| F[注入 context.Context]
    F --> G[执行跳转]
  • 白名单支持运行时热更新(通过 sync.Map 缓存)
  • context.WithValue() 仅用于传递不可变元数据,避免跨 goroutine 泄漏

4.3 第三层:框架层加固——Gin/Echo/Chi适配器的SafeRedirect中间件实现

安全重定向的核心挑战

未经校验的 Location 头可能导致开放重定向漏洞。SafeRedirect 中间件需统一拦截 301/302/307/308 响应,仅允许相对路径或白名单域名。

三框架适配策略

  • Gin:通过 gin.ResponseWriter 包装器劫持 Header().Set("Location", ...)
  • Echo:利用 echo.HTTPErrorHandler + 自定义 Response.Redirect() 封装
  • Chi:借助 http.ResponseWriter 接口代理,在 WriteHeader() 后校验 Header().Get("Location")

核心校验逻辑(Gin 示例)

func SafeRedirect(whitelist []string) gin.HandlerFunc {
    return func(c *gin.Context) {
        w := &safeWriter{ResponseWriter: c.Writer, whitelist: whitelist}
        c.Writer = w
        c.Next()
    }
}

type safeWriter struct {
    gin.ResponseWriter
    whitelist []string
}

func (w *safeWriter) WriteHeader(code int) {
    if code == http.StatusMovedPermanently || code == http.StatusFound {
        loc := w.Header().Get("Location")
        if !isValidRedirect(loc, w.whitelist) {
            http.Error(w.ResponseWriter, "Forbidden redirect", http.StatusForbidden)
            return
        }
    }
    w.ResponseWriter.WriteHeader(code)
}

逻辑分析:中间件在 WriteHeader 阶段介入,仅当状态码为重定向类时检查 LocationisValidRedirect 判定逻辑:空值/相对路径直接放行,绝对 URL 必须匹配白名单域名(支持通配符如 *.example.com);参数 whitelist 为预加载可信域名列表,避免运行时解析开销。

框架兼容性对比

框架 重定向拦截点 是否需修改原有 Redirect 调用
Gin ResponseWriter 包装
Echo Context.Redirect() 封装 是(需替换调用)
Chi ResponseWriter 代理
graph TD
    A[HTTP 请求] --> B[路由匹配]
    B --> C[业务 Handler]
    C --> D{响应写入前}
    D -->|30x 状态码| E[提取 Location Header]
    D -->|非重定向| F[直出响应]
    E --> G[白名单校验]
    G -->|通过| H[正常返回]
    G -->|拒绝| I[返回 403]

4.4 第四层:可观测性闭环——跳转链路TraceID埋点与越权行为实时告警

TraceID 全链路透传机制

在网关层注入全局唯一 X-Trace-ID,并通过 HTTP Header 向下游服务透传。关键需保障跨线程、异步调用(如 Kafka 生产/消费、线程池)场景下的上下文延续。

// Spring Cloud Sleuth 已弃用,改用 Micrometer Tracing + OpenTelemetry
@Bean
public WebClient.Builder webClientBuilder(Tracer tracer) {
    return WebClient.builder()
        .filter((request, next) -> {
            String traceId = tracer.currentSpan().context().traceId();
            ClientRequest newRequest = ClientRequest.from(request)
                .header("X-Trace-ID", traceId) // 强制透传标准字段
                .build();
            return next.exchange(newRequest);
        });
}

逻辑分析:tracer.currentSpan().context().traceId() 获取当前 Span 的 16 进制 TraceID;X-Trace-ID 为统一约定字段,避免与 traceparent 混用导致采样冲突;filter 确保所有出站请求自动携带,无需业务代码显式干预。

越权行为实时检测策略

基于埋点日志构建行为图谱,识别“非角色路径跳转”模式(如普通用户直接访问 /admin/user/delete)。

检测维度 规则示例 响应动作
接口路径匹配 ^/admin/.*role != 'ADMIN' 触发告警并阻断
TraceID 关联 同一 TraceID 内连续 3 次越权尝试 升级为会话级封禁

实时告警闭环流程

graph TD
    A[API Gateway 日志] --> B{Flume/Kafka 实时接入}
    B --> C[Spark Structured Streaming]
    C --> D[规则引擎匹配越权模式]
    D --> E[触发 Prometheus Alertmanager]
    E --> F[飞书机器人推送+自动隔离 IP]

越权判定依赖 TraceID 关联的完整调用链,确保非孤立接口判断,而是结合用户身份、操作路径、上下文参数的联合决策。

第五章:从漏洞修复到架构演进的思考升级

在2023年某金融SaaS平台的一次安全审计中,团队发现一个看似普通的Spring Boot Actuator未授权访问漏洞(CVE-2022-22965变种),初始响应仅限于升级依赖版本并关闭敏感端点。但深入日志分析后发现:该接口被持续高频调用超37天,且请求路径携带异常参数?trace=true&include=env,metrics——这并非扫描器试探,而是攻击者已构建出定制化信息收集链路。

漏洞根因的三层穿透分析

分析层级 发现事实 架构影响
代码层 @ConfigurationProperties绑定未校验前缀合法性 配置注入风险面扩大至所有属性绑定场景
框架层 Spring Boot 2.6.x默认启用/actuator/env且无IP白名单机制 运维能力与开发框架耦合过紧
架构层 所有微服务共用同一套配置中心,环境变量通过HTTP暴露而非gRPC拉取 配置服务成为单点信任锚点

从补丁到重构的关键转折点

团队放弃“打补丁式修复”,启动为期6周的架构收敛项目:

  • 将12个独立部署的Actuator端点收敛为统一网关级健康检查服务,采用双向TLS+JWT鉴权;
  • 引入Service Mesh的Sidecar拦截所有/actuator/*请求,强制重定向至中央可观测性平台;
  • 重构配置中心,环境变量改用SPIFFE身份证书动态签发,避免明文传输。
graph LR
A[原始架构] --> B[每个服务暴露/actuator/env]
B --> C[攻击者获取数据库连接字符串]
C --> D[横向渗透至核心交易服务]
D --> E[数据泄露]
F[新架构] --> G[Sidecar拦截所有actuator请求]
G --> H[转发至中央可观测平台]
H --> I[基于SPIFFE证书动态生成临时凭证]
I --> J[凭证15分钟自动失效]

真实故障复盘带来的认知跃迁

2024年Q1一次生产事故中,因某中间件升级导致/actuator/health返回503,传统监控告警仅触发“服务不可用”通知。而新架构下,中央可观测平台捕获到Sidecar上报的异常模式:health端点失败率骤升但/metrics仍正常——这指向中间件线程池耗尽而非服务崩溃。运维团队据此精准定位到HikariCP连接池配置错误,恢复时间从47分钟缩短至8分钟。

技术债偿还的量化指标体系

建立漏洞修复ROI评估模型:

  • 修复成本 = 工时 × 人天单价 + 停机损失
  • 架构收益 = (MTTR降低值 × 年均故障次数) – (新组件运维成本)
    当单次漏洞修复收益>$23,500时,强制触发架构评审流程。该规则已在3个核心系统落地,平均架构干预周期从季度级压缩至双周级。

开发者心智模型的实质性转变

前端团队在接入新健康检查SDK时,主动提交PR增加/health?detailed=false参数支持——他们意识到:轻量级探针应与诊断级探针物理隔离。这种意识转变源于架构组发布的《可观测性契约白皮书》,其中明确要求所有服务必须实现三级健康状态:Liveness(K8s probe)、Readiness(负载均衡器)、Diagnostic(人工介入)。

2024年至今,平台未再发生因Actuator类漏洞导致的数据泄露事件,但配置中心API调用量下降62%,因为83%的服务已切换至本地缓存+长轮询模式。

以代码为修行,在 Go 的世界里静心沉淀。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注