From eab0a6a62677562b17e444d54b73d7499a47074a Mon Sep 17 00:00:00 2001 From: makeworld Date: Thu, 23 Dec 2021 00:23:06 -0500 Subject: [PATCH] Support restricting client certs to subpaths Fixes #115 --- CHANGELOG.md | 1 + amfora.go | 7 +- client/client.go | 103 +++++++++++++++++---- client/url.go | 102 ++++++++++++++++++++ display/util_test.go => client/url_test.go | 4 +- config/default.go | 12 ++- default-config.toml | 12 ++- display/display.go | 7 +- display/handlers.go | 4 +- display/util.go | 80 ---------------- 10 files changed, 219 insertions(+), 113 deletions(-) create mode 100644 client/url.go rename display/util_test.go => client/url_test.go (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d634b..15fe395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - Syntax highlighting for preformatted text blocks with alt text (#252, #263, [wiki page](https://github.com/makeworld-the-better-one/amfora/wiki/Source-Code-Highlighting)) +- [Client certificates](https://github.com/makeworld-the-better-one/amfora/wiki/Client-Certificates) can be restricted to certain paths of a host (#115) ### Changed - Center text automatically, removing `left_margin` from the config (#233) diff --git a/amfora.go b/amfora.go index 20cab0b..6a246de 100644 --- a/amfora.go +++ b/amfora.go @@ -54,7 +54,12 @@ func main() { fmt.Fprintf(os.Stderr, "Config error: %v\n", err) os.Exit(1) } - client.Init() + + err = client.Init() + if err != nil { + fmt.Fprintf(os.Stderr, "Client error: %v\n", err) + os.Exit(1) + } err = subscriptions.Init() if err != nil { diff --git a/client/client.go b/client/client.go index f216fcc..6f9b1df 100644 --- a/client/client.go +++ b/client/client.go @@ -2,9 +2,11 @@ package client import ( + "errors" "io/ioutil" "net" "net/url" + "strings" "sync" "time" @@ -13,40 +15,107 @@ import ( "github.com/spf13/viper" ) +// Simple key for certCache map and others, instead of a full URL +// Only uses the part of the URL relevant to matching certs to a URL +type certMapKey struct { + host string + path string +} + var ( - certCache = make(map[string][][]byte) + // [auth] section of config put into maps + confCerts = make(map[certMapKey]string) + confKeys = make(map[certMapKey]string) + + // Cache the cert and key assigned to different URLs + certCache = make(map[certMapKey][][]byte) certCacheMu = &sync.RWMutex{} fetchClient *gemini.Client ) -func Init() { +func Init() error { fetchClient = &gemini.Client{ ConnectTimeout: 10 * time.Second, // Default is 15 ReadTimeout: time.Duration(viper.GetInt("a-general.page_max_time")) * time.Second, } + + // Populate config maps + + certsViper := viper.Sub("auth.certs") + for _, certURL := range certsViper.AllKeys() { + // Normalize URL so that it can be matched no matter how it was written + // in the config + pu, _ := normalizeURL(FixUserURL(certURL)) + if pu == nil { + return errors.New("[auth.certs]: couldn't normalize URL: " + certURL) + } + confCerts[certMapKey{pu.Host, pu.Path}] = certsViper.GetString(certURL) + } + + keysViper := viper.Sub("auth.keys") + for _, keyURL := range keysViper.AllKeys() { + pu, _ := normalizeURL(FixUserURL(keyURL)) + if pu == nil { + return errors.New("[auth.keys]: couldn't normalize URL: " + keyURL) + } + confKeys[certMapKey{pu.Host, pu.Path}] = keysViper.GetString(keyURL) + } + + return nil } -func clientCert(host string) ([]byte, []byte) { +// getCertPath returns the path of the cert from the config. +// It returns "" if no config value exists. +func getCertPath(host string, path string) string { + for k, v := range confCerts { + if k.host == host && (k.path == path || strings.HasPrefix(path, k.path)) { + // Either exact match to what's in config, or a subpath + return v + } + } + // No matches + return "" +} + +// getKeyPath returns the path of the key from the config. +// It returns "" if no config value exists. +func getKeyPath(host string, path string) string { + for k, v := range confKeys { + if k.host == host && (k.path == path || strings.HasPrefix(path, k.path)) { + // Either exact match to what's in config, or a subpath + return v + } + } + // No matches + return "" +} + +func clientCert(host string, path string) ([]byte, []byte) { + mkey := certMapKey{host, path} + certCacheMu.RLock() - pair, ok := certCache[host] + pair, ok := certCache[mkey] certCacheMu.RUnlock() if ok { return pair[0], pair[1] } + ogCertPath := getCertPath(host, path) // Expand paths starting with ~/ - certPath, err := homedir.Expand(viper.GetString("auth.certs." + host)) + certPath, err := homedir.Expand(ogCertPath) if err != nil { - certPath = viper.GetString("auth.certs." + host) + certPath = ogCertPath } - keyPath, err := homedir.Expand(viper.GetString("auth.keys." + host)) + ogKeyPath := getKeyPath(host, path) + keyPath, err := homedir.Expand(ogKeyPath) if err != nil { - keyPath = viper.GetString("auth.keys." + host) + keyPath = ogKeyPath } + if certPath == "" && keyPath == "" { certCacheMu.Lock() - certCache[host] = [][]byte{nil, nil} + certCache[mkey] = [][]byte{nil, nil} certCacheMu.Unlock() return nil, nil } @@ -54,33 +123,33 @@ func clientCert(host string) ([]byte, []byte) { cert, err := ioutil.ReadFile(certPath) if err != nil { certCacheMu.Lock() - certCache[host] = [][]byte{nil, nil} + certCache[mkey] = [][]byte{nil, nil} certCacheMu.Unlock() return nil, nil } key, err := ioutil.ReadFile(keyPath) if err != nil { certCacheMu.Lock() - certCache[host] = [][]byte{nil, nil} + certCache[mkey] = [][]byte{nil, nil} certCacheMu.Unlock() return nil, nil } certCacheMu.Lock() - certCache[host] = [][]byte{cert, key} + certCache[mkey] = [][]byte{cert, key} certCacheMu.Unlock() return cert, key } -// HasClientCert returns whether or not a client certificate exists for a host. -func HasClientCert(host string) bool { - cert, _ := clientCert(host) +// HasClientCert returns whether or not a client certificate exists for a host and path. +func HasClientCert(host string, path string) bool { + cert, _ := clientCert(host, path) return cert != nil } func fetch(u string, c *gemini.Client) (*gemini.Response, error) { parsed, _ := url.Parse(u) - cert, key := clientCert(parsed.Host) + cert, key := clientCert(parsed.Host, parsed.Path) var res *gemini.Response var err error @@ -109,7 +178,7 @@ func Fetch(u string) (*gemini.Response, error) { func fetchWithProxy(proxyHostname, proxyPort, u string, c *gemini.Client) (*gemini.Response, error) { parsed, _ := url.Parse(u) - cert, key := clientCert(parsed.Host) + cert, key := clientCert(parsed.Host, parsed.Path) var res *gemini.Response var err error diff --git a/client/url.go b/client/url.go new file mode 100644 index 0000000..ac94dd0 --- /dev/null +++ b/client/url.go @@ -0,0 +1,102 @@ +package client + +// Functions that transform and normalize URLs +// Originally used to be in display/util.go +// Moved here for #115, so URLs in the [auth] config section could be normalized + +import ( + "net/url" + "strings" + + "github.com/makeworld-the-better-one/go-gemini" + "golang.org/x/text/unicode/norm" +) + +// See doc for NormalizeURL +func normalizeURL(u string) (*url.URL, string) { + u = norm.NFC.String(u) + + tmp, err := gemini.GetPunycodeURL(u) + if err != nil { + return nil, u + } + u = tmp + parsed, _ := url.Parse(u) + + if parsed.Scheme == "" { + // Always add scheme + parsed.Scheme = "gemini" + } else if parsed.Scheme != "gemini" { + // Not a gemini URL, nothing to do + return nil, u + } + + parsed.User = nil // No passwords in Gemini + parsed.Fragment = "" // No fragments either + if parsed.Port() == "1965" { + // Always remove default port + hostname := parsed.Hostname() + if strings.Contains(hostname, ":") { + parsed.Host = "[" + parsed.Hostname() + "]" + } else { + parsed.Host = parsed.Hostname() + } + } + + // Add slash to the end of a URL with just a domain + // gemini://example.com -> gemini://example.com/ + if parsed.Path == "" { + parsed.Path = "/" + } else { + // Decode and re-encode path + // This removes needless encoding, like that of ASCII chars + // And encodes anything that wasn't but should've been + parsed.RawPath = strings.ReplaceAll(url.PathEscape(parsed.Path), "%2F", "/") + } + + // Do the same to the query string + un, err := gemini.QueryUnescape(parsed.RawQuery) + if err == nil { + parsed.RawQuery = gemini.QueryEscape(un) + } + + return parsed, "" +} + +// NormalizeURL attempts to make URLs that are different strings +// but point to the same place all look the same. +// +// Example: gemini://gus.guru:1965/ and //gus.guru/. +// This function will take both output the same URL each time. +// +// It will also percent-encode invalid characters, and decode chars +// that don't need to be encoded. It will also apply Unicode NFC +// normalization. +// +// The string passed must already be confirmed to be a URL. +// Detection of a search string vs. a URL must happen elsewhere. +// +// It only works with absolute URLs. +func NormalizeURL(u string) string { + pu, s := normalizeURL(u) + if pu != nil { + // Could be normalized, return it + return pu.String() + } + // Return the best URL available up to that point + return s +} + +// FixUserURL will take a user-typed URL and add a gemini scheme to it if +// necessary. It is not the same as normalizeURL, and that func should still +// be used, afterward. +// +// For example "example.com" will become "gemini://example.com", but +// "//example.com" will be left untouched. +func FixUserURL(u string) string { + if !strings.HasPrefix(u, "//") && !strings.HasPrefix(u, "gemini://") && !strings.Contains(u, "://") { + // Assume it's a Gemini URL + u = "gemini://" + u + } + return u +} diff --git a/display/util_test.go b/client/url_test.go similarity index 97% rename from display/util_test.go rename to client/url_test.go index 02e1bca..843d777 100644 --- a/display/util_test.go +++ b/client/url_test.go @@ -1,5 +1,5 @@ //nolint: lll -package display +package client import ( "testing" @@ -36,7 +36,7 @@ var normalizeURLTests = []struct { func TestNormalizeURL(t *testing.T) { for _, tt := range normalizeURLTests { - actual := normalizeURL(tt.u) + actual := NormalizeURL(tt.u) if actual != tt.expected { t.Errorf("normalizeURL(%s): expected %s, actual %s", tt.u, tt.expected, actual) } diff --git a/config/default.go b/config/default.go index 09b5099..08c6fc3 100644 --- a/config/default.go +++ b/config/default.go @@ -99,13 +99,17 @@ underline = true [auth.certs] # Client certificates -# Set domain name equal to path to client cert -# "example.com" = 'mycert.crt' +# Set URL equal to path to client cert file +# +# "example.com" = 'mycert.crt' # Cert is used for all paths on this domain +# "example.com/dir/"= 'mycert.crt' # Cert is used for /dir/ and everything below only +# +# See the comment at the beginning of this file for examples of all valid types of +# URLs, ports and schemes can be used too [auth.keys] # Client certificate keys -# Set domain name equal to path to key for the client cert above -# "example.com" = 'mycert.key' +# Same as [auth.certs] but the path is to the client key file. [keybindings] diff --git a/default-config.toml b/default-config.toml index 7c42685..4feac30 100644 --- a/default-config.toml +++ b/default-config.toml @@ -96,13 +96,17 @@ underline = true [auth.certs] # Client certificates -# Set domain name equal to path to client cert -# "example.com" = 'mycert.crt' +# Set URL equal to path to client cert file +# +# "example.com" = 'mycert.crt' # Cert is used for all paths on this domain +# "example.com/dir/"= 'mycert.crt' # Cert is used for /dir/ and everything below only +# +# See the comment at the beginning of this file for examples of all valid types of +# URLs, ports and schemes can be used too [auth.keys] # Client certificate keys -# Set domain name equal to path to key for the client cert above -# "example.com" = 'mycert.key' +# Same as [auth.certs] but the path is to the client key file. [keybindings] diff --git a/display/display.go b/display/display.go index f05138d..16a5379 100644 --- a/display/display.go +++ b/display/display.go @@ -11,6 +11,7 @@ import ( "code.rocketnine.space/tslocum/cview" "github.com/gdamore/tcell/v2" "github.com/makeworld-the-better-one/amfora/cache" + "github.com/makeworld-the-better-one/amfora/client" "github.com/makeworld-the-better-one/amfora/config" "github.com/makeworld-the-better-one/amfora/renderer" "github.com/makeworld-the-better-one/amfora/structs" @@ -228,12 +229,12 @@ func Init(version, commit, builtBy string) { u := viper.GetString("a-general.search") + "?" + gemini.QueryEscape(query) // Don't use the cached version of the search - cache.RemovePage(normalizeURL(u)) + cache.RemovePage(client.NormalizeURL(u)) URL(u) } else { // Full URL // Don't use cached version for manually entered URL - cache.RemovePage(normalizeURL(fixUserURL(query))) + cache.RemovePage(client.NormalizeURL(client.FixUserURL(query))) URL(query) } return @@ -555,7 +556,7 @@ func URL(u string) { if strings.HasPrefix(u, "about:") { go goURL(t, u) } else { - go goURL(t, fixUserURL(u)) + go goURL(t, client.FixUserURL(u)) } } diff --git a/display/handlers.go b/display/handlers.go index 7d48849..60e9bf1 100644 --- a/display/handlers.go +++ b/display/handlers.go @@ -241,7 +241,7 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { return ret(handleAbout(t, u)) } - u = normalizeURL(u) + u = client.NormalizeURL(u) u = cache.Redirect(u) parsed, err := url.Parse(u) @@ -376,7 +376,7 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { page.TermWidth = termW - if !client.HasClientCert(parsed.Host) { + if !client.HasClientCert(parsed.Host, parsed.Path) { // Don't cache pages with client certs go cache.AddPage(page) } diff --git a/display/util.go b/display/util.go index d789ea9..74f0b81 100644 --- a/display/util.go +++ b/display/util.go @@ -6,9 +6,7 @@ import ( "strings" "code.rocketnine.space/tslocum/cview" - "github.com/makeworld-the-better-one/go-gemini" "github.com/spf13/viper" - "golang.org/x/text/unicode/norm" ) // This file contains funcs that are small, self-contained utilities. @@ -106,81 +104,3 @@ func resolveRelLink(t *tab, prev, next string) (string, error) { } return prevParsed.ResolveReference(nextParsed).String(), nil } - -// normalizeURL attempts to make URLs that are different strings -// but point to the same place all look the same. -// -// Example: gemini://gus.guru:1965/ and //gus.guru/. -// This function will take both output the same URL each time. -// -// It will also percent-encode invalid characters, and decode chars -// that don't need to be encoded. It will also apply Unicode NFC -// normalization. -// -// The string passed must already be confirmed to be a URL. -// Detection of a search string vs. a URL must happen elsewhere. -// -// It only works with absolute URLs. -func normalizeURL(u string) string { - u = norm.NFC.String(u) - - tmp, err := gemini.GetPunycodeURL(u) - if err != nil { - return u - } - u = tmp - parsed, _ := url.Parse(u) - - if parsed.Scheme == "" { - // Always add scheme - parsed.Scheme = "gemini" - } else if parsed.Scheme != "gemini" { - // Not a gemini URL, nothing to do - return u - } - - parsed.User = nil // No passwords in Gemini - parsed.Fragment = "" // No fragments either - if parsed.Port() == "1965" { - // Always remove default port - hostname := parsed.Hostname() - if strings.Contains(hostname, ":") { - parsed.Host = "[" + parsed.Hostname() + "]" - } else { - parsed.Host = parsed.Hostname() - } - } - - // Add slash to the end of a URL with just a domain - // gemini://example.com -> gemini://example.com/ - if parsed.Path == "" { - parsed.Path = "/" - } else { - // Decode and re-encode path - // This removes needless encoding, like that of ASCII chars - // And encodes anything that wasn't but should've been - parsed.RawPath = strings.ReplaceAll(url.PathEscape(parsed.Path), "%2F", "/") - } - - // Do the same to the query string - un, err := gemini.QueryUnescape(parsed.RawQuery) - if err == nil { - parsed.RawQuery = gemini.QueryEscape(un) - } - - return parsed.String() -} - -// fixUserURL will take a user-typed URL and add a gemini scheme to it if -// necessary. It is not the same as normalizeURL, and that func should still -// be used, afterward. -// -// For example "example.com" will become "gemini://example.com", but -// "//example.com" will be left untouched. -func fixUserURL(u string) string { - if !strings.HasPrefix(u, "//") && !strings.HasPrefix(u, "gemini://") && !strings.Contains(u, "://") { - // Assume it's a Gemini URL - u = "gemini://" + u - } - return u -}