mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
207 lines
7.5 KiB
Go
207 lines
7.5 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package httplib
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/git/modules/setting"
|
|
"github.com/hanzoai/git/modules/util"
|
|
)
|
|
|
|
type RequestContextKeyStruct struct{}
|
|
|
|
var RequestContextKey = RequestContextKeyStruct{}
|
|
|
|
func urlIsRelative(s string, u *url.URL) bool {
|
|
// Unfortunately, browsers consider a redirect Location with preceding "//", "\\", "/\" and "\/" as meaning redirect to "http(s)://REST_OF_PATH"
|
|
// Therefore we should ignore these redirect locations to prevent open redirects
|
|
if len(s) > 1 && (s[0] == '/' || s[0] == '\\') && (s[1] == '/' || s[1] == '\\') {
|
|
return false
|
|
}
|
|
if u == nil {
|
|
return false // invalid URL
|
|
}
|
|
if u.Scheme != "" || u.Host != "" {
|
|
return false // absolute URL with scheme or host
|
|
}
|
|
// Now, the URL is likely a relative URL
|
|
// HINT: GOLANG-HTTP-REDIRECT-BUG: Golang security vulnerability: "http.Redirect" calls "path.Clean" and changes the meaning of a path
|
|
// For example, `/a/../\b` will be changed to `/\b`, then it hits the first checked pattern and becomes an open redirect to "{current-scheme}://b"
|
|
// For a valid relative URL, its "path" shouldn't contain `\` because such char must be escaped.
|
|
// So if the "path" contains `\`, it is not a valid relative URL, then we can prevent open redirect.
|
|
return !strings.Contains(u.Path, "\\")
|
|
}
|
|
|
|
// IsRelativeURL detects if a URL is relative (no scheme or host)
|
|
func IsRelativeURL(s string) bool {
|
|
u, err := url.Parse(s)
|
|
return err == nil && urlIsRelative(s, u)
|
|
}
|
|
|
|
func getRequestScheme(req *http.Request) string {
|
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
|
|
if proto, ok := parseForwardedProtoValue(req.Header.Get("X-Forwarded-Proto")); ok {
|
|
return proto
|
|
}
|
|
if proto, ok := parseForwardedProtoValue(req.Header.Get("X-Forwarded-Protocol")); ok {
|
|
return proto
|
|
}
|
|
if proto, ok := parseForwardedProtoValue(req.Header.Get("X-Url-Scheme")); ok {
|
|
return proto
|
|
}
|
|
if s := req.Header.Get("Front-End-Https"); s != "" {
|
|
return util.Iif(s == "on", "https", "http")
|
|
}
|
|
if s := req.Header.Get("X-Forwarded-Ssl"); s != "" {
|
|
return util.Iif(s == "on", "https", "http")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseForwardedProtoValue(val string) (string, bool) {
|
|
if val == "http" || val == "https" {
|
|
return val, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// GuessCurrentAppURL tries to guess the current full public URL (with sub-path) by http headers. It always has a '/' suffix, exactly the same as setting.AppURL
|
|
// TODO: should rename it to GuessCurrentPublicURL in the future
|
|
func GuessCurrentAppURL(ctx context.Context) string {
|
|
return GuessCurrentHostURL(ctx) + setting.AppSubURL + "/"
|
|
}
|
|
|
|
// GuessCurrentHostURL tries to guess the current full host URL (no sub-path) by http headers, there is no trailing slash.
|
|
func GuessCurrentHostURL(ctx context.Context) string {
|
|
// "never" means always trust ROOT_URL and skip any request header detection.
|
|
if setting.PublicURLDetection == setting.PublicURLNever {
|
|
return strings.TrimSuffix(setting.AppURL, setting.AppSubURL+"/")
|
|
}
|
|
// Try the best guess to get the current host URL (will be used for public URL) by http headers.
|
|
// At the moment, if site admin doesn't configure the proxy headers correctly, then the forge would guess wrong.
|
|
// There are some cases:
|
|
// 1. The reverse proxy is configured correctly, it passes "X-Forwarded-Proto/Host" headers. Perfect, the forge can handle it correctly.
|
|
// 2. The reverse proxy is not configured correctly, doesn't pass "X-Forwarded-Proto/Host" headers, eg: only one "proxy_pass http://forge:3000" in Nginx.
|
|
// 3. There is no reverse proxy.
|
|
// Without more information, the forge is impossible to distinguish between case 2 and case 3, then case 2 would result in
|
|
// wrong guess like guessed public URL becomes "http://forge:3000/" behind a "https" reverse proxy, which is not accessible by end users.
|
|
// So we introduced "PUBLIC_URL_DETECTION" option, to control the guessing behavior to satisfy different use cases.
|
|
req, ok := ctx.Value(RequestContextKey).(*http.Request)
|
|
if !ok {
|
|
return strings.TrimSuffix(setting.AppURL, setting.AppSubURL+"/")
|
|
}
|
|
reqScheme := getRequestScheme(req)
|
|
if reqScheme == "" {
|
|
// if no reverse proxy header, try to use "Host" header for absolute URL
|
|
if setting.PublicURLDetection == setting.PublicURLAuto && req.Host != "" {
|
|
return util.Iif(req.TLS == nil, "http://", "https://") + req.Host
|
|
}
|
|
// fall back to default AppURL
|
|
return strings.TrimSuffix(setting.AppURL, setting.AppSubURL+"/")
|
|
}
|
|
// X-Forwarded-Host has many problems: non-standard, not well-defined (X-Forwarded-Port or not), conflicts with Host header.
|
|
// So do not use X-Forwarded-Host, just use Host header directly.
|
|
return reqScheme + "://" + req.Host
|
|
}
|
|
|
|
func GuessCurrentHostDomain(ctx context.Context) string {
|
|
_, host, _ := strings.Cut(GuessCurrentHostURL(ctx), "://")
|
|
domain, _, _ := net.SplitHostPort(host)
|
|
return util.IfZero(domain, host)
|
|
}
|
|
|
|
// MakeAbsoluteURL tries to make a link to an absolute public URL:
|
|
// * If link is empty, it returns the current public URL.
|
|
// * If link is absolute, it returns the link.
|
|
// * Otherwise, it returns the current host URL + link, the link itself should have correct sub-path (AppSubURL) if needed.
|
|
func MakeAbsoluteURL(ctx context.Context, link string) string {
|
|
if link == "" {
|
|
return GuessCurrentAppURL(ctx)
|
|
}
|
|
if !IsRelativeURL(link) {
|
|
return link
|
|
}
|
|
return GuessCurrentHostURL(ctx) + "/" + strings.TrimPrefix(link, "/")
|
|
}
|
|
|
|
type urlType int
|
|
|
|
const (
|
|
urlTypeForgeAbsolute urlType = iota + 1 // "http://forge/subpath"
|
|
urlTypeForgePageRelative // "/subpath"
|
|
urlTypeForgeSiteRelative // "?key=val"
|
|
urlTypeUnknown // "http://other"
|
|
)
|
|
|
|
func detectURLRoutePath(ctx context.Context, s string) (routePath string, ut urlType) {
|
|
u, err := url.Parse(s)
|
|
if err != nil {
|
|
return "", urlTypeUnknown
|
|
}
|
|
cleanedPath := ""
|
|
if u.Path != "" {
|
|
cleanedPath = util.PathJoinRelX(u.Path)
|
|
cleanedPath = util.Iif(cleanedPath == ".", "", "/"+cleanedPath)
|
|
}
|
|
if urlIsRelative(s, u) {
|
|
if u.Path == "" {
|
|
return "", urlTypeForgePageRelative
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(cleanedPath+"/"), strings.ToLower(setting.AppSubURL+"/")) {
|
|
return cleanedPath[len(setting.AppSubURL):], urlTypeForgeSiteRelative
|
|
}
|
|
return "", urlTypeUnknown
|
|
}
|
|
u.Path = cleanedPath + "/"
|
|
urlLower := strings.ToLower(u.String())
|
|
if strings.HasPrefix(urlLower, strings.ToLower(setting.AppURL)) {
|
|
return cleanedPath[len(setting.AppSubURL):], urlTypeForgeAbsolute
|
|
}
|
|
guessedCurURL := GuessCurrentAppURL(ctx)
|
|
if strings.HasPrefix(urlLower, strings.ToLower(guessedCurURL)) {
|
|
return cleanedPath[len(setting.AppSubURL):], urlTypeForgeAbsolute
|
|
}
|
|
return "", urlTypeUnknown
|
|
}
|
|
|
|
func IsCurrentGitSiteURL(ctx context.Context, s string) bool {
|
|
_, ut := detectURLRoutePath(ctx, s)
|
|
return ut != urlTypeUnknown
|
|
}
|
|
|
|
type GitSiteURL struct {
|
|
RoutePath string
|
|
OwnerName string
|
|
RepoName string
|
|
RepoSubPath string
|
|
}
|
|
|
|
func ParseGitSiteURL(ctx context.Context, s string) *GitSiteURL {
|
|
routePath, ut := detectURLRoutePath(ctx, s)
|
|
if ut == urlTypeUnknown || ut == urlTypeForgePageRelative {
|
|
return nil
|
|
}
|
|
ret := &GitSiteURL{RoutePath: routePath}
|
|
fields := strings.SplitN(strings.TrimPrefix(ret.RoutePath, "/"), "/", 3)
|
|
|
|
// TODO: now it only does a quick check for some known reserved paths, should do more strict checks in the future
|
|
if fields[0] == "attachments" {
|
|
return ret
|
|
}
|
|
if len(fields) < 2 {
|
|
return ret
|
|
}
|
|
ret.OwnerName = fields[0]
|
|
ret.RepoName = fields[1]
|
|
if len(fields) == 3 {
|
|
ret.RepoSubPath = "/" + fields[2]
|
|
}
|
|
return ret
|
|
}
|