Review
CVE: CVE-2026-35037
Name: Unauthenticated SSRF in GetWebsiteTitle allows access to internal services and cloud metadata
URL: https://github.com/advisories/GHSA-cqgf-f4x7-g6wc
Severity:
The Fix: https://github.com/lin-snow/Ech0/commit/4ca56fea5ba4cd477bd1c6134a0435014635b5d8
Project Description:
An open-source, self-hosted lightweight publishing platform for personal idea sharing.
The Bug
As stated by the security disclosure, the endpoint has no authentication, and no middleware is present either to ensure authentication:
appRouterGroup.PublicRouterGroup.GET("/website/title", h.CommonHandler.GetWebsiteTitle())
The handler doesn’t validate user input. No URL or host scheme validation is enforced via the DTO either:
func (commonHandler *CommonHandler) GetWebsiteTitle() gin.HandlerFunc {
return res.Execute(func(ctx *gin.Context) res.Response {
var dto commonModel.GetWebsiteTitleDto
if err := ctx.ShouldBindQuery(&dto); err != nil { ... }
title, err := commonHandler.commonService.GetWebsiteTitle(dto.WebSiteURL)
...
})
}
An unrestricted outbound request is then executed:
client := &http.Client{
Timeout: clientTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
req, err := http.NewRequest(method, url, nil)
...
resp, err := client.Do(req)
The Fix
#1 - Ensure the Endpoint is Authenticated
---:
appRouterGroup.PublicRouterGroup.GET("/website/title", h.CommonHandler.GetWebsiteTitle())
+++:
appRouterGroup.AuthRouterGroup.GET("/website/title", h.CommonHandler.GetWebsiteTitle())
#2 - Validate URLs & IPs
+++:
var blockedCIDRs = mustParseCIDRs(
[]string{
"0.0.0.0/8",
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
[TRUNCATED]
}
[TRUNCATED]
func isBlockedHostname(hostname string) bool {
hostname = strings.ToLower(strings.TrimSpace(hostname))
return hostname == "localhost" ||
strings.HasSuffix(hostname, ".localhost") ||
hostname == "host.docker.internal" ||
hostname == "gateway.docker.internal"
}
[TRUNCATED]
func secureDialContext(timeout time.Duration) func(context.Context, string, string) (net.Conn, error) {
dialer := &net.Dialer{Timeout: timeout}
return func(ctx context.Context, network, address string) (net.Conn, error) {
conn, err := dialer.DialContext(ctx, network, address)
if err != nil {
return nil, err
}
tcpAddr, ok := conn.RemoteAddr().(*net.TCPAddr)
if !ok || tcpAddr == nil || isPrivateOrReservedIP(tcpAddr.IP) {
_ = conn.Close()
return nil, errors.New("连接目标地址不被允许")
}
return conn, nil
}
}
[TRUNCATED]
#3 - Validate It With Tests
+++:
func TestValidatePublicHTTPURL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rawURL string
wantErr bool
}{
{
name: "allow_public_https_domain",
rawURL: "https://example.com",
wantErr: false,
},
{
name: "allow_public_http_ip",
rawURL: "http://93.184.216.34",
wantErr: false,
},
{
name: "reject_non_http_scheme",
rawURL: "ftp://example.com",
wantErr: true,
},
{
name: "reject_loopback_ipv4",
rawURL: "http://127.0.0.1",
wantErr: true,
},
{
name: "reject_loopback_ipv6",
rawURL: "http://[::1]",
wantErr: true,
},
[TRUNCATED]
Lessons Learned
- Always be aware of what endpoints are accessible to what groups. Ensure that some forms of tests or validation enforces a chosen state for endpoints, especially if they’re going to be unauthenticated.
- Never trust user input, and always double-check user inputs multiple ways before performing sensitive operations like requests via any protocol/scheme (especially HTTP/HTTPS).
Outputting .md Report to PDF via Pandoc
Command Example:
pandoc index.md --lua-filter=keep-together.lua -V geometry:margin=1in -o post.pdf --pdf-engine=xelatex -V CJKmainfont="Arial Unicode MS" --pdf-engine-opt=-shell-escape
Claude-Created LUA:
-- keep-together.lua
-- Pandoc Lua filter: heading pagination, list item grouping, minted code blocks
--
-- Requirements:
-- pip3 install pygments
-- sudo tlmgr install needspace minted fvextra xcolor
--
-- Usage:
-- pandoc post.md \
-- --lua-filter=keep-together.lua \
-- --pdf-engine=xelatex \
-- -V geometry:margin=1in \
-- -V CJKmainfont="PingFang SC" \
-- --shell-escape \
-- -o post.pdf
--
-- Note: --shell-escape is required for minted to call Pygments.
------------------------------------------------------------------------
-- Inject LaTeX preamble packages
------------------------------------------------------------------------
function Meta(meta)
local preamble = [[
\usepackage{needspace}
\usepackage{xcolor}
\usepackage{minted}
% minted style: friendly theme, grey background, line numbers
\setminted{
bgcolor=gray!10,
frame=single,
framesep=4pt,
rulecolor=gray,
numbers=left,
numbersep=8pt,
fontsize=\small,
breaklines=true,
breakanywhere=false,
tabsize=2,
style=friendly,
}
]]
local existing = meta['header-includes']
local new_block = pandoc.RawBlock('latex', preamble)
if existing == nil then
meta['header-includes'] = pandoc.MetaBlocks({new_block})
elseif existing.t == 'MetaBlocks' then
table.insert(existing, new_block)
meta['header-includes'] = existing
elseif existing.t == 'MetaList' then
table.insert(existing, pandoc.MetaBlocks({new_block}))
meta['header-includes'] = existing
else
meta['header-includes'] = pandoc.MetaList({existing, pandoc.MetaBlocks({new_block})})
end
return meta
end
------------------------------------------------------------------------
-- Rule 1: Headings stay with following content via \needspace
------------------------------------------------------------------------
function Header(el)
local lines = "6"
if el.level == 3 then lines = "8" end
if el.level == 2 then lines = "10" end
return {
pandoc.RawBlock('latex', '\\needspace{' .. lines .. '\\baselineskip}'),
el,
pandoc.RawBlock('latex', '\\nopagebreak[4]'),
}
end
------------------------------------------------------------------------
-- Rule 2: Each ordered list item stays together on one page
------------------------------------------------------------------------
function OrderedList(el)
local new_items = {}
for _, item in ipairs(el.content) do
local wrapped = {}
table.insert(wrapped, pandoc.RawBlock('latex', '\\begin{minipage}{\\linewidth}'))
for _, block in ipairs(item) do
table.insert(wrapped, block)
end
table.insert(wrapped, pandoc.RawBlock('latex', '\\end{minipage}\\vspace{0.5em}'))
table.insert(new_items, wrapped)
end
return pandoc.OrderedList(new_items, el.start, el.style, el.delimiter)
end
------------------------------------------------------------------------
-- Rule 3: Code blocks → minted with syntax highlighting
--
-- Key fix: build the entire block as ONE raw string so there is no
-- extra newline inserted between \begin{minted}{lang} and the first
-- line of code. A trailing newline before \end{minted} is intentional
-- and required by minted.
------------------------------------------------------------------------
function CodeBlock(el)
-- Determine language; fall back to 'text' so minted never errors
local lang = "text"
if el.classes and el.classes[1] and el.classes[1] ~= "" then
lang = el.classes[1]
end
-- Build the entire block as a single string — no inter-block gaps
local block = '\\begin{minted}{' .. lang .. '}\n'
.. el.text .. '\n'
.. '\\end{minted}'
return pandoc.RawBlock('latex', block)
end
------------------------------------------------------------------------
-- Rule 4: Preserve intentional blank lines / line breaks inside
-- regular paragraphs. Pandoc collapses soft line breaks by default.
-- This converts LineBreak inlines into explicit LaTeX newlines.
------------------------------------------------------------------------
function LineBreak()
return pandoc.RawInline('latex', '\\\\\n')
end
This is a collection of my cybersecurity notes & projects.
I graduated from Dakota State University with a MS in Cyber Defense & BS in Cyber Operations. Since then I've worked as a Malware Analyst with the U.S. Army Cyber Command, and am now a Web Application Security Consultant.
I'm a big fan of open security standards for applications and workflow automation when it comes to security testing. The easier it is to identify and replicate, the more secure everyone's apps can be! My other writings and projects are scattered across the web, but can be found in the links page.
Contact me: