-
Notifications
You must be signed in to change notification settings - Fork 29
feat(sdk): fill an empty thumbnail from the target's own og:image #295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
c8b5aec
01c6125
4e4c960
ab684bd
8339348
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,19 +44,24 @@ type ExposeConfig struct { | |
| RelayURLs []string | ||
| Discovery bool | ||
|
|
||
| Identity types.Identity | ||
| IdentityPath string | ||
| IdentityJSON string | ||
| TargetAddr string | ||
| UDPAddr string | ||
| UDPEnabled bool | ||
| TCPEnabled bool | ||
| ECH bool | ||
| MultiHop []string | ||
| MultiHopDepth int | ||
| BanMITM bool | ||
| MaxActiveRelays int | ||
| Metadata types.LeaseMetadata | ||
| Identity types.Identity | ||
| IdentityPath string | ||
| IdentityJSON string | ||
| TargetAddr string | ||
| UDPAddr string | ||
| UDPEnabled bool | ||
| TCPEnabled bool | ||
| ECH bool | ||
| MultiHop []string | ||
| MultiHopDepth int | ||
| BanMITM bool | ||
| MaxActiveRelays int | ||
| Metadata types.LeaseMetadata | ||
| // ThumbnailFromTarget fills an empty Metadata.Thumbnail with the image the | ||
| // target application advertises, so a card does not have to be described | ||
| // twice. Off by default: it is one request to the target, and a caller that | ||
| // did not ask for it should not make it. | ||
| ThumbnailFromTarget bool | ||
| X402PayTo string | ||
| X402Testnet bool | ||
| X402Network string | ||
|
|
@@ -162,6 +167,8 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) { | |
| runtimeCfg.UDPAddr = udpAddr | ||
| runtimeCfg.MultiHop = append([]string(nil), multiHop...) | ||
| runtimeCfg.Metadata = cfg.Metadata.Copy() | ||
| runtimeCfg.Metadata.Thumbnail = resolveMetadataThumbnail( | ||
| ctx, cfg, targetAddr, listenerIdentity.Name, explicitRelayURLs) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Use the effective relay URL list for relative thumbnails.
Pass the effective public relay URL list instead of 🤖 Prompt for AI Agents |
||
| runtimeCfg.X402PayTo = x402PayTo | ||
| runtimeCfg.X402Testnet = cfg.X402Testnet | ||
| runtimeCfg.X402Network = strings.ToLower(strings.TrimSpace(cfg.X402Network)) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,226 @@ | ||
| package sdk | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "net/url" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/rs/zerolog/log" | ||
| "golang.org/x/net/html" | ||
|
|
||
| "github.com/gosuda/portal-tunnel/v2/utils" | ||
| ) | ||
|
|
||
| // Reading the thumbnail here rather than on the relay is deliberate. The relay | ||
| // would have to fetch a page it does not control, follow its redirects, and | ||
| // proxy back whatever came out — an SSRF surface and a cache, added for a card | ||
| // image. This client already talks to the target as its whole purpose, and the | ||
| // target belongs to whoever runs it, so asking it what image it advertises adds | ||
| // no reachability that the tunnel does not already have. | ||
|
|
||
| const ( | ||
| // Short on purpose: this runs before the tunnel is announced, and a card | ||
| // image must not hold startup open while a target that is not listening yet | ||
| // times out. | ||
| thumbnailFetchTimeout = 3 * time.Second | ||
| thumbnailHTMLReadLimit = 512 << 10 | ||
| ) | ||
|
|
||
| // resolveMetadataThumbnail returns the thumbnail the lease should carry. | ||
| // | ||
| // An explicit value always wins, and without the opt-in the target is never | ||
| // contacted. Both halves of that are the contract with whoever runs this: they | ||
| // asked for a specific image, or they asked for none of this at all. | ||
| func resolveMetadataThumbnail(ctx context.Context, cfg ExposeConfig, targetAddr, name string, relayURLs []string) string { | ||
| declared := cfg.Metadata.Thumbnail | ||
| if strings.TrimSpace(declared) != "" || !cfg.ThumbnailFromTarget { | ||
| return declared | ||
| } | ||
| return discoverThumbnailForExposure(ctx, targetAddr, name, relayURLs) | ||
| } | ||
|
|
||
| // discoverThumbnailForExposure resolves the hostname this exposure will answer | ||
| // at, then asks the target what image it advertises. It never fails: what it | ||
| // found, or why it found nothing, goes to the log so the chosen value is | ||
| // visible rather than guessed at. | ||
| func discoverThumbnailForExposure(ctx context.Context, targetAddr, name string, relayURLs []string) string { | ||
| publicHostname := "" | ||
| if len(relayURLs) > 0 { | ||
| // The first relay anchors a relative reference. A lease answers at one | ||
| // hostname per relay while metadata carries a single value, and an app | ||
| // that declares an absolute URL — which Open Graph asks for — is | ||
| // unaffected either way. | ||
| if host, err := utils.LeaseHostname(name, utils.PortalRootHost(relayURLs[0])); err == nil { | ||
| publicHostname = host | ||
| } | ||
| } | ||
|
|
||
| thumbnail, err := DiscoverThumbnail(ctx, targetAddr, publicHostname) | ||
| switch { | ||
| case err != nil: | ||
| log.Info().Err(err).Str("target", targetAddr). | ||
| Msg("could not read a thumbnail from the target; pass --thumbnail to set one") | ||
| return "" | ||
| case thumbnail == "": | ||
| log.Info().Str("target", targetAddr). | ||
| Msg("target advertises no og:image, twitter:image or icon; leaving the thumbnail empty") | ||
| return "" | ||
| } | ||
| log.Info().Str("thumbnail", thumbnail).Str("target", targetAddr). | ||
| Msg("using the image the target advertises as the lease thumbnail") | ||
| return thumbnail | ||
| } | ||
|
|
||
| // DiscoverThumbnail asks the target application which image represents it and | ||
| // returns that as an absolute URL, or an empty string when it advertises none. | ||
| // | ||
| // publicHostname is the lease hostname this exposure will be reachable at. It | ||
| // is only consulted for a relative reference: the Open Graph protocol calls for | ||
| // an absolute URL, but declaring "/og.png" is common, and the file is served | ||
| // through the tunnel like every other path. | ||
| // | ||
| // Callers should treat any error as "no thumbnail". A card image is not worth | ||
| // failing a tunnel over. | ||
| func DiscoverThumbnail(ctx context.Context, targetAddr, publicHostname string) (string, error) { | ||
| targetAddr = strings.TrimSpace(targetAddr) | ||
| if targetAddr == "" { | ||
| return "", fmt.Errorf("no target address") | ||
| } | ||
|
|
||
| pageURL := &url.URL{Scheme: "http", Host: targetAddr, Path: "/"} | ||
| refs, err := declaredImageRefs(ctx, pageURL) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Find callers that can bypass sdk.Expose target normalization.
rg -n -C 3 --glob '*.go' '\bDiscoverThumbnail\s*\('Repository: gosuda/portal-tunnel Length of output: 158 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- candidate files ---'
git ls-files | grep -E '(^|/)(thumbnail|expose|utils).*\.go$|sdk/.*\.go$' | head -200
printf '%s\n' '--- DiscoverThumbnail declarations and calls ---'
rg -n -C 5 --glob '*.go' 'DiscoverThumbnail|func[[:space:]]+NormalizeLoopbackTarget|NormalizeLoopbackTarget|declaredImageRefs' . || true
printf '%s\n' '--- relevant source slices ---'
for f in sdk/thumbnail.go sdk/expose.go; do
if test -f "$f"; then
echo "### $f"
nl -ba "$f" | sed -n '1,230p'
fi
doneRepository: gosuda/portal-tunnel Length of output: 13176 🏁 Script executed: #!/bin/bash
set -euo pipefail
for f in sdk/thumbnail.go sdk/expose.go utils/cmd.go sdk/thumbnail_test.go; do
if test -f "$f"; then
echo "### $f"
sed -n '1,240p' "$f"
fi
done
printf '%s\n' '--- HTTP client and transport definitions ---'
rg -n -C 8 --glob '*.go' 'http\.Client|http\.DefaultClient|Do\(req\)|CheckRedirect|Transport|declaredImageRefs' sdk utilsRepository: gosuda/portal-tunnel Length of output: 50376 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '### utils.NormalizeLoopbackTarget'
sed -n '330,410p' utils/cmd.go
echo '### HTTP redirect option and transport defaults'
sed -n '95,125p' utils/http.go
sed -n '1,35p' utils/http.go
echo '### all direct callers'
rg -n --glob '*.go' 'DiscoverThumbnail\s*\(' . || true
echo '### target normalization tests'
rg -n -C 5 --glob '*_test.go' 'NormalizeLoopbackTarget|localhost|loopback' utils sdk cmdRepository: gosuda/portal-tunnel Length of output: 10279 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '### NormalizeTargetAddr and related helpers'
rg -n -C 12 --glob '*.go' 'func[[:space:]]+NormalizeTargetAddr|NormalizeTargetAddr\(' utils sdk cmd
echo '### thumbnail tests after the visible section'
sed -n '200,420p' sdk/thumbnail_test.go
echo '### read-only source invariant check'
python3 - <<'PY'
from pathlib import Path
thumbnail = Path("sdk/thumbnail.go").read_text()
expose = Path("sdk/expose.go").read_text()
http = Path("utils/http.go").read_text()
checks = {
"DiscoverThumbnail is exported": "func DiscoverThumbnail(" in thumbnail,
"DiscoverThumbnail constructs URL from targetAddr": '&url.URL{Scheme: "http", Host: targetAddr' in thumbnail,
"DiscoverThumbnail does not normalize target": "NormalizeLoopbackTarget(targetAddr)" not in thumbnail,
"Expose normalizes cfg.TargetAddr": "NormalizeLoopbackTarget(cfg.TargetAddr)" in expose,
"thumbnail client has no redirect option": "WithHTTPCheckRedirect" not in thumbnail,
"NewHTTPClient default leaves CheckRedirect unset": "client := &http.Client{Transport: defaultTransport()}" in http,
}
for name, ok in checks.items():
print(f"{name}: {'yes' if ok else 'no'}")
PYRepository: gosuda/portal-tunnel Length of output: 8976 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '435,500p' utils/utils.go
echo '### normalization tests and callers'
rg -n -C 8 --glob '*_test.go' 'NormalizeLoopbackTarget|NormalizeTargetAddr' utils sdk cmd
echo '### Go tool availability'
command -v go || true
go version 2>/dev/null || trueRepository: gosuda/portal-tunnel Length of output: 3298 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re
cmd = Path("utils/cmd.go").read_text()
utils = Path("utils/utils.go").read_text()
thumbnail = Path("sdk/thumbnail.go").read_text()
assert "return NormalizeTargetAddr(raw)" in cmd
assert "return \"\", fmt.Errorf(\"invalid target address %q\", raw)" in utils
assert "net.ParseIP(raw)" in utils
assert "WithHTTPCheckRedirect" not in thumbnail
print("NormalizeLoopbackTarget delegates to NormalizeTargetAddr: yes")
print("NormalizeTargetAddr has no loopback check: yes")
print("DiscoverThumbnail has no redirect policy: yes")
PY
echo '### Go HTTP client redirect semantics'
go doc net/http.Client | sed -n '/type Client struct/,/func (c \*Client)/p' | head -80Repository: gosuda/portal-tunnel Length of output: 3965 Enforce a real loopback-only target policy before fetching.
🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| if err != nil { | ||
| return "", err | ||
| } | ||
|
|
||
| base := (*url.URL)(nil) | ||
| if publicHostname = utils.NormalizeHostname(publicHostname); publicHostname != "" { | ||
| base = &url.URL{Scheme: "https", Host: publicHostname, Path: "/"} | ||
| } | ||
|
|
||
| for _, ref := range refs { | ||
| resolved := resolveThumbnailRef(ref, base) | ||
| if resolved != "" { | ||
| return resolved, nil | ||
| } | ||
| } | ||
| return "", nil | ||
| } | ||
|
|
||
| // resolveThumbnailRef turns one declared reference into an absolute http(s) | ||
| // URL, or returns empty when it cannot be used. | ||
| // | ||
| // metadata.thumbnail is an absolute URL by contract — the dashboard's own | ||
| // tunnel command builder rejects anything else — so a value that cannot be made | ||
| // absolute is dropped rather than stored and left to render as a broken image. | ||
| func resolveThumbnailRef(ref string, base *url.URL) string { | ||
| ref = strings.TrimSpace(ref) | ||
| if ref == "" { | ||
| return "" | ||
| } | ||
| parsed, err := url.Parse(ref) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
|
|
||
| if parsed.IsAbs() { | ||
| // data: and javascript: parse as absolute too, so the scheme is checked | ||
| // rather than assumed. | ||
| if parsed.Scheme != "http" && parsed.Scheme != "https" { | ||
| return "" | ||
| } | ||
| return parsed.String() | ||
| } | ||
|
|
||
| // Relative, and nothing to resolve it against: the target's own address is | ||
| // loopback and would be useless to a browser elsewhere. | ||
| if base == nil { | ||
| return "" | ||
| } | ||
| return base.ResolveReference(parsed).String() | ||
| } | ||
|
|
||
| func declaredImageRefs(ctx context.Context, pageURL *url.URL) ([]string, error) { | ||
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL.String(), nil) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| req.Header.Set("Accept", "text/html") | ||
|
|
||
| client := utils.NewHTTPClient(utils.WithHTTPTimeout(thumbnailFetchTimeout)) | ||
| resp, err := client.Do(req) | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the shared client factory and all redirect policies.
rg -n -C 5 --glob '*.go' 'func NewHTTPClient|CheckRedirect|ErrUseLastResponse|WithHTTPTimeout'Repository: gosuda/portal-tunnel Length of output: 158 🏁 Script executed: set -euo pipefail
printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(thumbnail\.go|.*http.*\.go)$|(^|/)utils(/|$)' || true
printf '%s\n' '--- thumbnail implementation ---'
sed -n '1,230p' sdk/thumbnail.go
printf '%s\n' '--- HTTP client factory and redirect policies ---'
rg -n -C 8 --glob '*.go' 'NewHTTPClient|CheckRedirect|ErrUseLastResponse|WithHTTPTimeout|http\.Client' . || trueRepository: gosuda/portal-tunnel Length of output: 37798 🏁 Script executed: set -euo pipefail
printf '%s\n' '--- DiscoverThumbnail call sites and public exposure path ---'
rg -n -C 10 --glob '*.go' 'DiscoverThumbnail|func Expose|targetAddr|Normalize.*Target|loopback|localhost|127\.0\.0\.1|\[::1\]' sdk types cmd utils || true
printf '%s\n' '--- relevant SDK tests and documentation ---'
rg -n -C 8 --glob '*_test.go' --glob '*.md' 'ThumbnailFromTarget|thumbnail|DiscoverThumbnail|target address|targetAddr' sdk README.md docs cmd || true
printf '%s\n' '--- standalone default redirect probe ---'
if command -v go >/dev/null 2>&1; then
cat >/tmp/http_redirect_probe.go <<'EOF'
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "target reached")
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusFound)
}))
defer redirector.Close()
resp, err := (&http.Client{}).Get(redirector.URL)
if err != nil {
fmt.Printf("error=%v\n", err)
return
}
defer resp.Body.Close()
fmt.Printf("status=%s final_url=%s\n", resp.Status, resp.Request.URL)
}
EOF
go run /tmp/http_redirect_probe.go
else
echo 'go is unavailable'
fiRepository: gosuda/portal-tunnel Length of output: 50376 🏁 Script executed: set -euo pipefail
printf '%s\n' '--- target normalization ---'
sed -n '430,475p' utils/utils.go
sed -n '338,362p' utils/cmd.go
printf '%s\n' '--- focused public API references ---'
rg -n --glob '*.go' 'DiscoverThumbnail\(' .
rg -n -C 4 --glob '*_test.go' 'NormalizeTargetAddr|NormalizeLoopbackTarget' utils sdk | head -n 240
printf '%s\n' '--- standalone default redirect probe ---'
if command -v go >/dev/null 2>&1; then
cat >/tmp/http_redirect_probe.go <<'EOF'
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "target reached")
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusFound)
}))
defer redirector.Close()
resp, err := (&http.Client{}).Get(redirector.URL)
if err != nil {
fmt.Printf("error=%v\n", err)
return
}
defer resp.Body.Close()
fmt.Printf("status=%s final_url=%s\n", resp.Status, resp.Request.URL)
}
EOF
go run /tmp/http_redirect_probe.go
else
echo 'go is unavailable'
fiRepository: gosuda/portal-tunnel Length of output: 7650 🏁 Script executed: set -euo pipefail
if command -v go >/dev/null 2>&1; then
cat >/tmp/http_redirect_probe.go <<'EOF'
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "target reached")
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusFound)
}))
defer redirector.Close()
resp, err := (&http.Client{}).Get(redirector.URL)
if err != nil {
fmt.Printf("error=%v\n", err)
return
}
defer resp.Body.Close()
fmt.Printf("status=%s final_url=%s\n", resp.Status, resp.Request.URL)
}
EOF
CGO_ENABLED=0 go run /tmp/http_redirect_probe.go
else
echo 'go is unavailable'
fiRepository: gosuda/portal-tunnel Length of output: 248 🏁 Script executed: set -euo pipefail
goroot="$(go env GOROOT)"
printf 'GOROOT=%s\n' "$goroot"
rg -n -C 8 'CheckRedirect|defaultCheckRedirect|redirectBehavior' "$goroot/src/net/http/client.go" | head -n 160Repository: gosuda/portal-tunnel Length of output: 6870 Disable redirects during thumbnail discovery.
🧰 Tools🪛 GitHub Check: CodeQL[failure] 155-155: Uncontrolled data used in network request 🤖 Prompt for AI AgentsSource: MCP tools |
||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| defer func() { _ = resp.Body.Close() }() | ||
| if resp.StatusCode != http.StatusOK { | ||
| return nil, fmt.Errorf("target returned %d", resp.StatusCode) | ||
| } | ||
|
|
||
| return parseDeclaredImageRefs(io.LimitReader(resp.Body, thumbnailHTMLReadLimit)), nil | ||
| } | ||
|
|
||
| // parseDeclaredImageRefs collects image references in preference order: | ||
| // og:image, then twitter:image, then apple-touch-icon. | ||
| func parseDeclaredImageRefs(r io.Reader) []string { | ||
| var og, twitter, icons []string | ||
|
|
||
| tokenizer := html.NewTokenizer(r) | ||
| for { | ||
| switch tokenizer.Next() { | ||
| case html.ErrorToken: | ||
| return append(append(og, twitter...), icons...) | ||
| case html.StartTagToken, html.SelfClosingTagToken: | ||
| token := tokenizer.Token() | ||
| switch token.Data { | ||
| case "meta": | ||
| var property, name, content string | ||
| for _, attr := range token.Attr { | ||
| switch strings.ToLower(attr.Key) { | ||
| case "property": | ||
| property = strings.ToLower(attr.Val) | ||
| case "name": | ||
| name = strings.ToLower(attr.Val) | ||
| case "content": | ||
| content = attr.Val | ||
| } | ||
| } | ||
| if content == "" { | ||
| continue | ||
| } | ||
| switch { | ||
| case property == "og:image", property == "og:image:secure_url": | ||
| og = append(og, content) | ||
| case name == "twitter:image", name == "twitter:image:src": | ||
| twitter = append(twitter, content) | ||
| } | ||
| case "link": | ||
| var rel, href string | ||
| for _, attr := range token.Attr { | ||
| switch strings.ToLower(attr.Key) { | ||
| case "rel": | ||
| rel = strings.ToLower(attr.Val) | ||
| case "href": | ||
| href = attr.Val | ||
| } | ||
| } | ||
| if href == "" { | ||
| continue | ||
| } | ||
| for _, value := range strings.Fields(rel) { | ||
| if value == "apple-touch-icon" || value == "apple-touch-icon-precomposed" || value == "icon" { | ||
| icons = append(icons, href) | ||
| break | ||
| } | ||
| } | ||
| case "body": | ||
| // Everything worth reading lives in the head. | ||
| return append(append(og, twitter...), icons...) | ||
| } | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.