Site Can Be Framed (Clickjacking)
Checks that each site forbids being embedded in other sites' frames.
- Why It Matters
- If a page can be framed, an attacker can overlay it invisibly and trick users into clicking buttons (clickjacking), such as 'delete' or 'transfer'.
- How It Detects
- Passes if the CSP frame-ancestors directive restricts framing (a * or scheme-only source fails), or, without frame-ancestors, X-Frame-Options is DENY or SAMEORIGIN (repeated identical values are accepted; conflicting values fail).
- How to Fix
- Send Content-Security-Policy: frame-ancestors 'self' (modern) and X-Frame-Options: SAMEORIGIN (older browsers).
- How to Verify
curl -sI https://<site>/ | grep -Ei 'x-frame-options|frame-ancestors'- CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N- Control Category
- Web Application Security: CIS Controls v8 16; NIST CSF 2.0 PR.PS, PR.DS
- Fix Effort
- Low
- CIS Controls v8 Safeguard
- 16.7
- NIST CSF 2.0 Subcategory
- PR.PS-01
- OWASP WSTG v4.2
- WSTG-CLNT-09
- CWE
- CWE-1021
- OWASP Top 10
- A04:2021
- OWASP ASVS 4.0.3
- 14.4.7
- NIST SP 800-53 Rev. 5
- SC-18
- References
- OWASP Clickjacking Defense Cheat Sheet
MDN Web Docs, X-Frame-Options
Source Code
The complete file that defines this check. Run it with openvapt scan --only WEB-HDR-003, or read it offline with openvapt explain WEB-HDR-003.
internal/checks/web/headers.go · Lines 1-303View on GitHub at This Commit
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "regexp" |
| 7 | "strconv" |
| 8 | "strings" |
| 9 | |
| 10 | "github.com/openvapt/openvapt/internal/check" |
| 11 | "github.com/openvapt/openvapt/internal/sys" |
| 12 | ) |
| 13 | |
| 14 | func init() { |
| 15 | check.Register(&check.Check{ |
| 16 | ID: "WEB-HDR-001", |
| 17 | Title: "Missing HTTP Strict Transport Security (HSTS)", |
| 18 | Domain: check.DomainWeb, |
| 19 | Severity: check.Medium, |
| 20 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:L/A:N", |
| 21 | Summary: "Checks that HTTPS sites tell browsers to always use HTTPS, for at least six months.", |
| 22 | Rationale: "Without HSTS, the first visit or any typed http:// link can be intercepted and downgraded to plain HTTP (SSL stripping) on public Wi-Fi.", |
| 23 | Detection: "Reads the Strict-Transport-Security header from each HTTPS site's home page, including a redirect response, because browsers honour HSTS on any HTTPS response. Plain HTTP responses are not evaluated. Missing, or max-age below 15552000 seconds (180 days), fails.", |
| 24 | Fix: "Apache: Header always set Strict-Transport-Security \"max-age=31536000; includeSubDomains\" . nginx: add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always; . Only add 'preload' once every subdomain supports HTTPS.", |
| 25 | Verify: "curl -sI https://<site>/ | grep -i strict-transport-security", |
| 26 | Refs: []check.Ref{{Title: "RFC 6797, HTTP Strict Transport Security", URL: "https://www.rfc-editor.org/rfc/rfc6797.html"}, refHTTPHeadersCS, refMDN("Strict-Transport-Security")}, |
| 27 | Standards: check.Standards{CWE: []string{"CWE-319", "CWE-523"}, OWASP: []string{"A05:2021"}, ASVS: []string{"14.4.5"}, WSTG: []string{"WSTG-CONF-07"}, NIST: []string{"SC-8"}}, |
| 28 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 29 | return ForEachSite(ctx, s, func(st Site) check.Result { |
| 30 | // Browsers honour HSTS on any HTTPS response, redirects |
| 31 | // included (RFC 6797 section 8.1), so a 3xx is evaluated too. |
| 32 | if !st.HTTPS { |
| 33 | return check.SkipR(st.Name, "Site not served over HTTPS (see WEB-TLS-001)") |
| 34 | } |
| 35 | h := st.Home.Header.Get("Strict-Transport-Security") |
| 36 | if h == "" { |
| 37 | return check.FailR(st.Name, "Strict-Transport-Security header is missing") |
| 38 | } |
| 39 | n, ok := hstsMaxAge(h) |
| 40 | if !ok { |
| 41 | return check.FailR(st.Name, "HSTS max-age is missing, duplicated or invalid", h) |
| 42 | } |
| 43 | if n < 15552000 { |
| 44 | return check.FailR(st.Name, "HSTS max-age is shorter than 180 days", h) |
| 45 | } |
| 46 | return check.PassR(st.Name, "HSTS is set", h) |
| 47 | }) |
| 48 | }, |
| 49 | }) |
| 50 | |
| 51 | check.Register(&check.Check{ |
| 52 | ID: "WEB-HDR-002", |
| 53 | Title: "Missing Content Security Policy", |
| 54 | Domain: check.DomainWeb, |
| 55 | Severity: check.Medium, |
| 56 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", |
| 57 | Summary: "Checks that each site sends a Content-Security-Policy header.", |
| 58 | Rationale: "A CSP limits where scripts may load from, turning many cross-site scripting (XSS) bugs from account takeover into a blocked request.", |
| 59 | Detection: "Reads Content-Security-Policy from each site's home page. Missing fails. A policy containing 'unsafe-inline' in script-src (without a nonce or hash) or a bare * source is reported as weak.", |
| 60 | Fix: "Start with a report-only policy to find what the site loads, then enforce, for example: Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'", |
| 61 | Verify: "curl -sI https://<site>/ | grep -i content-security-policy", |
| 62 | Refs: []check.Ref{{Title: "OWASP Content Security Policy Cheat Sheet", URL: "https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html"}, {Title: "W3C Content Security Policy Level 3", URL: "https://www.w3.org/TR/CSP3/"}}, |
| 63 | Standards: check.Standards{CWE: []string{"CWE-1021", "CWE-79"}, OWASP: []string{"A03:2021", "A05:2021"}, ASVS: []string{"14.4.3"}, WSTG: []string{"WSTG-CONF-12"}, NIST: []string{"SI-10"}}, |
| 64 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 65 | return ForEachSite(ctx, s, func(st Site) check.Result { |
| 66 | if r, skip := st.Redirected(); skip { |
| 67 | return r |
| 68 | } |
| 69 | h := st.Home.Header.Get("Content-Security-Policy") |
| 70 | if h == "" { |
| 71 | return check.FailR(st.Name, "Content-Security-Policy header is missing") |
| 72 | } |
| 73 | if reason := weakCSP(h); reason != "" { |
| 74 | return check.FailR(st.Name, reason, h) |
| 75 | } |
| 76 | return check.PassR(st.Name, "Content-Security-Policy is set", h) |
| 77 | }) |
| 78 | }, |
| 79 | }) |
| 80 | |
| 81 | check.Register(&check.Check{ |
| 82 | ID: "WEB-HDR-003", |
| 83 | Title: "Site Can Be Framed (Clickjacking)", |
| 84 | Domain: check.DomainWeb, |
| 85 | Severity: check.Medium, |
| 86 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N", |
| 87 | Summary: "Checks that each site forbids being embedded in other sites' frames.", |
| 88 | Rationale: "If a page can be framed, an attacker can overlay it invisibly and trick users into clicking buttons (clickjacking), such as 'delete' or 'transfer'.", |
| 89 | Detection: "Passes if the CSP frame-ancestors directive restricts framing (a * or scheme-only source fails), or, without frame-ancestors, X-Frame-Options is DENY or SAMEORIGIN (repeated identical values are accepted; conflicting values fail).", |
| 90 | Fix: "Send Content-Security-Policy: frame-ancestors 'self' (modern) and X-Frame-Options: SAMEORIGIN (older browsers).", |
| 91 | Verify: "curl -sI https://<site>/ | grep -Ei 'x-frame-options|frame-ancestors'", |
| 92 | Refs: []check.Ref{{Title: "OWASP Clickjacking Defense Cheat Sheet", URL: "https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html"}, refMDN("X-Frame-Options")}, |
| 93 | Standards: check.Standards{CWE: []string{"CWE-1021"}, OWASP: []string{"A04:2021"}, ASVS: []string{"14.4.7"}, WSTG: []string{"WSTG-CLNT-09"}, NIST: []string{"SC-18"}}, |
| 94 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 95 | return ForEachSite(ctx, s, func(st Site) check.Result { |
| 96 | if r, skip := st.Redirected(); skip { |
| 97 | return r |
| 98 | } |
| 99 | ok, why := framingRestricted(st.Home.Header) |
| 100 | if ok { |
| 101 | return check.PassR(st.Name, "Framing is restricted", why) |
| 102 | } |
| 103 | return check.FailR(st.Name, why) |
| 104 | }) |
| 105 | }, |
| 106 | }) |
| 107 | |
| 108 | simpleHeader := func(id, title, header, want, summary, rationale, fix string, sev check.Severity, std check.Standards) { |
| 109 | check.Register(&check.Check{ |
| 110 | ID: id, Title: title, Domain: check.DomainWeb, Severity: sev, |
| 111 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N", |
| 112 | Summary: summary, |
| 113 | Rationale: rationale, |
| 114 | Detection: "Reads the " + header + " header from each site's home page and checks that it is present" + map[bool]string{true: " with the value " + want, false: ""}[want != ""] + ".", |
| 115 | Fix: fix, |
| 116 | Verify: "curl -sI https://<site>/ | grep -i " + strings.ToLower(header), |
| 117 | Refs: []check.Ref{refSecureHeaders, refMDN(header)}, |
| 118 | Standards: std, |
| 119 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 120 | return ForEachSite(ctx, s, func(st Site) check.Result { |
| 121 | if r, skip := st.Redirected(); skip { |
| 122 | return r |
| 123 | } |
| 124 | v := st.Home.Header.Get(header) |
| 125 | if v == "" || (want != "" && !strings.EqualFold(strings.TrimSpace(v), want)) { |
| 126 | return check.FailR(st.Name, header+" is missing or not '"+want+"'", v) |
| 127 | } |
| 128 | if header == "Referrer-Policy" && unsafeReferrer(st.Home.Header.Values(header)) { |
| 129 | return check.FailR(st.Name, "Referrer-Policy unsafe-url sends the full URL to every site", v) |
| 130 | } |
| 131 | return check.PassR(st.Name, header+" is set", v) |
| 132 | }) |
| 133 | }, |
| 134 | }) |
| 135 | } |
| 136 | simpleHeader("WEB-HDR-004", "Missing X-Content-Type-Options", "X-Content-Type-Options", "nosniff", |
| 137 | "Checks that browsers are told not to guess (sniff) content types.", |
| 138 | "Without nosniff, a browser may execute an uploaded text or image file as script or HTML, enabling XSS through file uploads.", |
| 139 | "Send X-Content-Type-Options: nosniff on every response (Apache: Header always set X-Content-Type-Options nosniff; nginx: add_header X-Content-Type-Options nosniff always;).", |
| 140 | check.Low, check.Standards{CWE: []string{"CWE-430"}, OWASP: []string{"A05:2021"}, ASVS: []string{"14.4.4"}, NIST: []string{"CM-6"}}) |
| 141 | simpleHeader("WEB-HDR-005", "Missing Referrer-Policy", "Referrer-Policy", "", |
| 142 | "Checks that each site limits what URL information is leaked to other sites in the Referer header.", |
| 143 | "Full URLs can contain session tokens, password-reset links or personal data, which would otherwise be sent to every third-party resource and outbound link.", |
| 144 | "Send Referrer-Policy: strict-origin-when-cross-origin (or no-referrer). Never use unsafe-url.", |
| 145 | check.Low, check.Standards{CWE: []string{"CWE-201"}, OWASP: []string{"A05:2021"}, ASVS: []string{"14.4.6"}, NIST: []string{"CM-6"}}) |
| 146 | simpleHeader("WEB-HDR-006", "Missing Permissions-Policy", "Permissions-Policy", "", |
| 147 | "Checks that each site restricts powerful browser features (camera, microphone, geolocation) it does not use.", |
| 148 | "Restricting features limits what injected or third-party scripts can do in your visitors' browsers.", |
| 149 | "Send, for example, Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=() adjusted to the features the site really uses.", |
| 150 | check.Low, check.Standards{CWE: []string{"CWE-1188"}, OWASP: []string{"A05:2021"}, NIST: []string{"CM-6", "CM-7"}}) |
| 151 | |
| 152 | check.Register(&check.Check{ |
| 153 | ID: "WEB-HDR-007", |
| 154 | Title: "Server Software Version Disclosed", |
| 155 | Domain: check.DomainWeb, |
| 156 | Severity: check.Low, |
| 157 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N", |
| 158 | Summary: "Checks whether response headers reveal exact web server, PHP or framework versions.", |
| 159 | Rationale: "Exact versions let attackers match the site against public exploit lists in seconds. Hiding them does not fix vulnerabilities but removes free reconnaissance.", |
| 160 | Detection: "Looks for a version number in the Server header and for the X-Powered-By, X-AspNet-Version and X-Generator headers.", |
| 161 | Fix: "Apache: ServerTokens Prod and ServerSignature Off . nginx: server_tokens off; . PHP: expose_php = Off in php.ini . Remove X-Powered-By in the application or proxy.", |
| 162 | Verify: "curl -sI https://<site>/ | grep -Ei '^(server|x-powered-by)'", |
| 163 | Refs: []check.Ref{refHTTPHeadersCS, {Title: "Apache HTTP Server, ServerTokens directive", URL: "https://httpd.apache.org/docs/2.4/mod/core.html#servertokens"}}, |
| 164 | Standards: check.Standards{CWE: []string{"CWE-497"}, OWASP: []string{"A05:2021"}, ASVS: []string{"14.3.3"}, WSTG: []string{"WSTG-INFO-02"}, NIST: []string{"CM-7", "SI-11"}}, |
| 165 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 166 | ver := regexp.MustCompile(`\d+\.\d+`) |
| 167 | return ForEachSite(ctx, s, func(st Site) check.Result { |
| 168 | if r, skip := st.Redirected(); skip { |
| 169 | return r |
| 170 | } |
| 171 | var ev []string |
| 172 | if v := st.Home.Header.Get("Server"); ver.MatchString(v) { |
| 173 | ev = append(ev, "Server: "+v) |
| 174 | } |
| 175 | for _, h := range []string{"X-Powered-By", "X-AspNet-Version", "X-AspNetMvc-Version", "X-Generator"} { |
| 176 | if v := st.Home.Header.Get(h); v != "" { |
| 177 | ev = append(ev, h+": "+v) |
| 178 | } |
| 179 | } |
| 180 | if len(ev) > 0 { |
| 181 | return check.FailR(st.Name, "Software versions are disclosed in headers", ev...) |
| 182 | } |
| 183 | return check.PassR(st.Name, "No version details in headers") |
| 184 | }) |
| 185 | }, |
| 186 | }) |
| 187 | } |
| 188 | |
| 189 | func hstsMaxAge(h string) (uint64, bool) { |
| 190 | var value string |
| 191 | for _, part := range strings.Split(h, ";") { |
| 192 | kv := strings.SplitN(strings.TrimSpace(part), "=", 2) |
| 193 | if len(kv) == 2 && strings.EqualFold(strings.TrimSpace(kv[0]), "max-age") { |
| 194 | if value != "" { |
| 195 | return 0, false |
| 196 | } |
| 197 | value = strings.TrimSpace(kv[1]) |
| 198 | } |
| 199 | } |
| 200 | // RFC 6797 allows max-age as a token or a quoted string. |
| 201 | if len(value) >= 2 && strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") { |
| 202 | value = value[1 : len(value)-1] |
| 203 | } |
| 204 | if value == "" { |
| 205 | return 0, false |
| 206 | } |
| 207 | n, err := strconv.ParseUint(value, 10, 64) |
| 208 | return n, err == nil |
| 209 | } |
| 210 | |
| 211 | func weakCSP(h string) string { |
| 212 | dirs := map[string][]string{} |
| 213 | for _, raw := range strings.Split(strings.ToLower(h), ";") { |
| 214 | f := strings.Fields(raw) |
| 215 | if len(f) > 0 { |
| 216 | dirs[f[0]] = f[1:] |
| 217 | } |
| 218 | } |
| 219 | script := dirs["script-src"] |
| 220 | if len(script) == 0 { |
| 221 | script = dirs["default-src"] |
| 222 | } |
| 223 | for _, src := range script { |
| 224 | if src == "*" || src == "data:" || strings.HasPrefix(src, "http:") { |
| 225 | return "CSP permits an unsafe script source" |
| 226 | } |
| 227 | if src == "'unsafe-eval'" { |
| 228 | return "CSP allows unsafe script evaluation" |
| 229 | } |
| 230 | if src == "'unsafe-inline'" && !hasNonceOrHash(script) { |
| 231 | return "CSP allows inline scripts without a nonce or hash" |
| 232 | } |
| 233 | } |
| 234 | if _, ok := dirs["object-src"]; !ok { |
| 235 | return "CSP does not restrict object-src" |
| 236 | } |
| 237 | return "" |
| 238 | } |
| 239 | func hasNonceOrHash(src []string) bool { |
| 240 | for _, s := range src { |
| 241 | if strings.HasPrefix(s, "'nonce-") || strings.HasPrefix(s, "'sha256-") || strings.HasPrefix(s, "'sha384-") || strings.HasPrefix(s, "'sha512-") { |
| 242 | return true |
| 243 | } |
| 244 | } |
| 245 | return false |
| 246 | } |
| 247 | |
| 248 | // framingRestricted evaluates CSP frame-ancestors first (browsers ignore |
| 249 | // X-Frame-Options when it is present), then X-Frame-Options. |
| 250 | func framingRestricted(h http.Header) (bool, string) { |
| 251 | for _, csp := range h.Values("Content-Security-Policy") { |
| 252 | for _, raw := range strings.Split(strings.ToLower(csp), ";") { |
| 253 | f := strings.Fields(raw) |
| 254 | if len(f) == 0 || f[0] != "frame-ancestors" { |
| 255 | continue |
| 256 | } |
| 257 | for _, src := range f[1:] { |
| 258 | if src == "*" || src == "http:" || src == "https:" || src == "*:" { |
| 259 | return false, "CSP frame-ancestors allows any site to frame the page (" + strings.Join(f[1:], " ") + ")" |
| 260 | } |
| 261 | } |
| 262 | if len(f) == 1 { |
| 263 | return false, "CSP frame-ancestors has no source list" |
| 264 | } |
| 265 | return true, "frame-ancestors " + strings.Join(f[1:], " ") |
| 266 | } |
| 267 | } |
| 268 | var vals []string |
| 269 | for _, v := range h.Values("X-Frame-Options") { |
| 270 | for _, p := range strings.Split(v, ",") { |
| 271 | if p = strings.ToUpper(strings.TrimSpace(p)); p != "" { |
| 272 | vals = append(vals, p) |
| 273 | } |
| 274 | } |
| 275 | } |
| 276 | if len(vals) == 0 { |
| 277 | return false, "Neither X-Frame-Options nor CSP frame-ancestors is set" |
| 278 | } |
| 279 | for _, v := range vals[1:] { |
| 280 | if v != vals[0] { |
| 281 | return false, "X-Frame-Options has conflicting values (" + strings.Join(vals, ", ") + "), which browsers ignore" |
| 282 | } |
| 283 | } |
| 284 | if vals[0] == "DENY" || vals[0] == "SAMEORIGIN" { |
| 285 | return true, "X-Frame-Options " + vals[0] |
| 286 | } |
| 287 | return false, "X-Frame-Options value " + vals[0] + " is not DENY or SAMEORIGIN" |
| 288 | } |
| 289 | |
| 290 | // unsafeReferrer reports whether the effective Referrer-Policy (the last |
| 291 | // recognised token, per the W3C Referrer Policy specification) is unsafe-url. |
| 292 | func unsafeReferrer(vals []string) bool { |
| 293 | last := "" |
| 294 | for _, v := range vals { |
| 295 | for _, t := range strings.Split(v, ",") { |
| 296 | switch t = strings.ToLower(strings.TrimSpace(t)); t { |
| 297 | case "no-referrer", "no-referrer-when-downgrade", "same-origin", "origin", "strict-origin", "origin-when-cross-origin", "strict-origin-when-cross-origin", "unsafe-url": |
| 298 | last = t |
| 299 | } |
| 300 | } |
| 301 | } |
| 302 | return last == "unsafe-url" |
| 303 | } |
Also available as plain Markdown for notes, scripts and AI tools. Same facts, same source, no styling.
Cite This Page
If this page helped your work, please cite it. The version and web address are filled in; the access date is today's date in your browser. Every other style, and when credit is required, is on How to Cite OpenVAPT.
OpenVAPT. (2026). WEB-HDR-003 Site Can Be Framed (Clickjacking) (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/WEB-HDR-003.html
In text: (OpenVAPT, 2026). Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.
@misc{openvapt_web_hdr_003,
author = {{OpenVAPT}},
title = {{WEB-HDR-003 Site Can Be Framed (Clickjacking)}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/WEB-HDR-003.html}},
note = {OpenVAPT Check Catalogue, version 0.1.0-alpha.6. Accessed 16 October 2026}
}
Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.
Citing OpenVAPT does not mean OpenVAPT endorses, approves or has reviewed your work.