第一章:Go JSON序列化陷阱(结构体标签失效链)导论
Go 语言中,json.Marshal 和 json.Unmarshal 的行为高度依赖结构体字段的可见性与标签(tag)配置。然而,一个看似微小的疏忽——例如字段未导出、标签拼写错误、或嵌套结构体中标签继承中断——便可能引发静默失效:序列化结果缺失字段、字段名不符合预期,甚至整个嵌套对象被忽略。
常见失效场景包括:
- 字段首字母小写(如
name string),导致无法被encoding/json包访问; - 标签中使用了错误的分隔符(如
json:"name,"多余逗号)或非法字符; - 使用
json:",omitempty"时,零值字段被意外剔除,而业务逻辑实际需要保留空字符串或零值占位; - 嵌套匿名结构体未显式指定标签,导致外层标签无法穿透生效。
以下代码演示典型失效链:
type User struct {
Name string `json:"name"`
Age int `json:"age"`
Addr address // 未导出字段,且无 json tag
}
type address struct { // 首字母小写 → 非导出类型
City string `json:"city"`
}
// 序列化结果:{"name":"Alice","age":30} —— Addr 完全消失,无报错、无警告
u := User{Name: "Alice", Age: 30, Addr: address{City: "Beijing"}}
data, _ := json.Marshal(u)
fmt.Println(string(data)) // 输出不含 City 字段
修复方案需同步满足三项条件:字段导出(首字母大写)、嵌套类型导出、标签显式声明。正确写法如下:
type User struct {
Name string `json:"name"`
Age int `json:"age"`
Addr Address `json:"addr"` // 显式命名 + 导出字段
}
type Address struct { // 首字母大写,导出类型
City string `json:"city"`
}
| 失效原因 | 检查要点 | 修复动作 |
|---|---|---|
| 字段不可见 | 字段名是否首字母大写? | 改为 Addr 而非 addr |
| 标签语法错误 | 是否含多余空格、逗号或引号? | 使用 json:"city" 而非 json:"city ", |
| 嵌套类型未导出 | 内嵌结构体是否为导出类型? | address → Address |
标签失效不是运行时 panic,而是“静默丢弃”,极易在测试覆盖不足时流入生产环境。务必在单元测试中校验 JSON 输出的字段完整性与结构一致性。
第二章:结构体字段可见性与JSON标签基础失效
2.1 字段首字母小写导致的不可导出性陷阱(理论+可复现代码验证)
Go语言规定:仅首字母大写的结构体字段才可被外部包导出(exported)。小写字母开头的字段在跨包访问时为私有,序列化/反射/ORM等场景将直接忽略。
序列化失效现象
package main
import (
"encoding/json"
"fmt"
)
type User struct {
Name string `json:"name"` // ✅ 导出字段
age int `json:"age"` // ❌ 首字母小写 → 不导出
}
func main() {
u := User{Name: "Alice", age: 30}
data, _ := json.Marshal(u)
fmt.Println(string(data)) // 输出:{"name":"Alice"} —— age 字段消失!
}
逻辑分析:
json.Marshal通过反射遍历结构体字段;age因未导出(unexported),反射无法读取其值,故跳过序列化。参数age的标签json:"age"无效——标签仅对导出字段生效。
关键规则对比
| 字段声明 | 是否导出 | JSON序列化可见 | 反射可读取 |
|---|---|---|---|
Name string |
✅ 是 | ✅ | ✅ |
age int |
❌ 否 | ❌(静默丢弃) | ❌ |
修复方案
- 将
age改为Age int - 或使用
json标签配合json:",omitempty"等增强语义(但前提仍是字段必须导出)
2.2 匿名字段嵌入时标签继承失效的边界条件(理论+嵌套结构体实测)
标签继承的基本前提
Go 中匿名字段嵌入时,外层结构体可访问内层字段的 json、xml 等 struct tag——仅当嵌入层级为直接一级且无同名字段遮蔽。
失效的典型边界条件
- 嵌入字段被同名字段显式覆盖(即使类型不同)
- 嵌入链中任一中间结构体使用 指针类型匿名字段(
*Inner而非Inner) - 最内层字段为未导出字段(首字母小写),导致标签不可见
实测嵌套结构体行为
type Inner struct {
Field string `json:"inner_field"`
}
type Middle struct {
Inner // ✅ 直接嵌入:标签有效
}
type Outer struct {
Middle // ❌ 间接嵌入:Outer 不继承 inner_field 标签
}
逻辑分析:
Outer通过Middle间接持有Inner,但json包反射仅扫描直接匿名字段;Middle是具名字段(虽无字段名,但类型名Middle占位),故Inner的json:"inner_field"对Outer不可见。参数说明:reflect.StructTag.Get("json")在Outer的字段遍历中无法命中Inner的 tag。
| 嵌入方式 | 标签是否继承 | 原因 |
|---|---|---|
type A struct{ B } |
✅ 是 | 直接匿名字段 |
type A struct{ *B } |
❌ 否 | 指针类型破坏嵌入语义 |
type A struct{ b B } |
❌ 否 | 非匿名(具名字段) |
graph TD
A[Outer] -->|嵌入| B[Middle]
B -->|嵌入| C[Inner]
C -->|tag: json:\"inner_field\"| D[仅对Middle生效]
A -.->|反射遍历时跳过C| E[标签不可达]
2.3 json:"-" 与空标签 json:"" 的语义混淆与静默丢弃(理论+反序列化对比实验)
核心语义差异
json:"-":显式忽略字段,序列化与反序列化均跳过,Go 标准库完全不处理该字段json:"":空标签被视为字段名为空字符串,触发encoding/json的特殊规则——反序列化时静默丢弃该字段值,但保留零值初始化
反序列化行为对比实验
type User struct {
Ignored string `json:"-"`
EmptyTag string `json:""`
}
// 输入 JSON: {"Ignored":"x","":"y"}
var u User
json.Unmarshal([]byte(`{"Ignored":"x","":"y"}`), &u)
// 结果:u.Ignored==""(未赋值),u.EmptyTag==""(零值,"y"被静默丢弃)
逻辑分析:
json:"-"字段在Unmarshal的字段匹配阶段即被过滤;而json:""会参与键匹配,因 JSON 键""无法映射到结构体字段(无对应命名字段),故值"y"被直接丢弃,不报错、不警告。
行为差异速查表
| 标签形式 | 序列化行为 | 反序列化行为 | 是否报错 |
|---|---|---|---|
json:"-" |
完全跳过字段 | 完全跳过字段 | 否 |
json:"" |
输出空键 ""(极少用) |
匹配失败 → 静默丢弃值 | 否 |
关键风险提示
- 空标签
json:""在反序列化中不触发 error,也不填充字段,极易导致数据丢失且难以调试 - 二者均无运行时提示,依赖开发者对标签语义的精确理解
2.4 omitempty 在零值类型上的误判链:指针、切片、map 的隐式空判定(理论+nil vs empty 深度验证)
omitempty 并非判断“是否为 nil”,而是依据 反射零值规则 进行序列化裁剪,导致语义混淆:
nil 与 empty 的本质差异
*int:nil≠new(int)(后者指向值为的有效地址)[]string:nil切片与[]string{}(len=0, cap=0)在json.Marshal中均被 omitempty,但底层结构不同map[string]int:nilmap 与make(map[string]int)均为空映射,但len()和for range行为一致
关键验证代码
type Config struct {
Ptr *int `json:"ptr,omitempty"`
Slice []int `json:"slice,omitempty"`
Map map[int]int `json:"map,omitempty"`
}
func main() {
v := Config{
Ptr: nil, // → 字段消失
Slice: []int{}, // → 字段消失(len==0)
Map: make(map[int]int), // → 字段消失(len==0)
}
b, _ := json.Marshal(v)
fmt.Println(string(b)) // 输出:{}
}
逻辑分析:json 包对每个字段调用 isEmptyValue(),该函数对指针判 v.IsNil(),对 slice/map 判 v.Len() == 0,完全忽略底层是否分配内存。
零值判定对照表
| 类型 | nil 状态 |
len() |
isEmptyValue() 结果 |
序列化是否省略 |
|---|---|---|---|---|
*int |
true |
— | true |
✅ |
[]int{} |
false |
|
true |
✅ |
map[int]int{} |
false |
|
true |
✅ |
误判链形成路径
graph TD
A[struct tag omitempty] --> B[json.isEmptyValue]
B --> C1[指针:IsNil?]
B --> C2[切片/Map:Len()==0?]
C1 --> D1[忽略非nil但指向零值]
C2 --> D2[混淆nil与empty底层数组]
D1 & D2 --> E[数据丢失风险]
2.5 结构体字段重命名冲突:同名字段在嵌入结构体中的标签覆盖失效(理论+字段阴影现象复现)
当多个嵌入结构体包含同名字段时,Go 的标签(json、xml 等)不会合并或优先级覆盖,而是被最外层直接定义的字段所遮蔽——即发生“字段阴影”(field shadowing)。
字段阴影复现实例
type User struct {
Name string `json:"name"`
}
type Admin struct {
User
Name string `json:"admin_name"` // ❌ 不生效:Name 已被嵌入 User 的 Name 遮蔽
}
func main() {
a := Admin{User: User{Name: "Alice"}, Name: "Bob"}
fmt.Println(json.Marshal(a)) // 输出: {"name":"Alice"}
}
逻辑分析:
Admin中显式声明的Name字段虽带json:"admin_name"标签,但因与嵌入User.Name同名,Go 编译器将其视为对嵌入字段的遮蔽声明;序列化时仍使用嵌入字段的标签,显式字段的标签被完全忽略。
关键行为规则
- 嵌入字段优先于同名显式字段参与结构体导出和序列化
- 标签仅作用于实际参与内存布局的字段,遮蔽字段不参与 JSON 映射
- 无编译错误,属静默语义陷阱
| 场景 | 是否触发阴影 | 标签是否生效 |
|---|---|---|
| 同名嵌入 + 同名显式字段 | ✅ 是 | ❌ 显式字段标签失效 |
不同名字段(如 AdminName) |
❌ 否 | ✅ 生效 |
graph TD
A[定义 Admin 结构体] --> B{存在同名嵌入字段?}
B -->|是| C[显式字段被遮蔽]
B -->|否| D[标签正常绑定]
C --> E[JSON 序列化使用嵌入字段标签]
第三章:反射机制与JSON编码器内部行为偏差
3.1 json.Encoder 缓冲写入与 panic 恢复导致的静默截断(理论+io.Writer 实现干扰分析)
数据同步机制
json.Encoder 内部维护一个 bufio.Writer,调用 Encode() 时先序列化到缓冲区,仅当缓冲区满或显式 Flush() 时才真正写入底层 io.Writer。若在此过程中发生 panic(如自定义 Write 方法中触发),而调用方用 recover() 捕获却忽略错误,缓冲区残留数据将永久丢失。
关键干扰点
以下 io.Writer 实现会破坏 Encoder 的错误传播契约:
type BrokenWriter struct{ buf []byte }
func (w *BrokenWriter) Write(p []byte) (n int, err error) {
// 忽略部分写入,且不返回错误 → Encoder 认为成功,但数据未落盘
if len(p) > 10 {
w.buf = append(w.buf, p[:10]...)
return 10, nil // ❌ 静默截断
}
w.buf = append(w.buf, p...)
return len(p), nil
}
逻辑分析:
json.Encoder.Encode()调用Write()后仅检查err != nil;此处始终返回nil错误,导致编码器认为全部字节已写出,实际p[10:]被丢弃。缓冲区后续Flush()也无济于事——因为Write()本就未报告失败。
| 场景 | 是否触发 panic | 是否静默截断 | 根本原因 |
|---|---|---|---|
Write 返回 n<len(p), err=nil |
否 | 是 | Encoder 信任 n 正确性 |
Write panic + recover() 未重抛 |
是 | 是 | defer 中 Flush() 被跳过 |
graph TD
A[Encode call] --> B[Serialize to buffer]
B --> C{Write to io.Writer}
C -->|n < len| D[Encoder assumes success]
C -->|panic| E[recover without error re-throw]
D --> F[Buffer not flushed → data lost]
E --> F
3.2 自定义 MarshalJSON 方法中未调用 json.Marshal 引发的递归缺失(理论+错误实现 vs 正确委托模式)
错误实现:手动拼接 JSON 字符串
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func (u User) MarshalJSON() ([]byte, error) {
return []byte(`{"id":` + strconv.Itoa(u.ID) + `,"name":"` + u.Name + `"}`), nil
}
⚠️ 问题:忽略字段标签、转义逻辑、嵌套结构序列化,且完全绕过标准 json.Marshal 的递归处理机制,导致 time.Time、map[string]interface{} 等嵌套字段无法正确序列化。
正确委托模式:安全复用标准库
func (u User) MarshalJSON() ([]byte, error) {
type Alias User // 防止无限递归
return json.Marshal(&struct {
*Alias
FullName string `json:"full_name"`
}{
Alias: (*Alias)(&u),
FullName: u.Name + " (user)",
})
}
✅ 关键:通过匿名嵌入 Alias 类型规避 MarshalJSON 重入,同时复用 json.Marshal 的完整递归能力与转义逻辑。
| 方式 | 递归支持 | 标签解析 | 安全转义 | 嵌套类型兼容 |
|---|---|---|---|---|
| 手动拼接 | ❌ | ❌ | ❌ | ❌ |
委托 json.Marshal |
✅ | ✅ | ✅ | ✅ |
3.3 json.RawMessage 与 interface{} 混用时的类型擦除丢失(理论+动态结构体解析失败案例)
当 json.RawMessage 被赋值给 interface{},Go 运行时会丢失原始字节的结构上下文,仅保留底层 []byte —— 类型信息彻底擦除。
动态解析失效根源
var raw json.RawMessage = []byte(`{"id":1,"name":"alice"}`)
var v interface{} = raw // ← 此刻 raw 的类型元数据已丢失
var user struct{ ID int; Name string }
json.Unmarshal(raw, &user) // ✅ 成功
json.Unmarshal(v.([]byte), &user) // ❌ panic: interface {} is []uint8, not []byte
interface{} 存储的是 []uint8(Go 内部表示),而非可直接解码的 []byte 类型;强制类型断言失败。
关键差异对比
| 场景 | 类型保留 | 可直接 Unmarshal |
运行时安全 |
|---|---|---|---|
json.RawMessage 变量 |
✅ | ✅ | ✅ |
interface{} 中的 RawMessage |
❌ | ❌(需显式转换) | ❌ |
安全解法流程
graph TD
A[RawMessage] --> B{赋值给 interface{}}
B -->|类型擦除| C[丢失 byte slice 语义]
C --> D[必须显式转换:v.([]byte)]
D --> E[否则 panic]
正确做法:始终通过 json.RawMessage 字段接收,避免经由 interface{} 中转。
第四章:跨包交互与第三方库引发的标签污染链
4.1 gorm 或 sqlx 标签与 json 标签共存时的 struct tag 解析优先级冲突(理论+structtag 包源码级验证)
Go 的 reflect.StructTag 本身不定义优先级,仅提供 Get(key) 方法线性查找——json、gorm、sqlx 等标签并列存储于同一字符串,解析权完全交由各库自行实现。
标签解析本质是“按需提取”
type User struct {
ID int `json:"id" gorm:"primaryKey" sqlx:"id"`
Name string `json:"name" gorm:"size:100" sqlx:"name"`
}
json.Marshal仅调用tag.Get("json"),无视gorm/sqlx;gorm内部使用正则^gorm:(.*)$提取并解析gorm子串;sqlx则调用reflect.StructTag.Get("sqlx"),严格区分命名空间。
| 库 | 解析方式 | 是否覆盖其他标签 | 源码依据 |
|---|---|---|---|
encoding/json |
tag.Get("json") |
否 | src/encoding/json/encode.go |
gorm.io/gorm |
自定义 parseTag 函数 |
否 | schema/field.go#ParseTag |
github.com/jmoiron/sqlx |
tag.Get("sqlx") |
否 | bind.go#parseStructTag |
graph TD
A[struct tag string] --> B[json.Unmarshal]
A --> C[gorm.AutoMigrate]
A --> D[sqlx.NamedExec]
B --> B1{Get “json”}
C --> C1{正则匹配 “gorm:.*”}
D --> D1{Get “sqlx”}
冲突只发生在开发者手动混用标签且期望语义重叠时(如 json:"-" gorm:"-"),但各库互不干涉——所谓“优先级”实为误解,本质是标签域隔离。
4.2 mapstructure 解码后二次 JSON 序列化时的标签丢失(理论+struct → map → struct 转换链断裂分析)
标签丢失的本质根源
mapstructure.Decode 将 JSON 解码为 struct 时,仅依据字段名(非结构体标签)映射;而后续 json.Marshal 默认使用 json 标签——但经 mapstructure 中转生成的中间 map[string]interface{} 已剥离所有结构体标签信息。
转换链断裂示意图
graph TD
A[原始JSON] --> B[mapstructure.Decode → struct]
B --> C[struct → map[string]interface{}]
C --> D[json.Marshal → 新JSON]
D -.-> E[丢失 json:\"xxx\" 标签]
典型复现代码
type User struct {
Name string `json:"full_name"`
Age int `json:"age_years"`
}
var raw = `{"full_name":"Alice","age_years":30}`
var m map[string]interface{}
json.Unmarshal([]byte(raw), &m) // 直接解到 map
var u User
mapstructure.Decode(m, &u) // 无标签上下文
b, _ := json.Marshal(u) // 输出:{"Name": "Alice", "Age": 30} ← 标签丢失!
mapstructure.Decode不读取json标签,仅按字段名匹配json.Marshal对 struct 字段默认忽略json标签(因未触发反射标签解析路径)- 中间
map层彻底切断结构体元数据传递链
| 阶段 | 是否保留 json 标签 |
原因 |
|---|---|---|
| 原始 struct | ✅ | 编译期嵌入 |
| 中间 map | ❌ | 运行时无类型信息 |
| 重建 struct | ❌ | mapstructure 不注入标签 |
4.3 Protobuf 生成结构体中 json_name 选项与原生 json tag 的竞争失效(理论+protoc-gen-go 版本兼容性实测)
理论冲突根源
当 .proto 文件中字段同时声明 json_name 选项(如 string name = 1 [json_name="full_name"];),而生成的 Go 结构体又被手动添加 json:"full_name,omitempty" tag 时,protoc-gen-go 会忽略用户自定义 tag——因生成器在代码生成阶段直接覆写 json tag。
版本行为差异(实测)
| protoc-gen-go 版本 | 是否覆盖 json tag |
备注 |
|---|---|---|
| v1.28.x(旧) | 否 | 尊重用户已有 tag |
| v1.30.0+(新) | 是 | 强制以 json_name 为准 |
典型失效示例
// user.proto
message User {
string display_name = 1 [json_name="displayName"];
}
// 手动添加的 tag 将被静默覆盖
type User struct {
DisplayName string `json:"display_name,omitempty"` // ← 无效!生成后变为 `json:"displayName,omitempty"`
}
生成逻辑:
protoc-gen-go在v1.30.0+中通过descriptorpb.FieldOptions.JsonName优先注入jsontag,跳过ast层已有 tag 检查。
解决路径
- ✅ 始终通过
json_name控制序列化名 - ❌ 避免对生成结构体手工修改
jsontag - ⚠️ 升级前需扫描所有
//go:generate脚本及 CI 中的protoc-gen-go版本一致性
4.4 Go 1.21+ //go:embed 或 //go:build 注释干扰 struct tag 解析的边缘 case(理论+构建约束下反射行为变异)
当 //go:embed 或 //go:build 注释紧邻结构体定义(无空行分隔)时,Go 1.21+ 的 go/types 包在构建期解析 AST 时可能将注释误关联至后续 struct 字段,导致 reflect.StructTag 解析出非预期的 tag 内容。
构建约束引发的反射行为漂移
//go:embed config.json
type Config struct {
Port int `json:"port"`
}
⚠️ 此处 //go:embed 被 go/types 错误绑定到 Config 类型节点,但某些构建模式(如 -toolexec 或自定义 loader)会将其“泄漏”进字段 tag 映射,使 reflect.TypeOf(Config{}).Field(0).Tag.Get("json") 返回空字符串而非 "port"。
关键差异对比
| 场景 | Go 1.20 行为 | Go 1.21+ 行为 | 触发条件 |
|---|---|---|---|
紧邻 //go:embed + struct |
tag 解析正常 | tag 被清空或污染 | 无空行分隔、启用 -gcflags="-l" |
//go:build 在 struct 前 |
忽略注释 | 可能触发类型跳过,导致 reflect 获取零值 |
条件不满足时 struct 不参与编译 |
防御性实践
- 始终在 directive 与 type 定义间插入空行
- 使用
go vet -tags检测潜在 tag 冲突 - 在 CI 中启用
GOOS=linux GOARCH=amd64 go build -a -gcflags="-l"复现构建路径变异
第五章:防御性编程与标签安全最佳实践总结
标签注入的典型攻击路径还原
某电商后台管理系统曾因未过滤用户提交的 product_name 字段,导致 <script>alert(document.cookie)</script> 被直接渲染进商品列表页。攻击者通过构造含 <img src=x onerror="fetch('/api/admin?token='+document.cookie)"> 的恶意商品名,在管理员查看页面时窃取会话令牌。该漏洞本质是 HTML 标签未转义 + 事件属性未剥离 + 上下文感知缺失三重失效。
安全编码检查清单(强制执行项)
- 所有动态插入 DOM 的字符串必须经
DOMPurify.sanitize()处理(v3.0.5+ 支持SAFE_FOR_TEMPLATES: true模式) - 模板引擎中禁止使用
v-html(Vue)、{{{ }}}(Handlebars)等原始输出语法,改用v-text或textContent <input>、<textarea>等表单控件值写入前需调用encodeURIComponent()防止 URL 编码绕过- 后端返回 JSON 中的
description字段若含富文本,必须携带content-security-policy: default-src 'self'; script-src 'none'响应头
实战防护代码片段对比
// ❌ 危险写法(直接 innerHTML)
document.getElementById('userBio').innerHTML = userData.bio;
// ✅ 安全写法(上下文感知渲染)
const container = document.getElementById('userBio');
container.textContent = ''; // 清空旧内容
const safeText = document.createTextNode(userData.bio);
container.appendChild(safeText);
// ✅ 富文本场景(使用 DOMPurify)
const cleanHTML = DOMPurify.sanitize(userData.richBio, {
ALLOWED_TAGS: ['b', 'i', 'u', 'p', 'br'],
ALLOWED_ATTR: ['class']
});
container.innerHTML = cleanHTML;
关键配置项验证表
| 组件类型 | 必须启用的安全机制 | 检测命令示例 | 违规示例 |
|---|---|---|---|
| React 应用 | react-dom v18+ 自动转义 |
grep -r "dangerouslySetInnerHTML" ./src/ |
存在 dangerouslySetInnerHTML 使用 |
| Vue 3 项目 | @vue/compiler-sfc 模板校验 |
npm run build && grep -i "v-html" dist/ |
构建产物含未转义 HTML 标签 |
| Spring Boot API | Content-Security-Policy |
curl -I https://api.example.com/user | grep "Content-Security-Policy" |
响应头缺失 CSP 策略 |
流程图:标签安全防护决策树
flowchart TD
A[接收到用户输入] --> B{是否为纯文本显示?}
B -->|是| C[使用 textContent 或 innerText]
B -->|否| D{是否需保留部分 HTML?}
D -->|是| E[调用 DOMPurify.sanitize\(\) 并限定白名单]
D -->|否| F[拒绝渲染,返回 400 错误]
C --> G[完成安全输出]
E --> G
F --> H[记录审计日志并告警]
生产环境监控指标
- 每日
DOMPurify.removed事件触发次数 > 50 次时自动触发安全团队告警 - Chrome DevTools 控制台中
Refused to execute inline script报错率突增 300% 触发 CSP 策略复审 - OWASP ZAP 扫描报告中
XSS风险等级从 High 降至 None 的修复闭环率需 ≥95%
紧急响应案例:某 SaaS 平台 XSS 漏洞热修复
2023年Q4,客户反馈仪表盘图表标题栏可执行任意 JS。排查发现 Chart.js 的 options.plugins.title.text 直接拼接了未过滤的用户输入。热修复方案:
- 在
title.text渲染前增加String.prototype.escapeHtml()扩展方法(正则替换<>&'") - 临时禁用所有
title.text的 HTML 解析能力(设置options.plugins.title.display = false) - 发布补丁版本后 4 小时内完成全量灰度发布,验证
alert(1)输入被转义为<script>alert(1)</script>
安全测试自动化脚本片段
# 检测前端资源是否包含危险模式
find ./public -name "*.js" -exec grep -l "innerHTML\|outerHTML\|document\.write" {} \;
# 验证 CSP 头是否生效
curl -sI https://app.example.com/ | grep -i "content-security-policy" | grep -q "script-src.*'self'" && echo "CSP OK" || echo "CSP MISSING" 