mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-07-13 21:41:12 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c1cc98fd3 | |||
| 3008ffad34 | |||
| 25a41bf62b | |||
| b62bb2d37a | |||
| 791adbb54d | |||
| 4cd4d67bda | |||
| 009ae30b47 |
+4
-2
@@ -4,6 +4,10 @@
|
||||
|
||||
# The base URL where the app is hosted.
|
||||
TINYAUTH_APPURL=
|
||||
# Path to config file.
|
||||
TINYAUTH_CONFIGFILE=
|
||||
# Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment.
|
||||
TINYAUTH_LABELPROVIDER="auto"
|
||||
|
||||
# database config
|
||||
|
||||
@@ -233,8 +237,6 @@ TINYAUTH_TAILSCALE_APITOKENFILE=
|
||||
TINYAUTH_TAILSCALE_TAILNET=
|
||||
# Cache duration for Tailscale device and user lists in seconds.
|
||||
TINYAUTH_TAILSCALE_CACHEDURATION=300
|
||||
# Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment.
|
||||
TINYAUTH_LABELPROVIDER="auto"
|
||||
|
||||
# log config
|
||||
|
||||
|
||||
+5
-11
@@ -1,12 +1,6 @@
|
||||
"base_path": "."
|
||||
"base_url": "https://api.crowdin.com"
|
||||
|
||||
"preserve_hierarchy": true
|
||||
|
||||
base_path: .
|
||||
base_url: 'https://api.crowdin.com'
|
||||
preserve_hierarchy: 1
|
||||
files:
|
||||
[
|
||||
{
|
||||
"source": "/frontend/src/lib/i18n/locales/en.json",
|
||||
"translation": "/frontend/src/lib/i18n/locales/%locale%.json",
|
||||
},
|
||||
]
|
||||
- source: /frontend/src/lib/i18n/locales/en.json
|
||||
translation: /frontend/src/lib/i18n/locales/%locale%.json
|
||||
|
||||
@@ -23,16 +23,16 @@
|
||||
"axios": "^1.18.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.3.4",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"lucide-react": "^1.23.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^8.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
Generated
+18
-18
@@ -42,8 +42,8 @@ importers:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
i18next:
|
||||
specifier: ^26.3.4
|
||||
version: 26.3.4(typescript@6.0.3)
|
||||
specifier: ^26.3.6
|
||||
version: 26.3.6(typescript@6.0.3)
|
||||
i18next-browser-languagedetector:
|
||||
specifier: ^8.2.1
|
||||
version: 8.2.1
|
||||
@@ -51,8 +51,8 @@ importers:
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
lucide-react:
|
||||
specifier: ^1.23.0
|
||||
version: 1.23.0(react@19.2.7)
|
||||
specifier: ^1.24.0
|
||||
version: 1.24.0(react@19.2.7)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -69,8 +69,8 @@ importers:
|
||||
specifier: ^7.81.0
|
||||
version: 7.81.0(react@19.2.7)
|
||||
react-i18next:
|
||||
specifier: ^17.0.8
|
||||
version: 17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
|
||||
specifier: ^17.0.9
|
||||
version: 17.0.9(i18next@26.3.6(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@19.2.17)(react@19.2.7)
|
||||
@@ -1772,10 +1772,10 @@ packages:
|
||||
i18next-resources-to-backend@1.2.1:
|
||||
resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==}
|
||||
|
||||
i18next@26.3.4:
|
||||
resolution: {integrity: sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==}
|
||||
i18next@26.3.6:
|
||||
resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==}
|
||||
peerDependencies:
|
||||
typescript: ^5 || ^6
|
||||
typescript: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
@@ -1957,8 +1957,8 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lucide-react@1.23.0:
|
||||
resolution: {integrity: sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==}
|
||||
lucide-react@1.24.0:
|
||||
resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
@@ -2173,14 +2173,14 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
|
||||
react-i18next@17.0.8:
|
||||
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
|
||||
react-i18next@17.0.9:
|
||||
resolution: {integrity: sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==}
|
||||
peerDependencies:
|
||||
i18next: '>= 26.2.0'
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
typescript: ^5 || ^6
|
||||
typescript: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
@@ -4220,7 +4220,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
|
||||
i18next@26.3.4(typescript@6.0.3):
|
||||
i18next@26.3.6(typescript@6.0.3):
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
@@ -4347,7 +4347,7 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lucide-react@1.23.0(react@19.2.7):
|
||||
lucide-react@1.24.0(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
|
||||
@@ -4736,11 +4736,11 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
|
||||
react-i18next@17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3):
|
||||
react-i18next@17.0.9(i18next@26.3.6(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 26.3.4(typescript@6.0.3)
|
||||
i18next: 26.3.6(typescript@6.0.3)
|
||||
react: 19.2.7
|
||||
use-sync-external-store: 1.6.0(react@19.2.7)
|
||||
optionalDependencies:
|
||||
|
||||
+4
-1
@@ -46,11 +46,14 @@ func generateExampleEnv() {
|
||||
func buildEnvEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]EnvEntry) {
|
||||
desc := child.Tag.Get("description")
|
||||
tag := child.Tag.Get("yaml")
|
||||
gen := child.Tag.Get("gen")
|
||||
|
||||
if tag == "-" {
|
||||
if tag == "-" && gen != "include" {
|
||||
return
|
||||
}
|
||||
|
||||
tag = strings.TrimSuffix(tag, ",omitempty")
|
||||
|
||||
value := childValue.Interface()
|
||||
|
||||
entry := EnvEntry{
|
||||
|
||||
+63
-19
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -13,9 +12,13 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
)
|
||||
|
||||
type ConfigOptions struct {
|
||||
Env string
|
||||
Flag string
|
||||
YAML string
|
||||
}
|
||||
type MarkdownEntry struct {
|
||||
Env string
|
||||
Flag string
|
||||
Options ConfigOptions
|
||||
Description string
|
||||
Default any
|
||||
}
|
||||
@@ -47,16 +50,27 @@ func generateMarkdown() {
|
||||
func buildMdEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]MarkdownEntry) {
|
||||
desc := child.Tag.Get("description")
|
||||
tag := child.Tag.Get("yaml")
|
||||
gen := child.Tag.Get("gen")
|
||||
|
||||
if tag == "-" {
|
||||
if tag == "-" && gen != "include" {
|
||||
return
|
||||
}
|
||||
|
||||
tag = strings.TrimSuffix(tag, ",omitempty")
|
||||
|
||||
value := childValue.Interface()
|
||||
|
||||
configOptions := ConfigOptions{
|
||||
Env: strings.ToUpper(strings.ReplaceAll(parentPath, ".", "_")) + strings.ToUpper(child.Name),
|
||||
Flag: fmt.Sprintf("--%s%s", strings.TrimPrefix(parentPath, "tinyauth."), strings.ToLower(child.Name)),
|
||||
}
|
||||
|
||||
if tag != "-" && tag != "" {
|
||||
configOptions.YAML = strings.TrimPrefix(parentPath, "tinyauth.") + tag
|
||||
}
|
||||
|
||||
entry := MarkdownEntry{
|
||||
Env: strings.ToUpper(strings.ReplaceAll(parentPath, ".", "_")) + strings.ToUpper(child.Name),
|
||||
Flag: fmt.Sprintf("--%s%s", strings.TrimPrefix(parentPath, "tinyauth."), strings.ToLower(child.Name)),
|
||||
Options: configOptions,
|
||||
Description: desc,
|
||||
}
|
||||
|
||||
@@ -83,11 +97,14 @@ func buildMdMapEntry(child reflect.StructField, parentPath string, entries *[]Ma
|
||||
}
|
||||
|
||||
tag := child.Tag.Get("yaml")
|
||||
gen := child.Tag.Get("gen")
|
||||
|
||||
if tag == "-" {
|
||||
if tag == "-" && gen != "include" {
|
||||
return
|
||||
}
|
||||
|
||||
tag = strings.TrimSuffix(tag, ",omitempty")
|
||||
|
||||
mapPath := parentPath + tag + ".[name]."
|
||||
valueType := fieldType.Elem()
|
||||
|
||||
@@ -102,27 +119,54 @@ func buildMdChildPath(parent string, child string) string {
|
||||
}
|
||||
|
||||
func compileMd(entries []MarkdownEntry) []byte {
|
||||
buffer := bytes.Buffer{}
|
||||
buffer := strings.Builder{}
|
||||
|
||||
buffer.WriteString("<!--- This file is automatically generated by gen/gen_md.go. Do not edit manually. --->\n\n")
|
||||
buffer.WriteString("# Tinyauth configuration reference\n\n")
|
||||
buffer.WriteString("| Environment | Flag | Description | Default |\n")
|
||||
buffer.WriteString("| - | - | - | - |\n")
|
||||
buffer.WriteString("{/* This part is automatically generated by gen/gen_md.go in the main repository. DO NOT EDIT. */}\n\n")
|
||||
buffer.WriteString("import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n")
|
||||
buffer.WriteString("<Tabs>\n")
|
||||
|
||||
renderTabItem(&buffer, entries, "Environment")
|
||||
renderTabItem(&buffer, entries, "Flags")
|
||||
renderTabItem(&buffer, entries, "YAML")
|
||||
|
||||
buffer.WriteString("</Tabs>\n")
|
||||
|
||||
return []byte(buffer.String())
|
||||
}
|
||||
|
||||
func renderTabItem(buffer *strings.Builder, entries []MarkdownEntry, section string) {
|
||||
buffer.WriteString(fmt.Sprintf(" <TabItem label=\"%s\">\n", section))
|
||||
buffer.WriteString(" ### main\n\n")
|
||||
buffer.WriteString(" | Option | Description | Default |\n")
|
||||
buffer.WriteString(" | - | - | - |\n")
|
||||
|
||||
configType := strings.ToLower(section)
|
||||
previousSection := ""
|
||||
|
||||
for _, entry := range entries {
|
||||
if strings.Count(entry.Env, "_") > 1 {
|
||||
section := strings.Split(strings.TrimPrefix(entry.Env, "TINYAUTH_"), "_")[0]
|
||||
section := strings.Split(entry.Options.YAML, ".")[0]
|
||||
var option string
|
||||
switch configType {
|
||||
case "yaml":
|
||||
option = entry.Options.YAML
|
||||
case "flags":
|
||||
option = entry.Options.Flag
|
||||
case "environment":
|
||||
option = entry.Options.Env
|
||||
}
|
||||
if option == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Count(entry.Options.YAML, ".") >= 1 {
|
||||
if section != previousSection {
|
||||
buffer.WriteString("\n## " + strings.ToLower(section) + "\n\n")
|
||||
buffer.WriteString("| Environment | Flag | Description | Default |\n")
|
||||
buffer.WriteString("| - | - | - | - |\n")
|
||||
buffer.WriteString("\n ### " + strings.ToLower(section) + "\n\n")
|
||||
buffer.WriteString(" | Option | Description | Default |\n")
|
||||
buffer.WriteString(" | - | - | - |\n")
|
||||
previousSection = section
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&buffer, "| `%s` | `%s` | %s | %s |\n", entry.Env, entry.Flag, entry.Description, entry.Default)
|
||||
fmt.Fprintf(buffer, " | `%s` | %s | %s |\n", option, entry.Description, entry.Default)
|
||||
}
|
||||
|
||||
return buffer.Bytes()
|
||||
buffer.WriteString(" </TabItem>\n")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package controller
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/pkg/validators"
|
||||
"go.uber.org/dig"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -311,54 +311,46 @@ func (controller *OAuthController) getCookieDomain() string {
|
||||
}
|
||||
|
||||
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
|
||||
u, err := url.Parse(redirectURI)
|
||||
v := validators.NewDomainValidator(validators.DomainValidatorOptions{
|
||||
WithScheme: true,
|
||||
WithPort: true,
|
||||
})
|
||||
|
||||
_, err := v.SafeHostname(controller.runtime.AppURL)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to parse redirect URI")
|
||||
controller.log.App.Error().Err(err).Msg("App URL is invalid, cannot validate redirect URI")
|
||||
return false
|
||||
}
|
||||
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
controller.log.App.Warn().Msg("Redirect URI has invalid scheme or host")
|
||||
return false
|
||||
}
|
||||
err = v.Validate(redirectURI, controller.runtime.AppURL)
|
||||
|
||||
au, err := url.Parse(controller.runtime.AppURL)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to parse app URL")
|
||||
return false
|
||||
}
|
||||
|
||||
if u.Scheme != au.Scheme {
|
||||
controller.log.App.Warn().Msg("Redirect URI scheme does not match app URL scheme")
|
||||
return false
|
||||
}
|
||||
|
||||
getEffectivePort := func(u *url.URL) string {
|
||||
if u.Port() != "" {
|
||||
return u.Port()
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
|
||||
if getEffectivePort(u) != getEffectivePort(au) {
|
||||
controller.log.App.Warn().Msg("Redirect URI port does not match app URL port")
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.EqualFold(u.Hostname(), au.Hostname()) {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
controller.log.App.Debug().Err(err).Msg("Failed to validate redirect URI")
|
||||
|
||||
if strings.HasPrefix(err.Error(), "expected port") ||
|
||||
strings.HasPrefix(err.Error(), "expected scheme") ||
|
||||
err.Error() == "input url is invalid" {
|
||||
return false
|
||||
}
|
||||
|
||||
if !controller.config.Auth.SubdomainsEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.HasSuffix(strings.ToLower(u.Hostname()), "."+strings.ToLower(controller.runtime.CookieDomain)) {
|
||||
v = validators.NewDomainValidator(validators.DomainValidatorOptions{})
|
||||
|
||||
hostname, err := v.SafeHostname(redirectURI)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get safe hostname from redirect URI")
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.HasSuffix(hostname, "."+strings.ToLower(controller.runtime.CookieDomain)) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
func TestOAuthControllerIsRedirectSafe(t *testing.T) {
|
||||
func TestOAuthController_isRedirectSafe(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
|
||||
@@ -103,6 +103,8 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
|
||||
|
||||
type Config struct {
|
||||
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl,omitempty"`
|
||||
ConfigFile string `description:"Path to config file." yaml:"-" gen:"include"`
|
||||
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,omitempty"`
|
||||
Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"`
|
||||
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"`
|
||||
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"`
|
||||
@@ -115,9 +117,7 @@ type Config struct {
|
||||
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"`
|
||||
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"`
|
||||
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,omitempty"`
|
||||
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,omitempty"`
|
||||
Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"`
|
||||
ConfigFile string `description:"Path to config file." yaml:"-"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/pkg/validators"
|
||||
"go.uber.org/dig"
|
||||
)
|
||||
|
||||
@@ -35,27 +36,39 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
|
||||
}
|
||||
}
|
||||
|
||||
func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App {
|
||||
func (service *AccessControlsService) lookupStaticACLs(domain string) (*model.App, error) {
|
||||
var nameMatch *model.App
|
||||
|
||||
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
|
||||
|
||||
// First try to find a matching app by domain, then fallback to matching by app name (subdomain)
|
||||
for app, config := range service.config.Apps {
|
||||
if config.Config.Domain == domain {
|
||||
service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
|
||||
return &config
|
||||
err := v.Validate(config.Config.Domain, domain)
|
||||
// Maybe not the best way to check if a match succeeded, but expected is only
|
||||
// used on match errors and not parsing errors
|
||||
if err != nil && !strings.HasPrefix(err.Error(), "expected") {
|
||||
return nil, err
|
||||
}
|
||||
if strings.SplitN(domain, ".", 2)[0] == app {
|
||||
if err == nil {
|
||||
service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
|
||||
return &config, nil
|
||||
}
|
||||
if strings.HasPrefix(domain, app+".") {
|
||||
service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
|
||||
nameMatch = &config
|
||||
}
|
||||
}
|
||||
|
||||
return nameMatch
|
||||
return nameMatch, nil
|
||||
}
|
||||
|
||||
func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
|
||||
// First check in the static config
|
||||
app := service.lookupStaticACLs(domain)
|
||||
app, err := service.lookupStaticACLs(domain)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if app != nil {
|
||||
service.log.App.Debug().Msg("Using static ACLs for app")
|
||||
|
||||
@@ -92,7 +92,8 @@ func TestLookupStaticACLs(t *testing.T) {
|
||||
Config: &model.Config{Apps: tt.apps},
|
||||
LabelProvider: nil,
|
||||
})
|
||||
got := svc.lookupStaticACLs(tt.domain)
|
||||
got, err := svc.lookupStaticACLs(tt.domain)
|
||||
require.NoError(t, err)
|
||||
if tt.expectNil {
|
||||
assert.Nil(t, got)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Package validators provides validators for various types of data.
|
||||
//
|
||||
// Domain validator is a simple utility that ensures two domains are exact
|
||||
// matches while ensuring that techniques used to bypass such checks do
|
||||
// not impact the validation.
|
||||
|
||||
package validators
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
// DomainValidatorOptions is a set of options for DomainValidator.
|
||||
type DomainValidatorOptions struct {
|
||||
// Ensure domains have the same scheme.
|
||||
WithScheme bool
|
||||
// Ensure domains have the same port.
|
||||
WithPort bool
|
||||
// Specify a list of allowed schemes IF WithScheme is set to true.
|
||||
// Leave empty to allow any scheme.
|
||||
AllowedSchemes []string
|
||||
}
|
||||
|
||||
// DomainValidator is a simple utility that ensures two domains are exact
|
||||
// matches while ensuring that techniques used to bypass such checks do
|
||||
// not impact the validation.
|
||||
type DomainValidator struct {
|
||||
opts DomainValidatorOptions
|
||||
}
|
||||
|
||||
// NewDomainValidator creates a new DomainValidator.
|
||||
func NewDomainValidator(opts DomainValidatorOptions) *DomainValidator {
|
||||
return &DomainValidator{
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *DomainValidator) getURL(i string) (*url.URL, error) {
|
||||
u, err := url.Parse(i)
|
||||
|
||||
if !v.opts.WithScheme && (err != nil || u.Host == "") {
|
||||
u, err = url.Parse("tinyauth://" + i)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse input url: %w", err)
|
||||
}
|
||||
|
||||
if u.Host == "" {
|
||||
return nil, fmt.Errorf("input url is invalid")
|
||||
}
|
||||
|
||||
if v.opts.WithPort && !v.opts.WithScheme && u.Port() == "" {
|
||||
return nil, fmt.Errorf("port validation is enabled but port is missing in input url and schemes are not enabled")
|
||||
}
|
||||
|
||||
if v.opts.WithScheme {
|
||||
// Empty scheme means that we parsed the url with the tinyauth:// placeholder
|
||||
if u.Scheme == "tinyauth" {
|
||||
return nil, fmt.Errorf("input url is missing scheme")
|
||||
}
|
||||
if len(v.opts.AllowedSchemes) > 0 && !slices.Contains(v.opts.AllowedSchemes, u.Scheme) {
|
||||
return nil, fmt.Errorf("scheme %s not allowed", u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (v *DomainValidator) getEffectivePort(u *url.URL) string {
|
||||
if u.Port() != "" {
|
||||
return u.Port()
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
|
||||
func (v *DomainValidator) formatHostname(hostname string) (string, error) {
|
||||
hostname = strings.ToLower(hostname)
|
||||
hostname = strings.TrimSuffix(hostname, ".")
|
||||
if net.ParseIP(hostname) != nil {
|
||||
return "", fmt.Errorf("ip addresses are not supported")
|
||||
}
|
||||
hostname, err := idna.Lookup.ToASCII(hostname)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to convert hostname to ascii: %w", err)
|
||||
}
|
||||
return hostname, nil
|
||||
}
|
||||
|
||||
// Validate ensures that two domains are exact matches with the
|
||||
// options defined in the DomainValidatorOptions. It ensures that the
|
||||
// inputs are proper URLs and contain a host. It lowercases the hostnames
|
||||
// and removes the trailing dot. Finally, it checks that the hostnames are
|
||||
// equal unless WithScheme or WithPort is set to true where it also
|
||||
// validates the scheme and port respectively.
|
||||
func (v *DomainValidator) Validate(expected, actual string) error {
|
||||
eu, err := v.getURL(expected)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
au, err := v.getURL(actual)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if v.opts.WithScheme {
|
||||
if eu.Scheme != au.Scheme {
|
||||
return fmt.Errorf("expected scheme %s, got %s", eu.Scheme, au.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
if v.opts.WithPort {
|
||||
if v.getEffectivePort(eu) != v.getEffectivePort(au) {
|
||||
return fmt.Errorf("expected port %s, got %s", v.getEffectivePort(eu), v.getEffectivePort(au))
|
||||
}
|
||||
}
|
||||
|
||||
euf, err := v.formatHostname(eu.Hostname())
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
auf, err := v.formatHostname(au.Hostname())
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if euf != auf {
|
||||
return fmt.Errorf("expected hostname %s, got %s", euf, auf)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SafeHostname uses the internal validation for domains that Validator uses
|
||||
// to parse a hostname. It ensures the input URL is a valid URL, that a host
|
||||
// is present and that the hostname is lowercased and without a trailing dot.
|
||||
func (v *DomainValidator) SafeHostname(input string) (string, error) {
|
||||
u, err := v.getURL(input)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return v.formatHostname(u.Hostname())
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDomainValidator_SafeHostname(t *testing.T) {
|
||||
type testCase struct {
|
||||
description string
|
||||
options DomainValidatorOptions
|
||||
input string
|
||||
expected string
|
||||
errorFunc func(t *testing.T, e error)
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
description: "Empty url fails",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "input url is invalid")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Invalid url fails",
|
||||
input: "foo:foo",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "failed to parse input url")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Domain without scheme should parse if scheme is disabled",
|
||||
input: "example.com",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
description: "Domain without scheme should not parse if scheme is enabled",
|
||||
options: DomainValidatorOptions{WithScheme: true},
|
||||
input: "example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "input url is invalid")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Domain with scheme and disallowed scheme should fail",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
|
||||
input: "foo://example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "foo not allowed")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Domain with scheme and allowed scheme should pass",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
|
||||
input: "https://example.com",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
description: "Domain should get lowercased",
|
||||
input: "EXAMPLE.COM",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
description: "DNS dot should be removed",
|
||||
input: "example.com.",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
description: "IPv4 address should fail",
|
||||
input: "127.0.0.1",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "ip addresses are not supported")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "IPv6 address should fail",
|
||||
input: "[::1]",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "ip addresses are not supported")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Domains with unicode characters should be allowed",
|
||||
input: "bücher.example.com",
|
||||
expected: "xn--bcher-kva.example.com",
|
||||
},
|
||||
{
|
||||
description: "Invalid IDNA domain should fail",
|
||||
input: "ab--cd.example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "invalid label")
|
||||
},
|
||||
},
|
||||
{
|
||||
// Placeholder should not be used by users and is reserved for the validator.
|
||||
// Using it is like not using any scheme for the validator, and thus it will fail
|
||||
// with schemes enabled.
|
||||
description: "Placeholder scheme supplied directly should fail",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
|
||||
input: "tinyauth://example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "input url is missing scheme")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.description, func(t *testing.T) {
|
||||
v := NewDomainValidator(test.options)
|
||||
res, err := v.SafeHostname(test.input)
|
||||
if test.errorFunc != nil {
|
||||
test.errorFunc(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.expected, res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainValidator_Validate(t *testing.T) {
|
||||
type testCase struct {
|
||||
description string
|
||||
options DomainValidatorOptions
|
||||
expected string
|
||||
actual string
|
||||
errorFunc func(t *testing.T, e error)
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
description: "Invalid expected domain fails checks",
|
||||
expected: "foo:foo",
|
||||
actual: "bar.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "failed to parse input url")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Invalid check domain fails checks",
|
||||
expected: "example.com",
|
||||
actual: "foo:foo",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "failed to parse input url")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Valid domains with non-matching schemes should fail",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}},
|
||||
expected: "https://example.com",
|
||||
actual: "http://example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "expected scheme https, got http")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Valid domains with matching schemes should pass",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}},
|
||||
expected: "https://example.com",
|
||||
actual: "https://example.com",
|
||||
},
|
||||
{
|
||||
description: "Port validation without ports and schemes disabled should fail",
|
||||
options: DomainValidatorOptions{WithPort: true},
|
||||
expected: "example.com",
|
||||
actual: "example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "port validation is enabled but port is missing in input url and schemes are not enabled")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Port validation with no port and http should pass",
|
||||
options: DomainValidatorOptions{WithPort: true, WithScheme: true, AllowedSchemes: []string{"http"}},
|
||||
expected: "http://example.com",
|
||||
actual: "http://example.com",
|
||||
},
|
||||
{
|
||||
description: "Port validation with no port and https should pass",
|
||||
options: DomainValidatorOptions{WithPort: true, WithScheme: true, AllowedSchemes: []string{"https"}},
|
||||
expected: "https://example.com",
|
||||
actual: "https://example.com",
|
||||
},
|
||||
{
|
||||
description: "Port validation with port and no scheme should pass with same port",
|
||||
options: DomainValidatorOptions{WithPort: true},
|
||||
expected: "example.com:8080",
|
||||
actual: "example.com:8080",
|
||||
},
|
||||
{
|
||||
description: "Port validation with port and no scheme should fail with different port",
|
||||
options: DomainValidatorOptions{WithPort: true},
|
||||
expected: "example.com:8080",
|
||||
actual: "example.com:8081",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "expected port 8080, got 8081")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Failure to format expected domain should fail",
|
||||
expected: "ab--cd.example.com",
|
||||
actual: "example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "idna: invalid label")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Failure to format check domain should fail",
|
||||
expected: "example.com",
|
||||
actual: "ab--cd.example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "idna: invalid label")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Valid domains with matching schemes and ports should pass",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}, WithPort: true},
|
||||
expected: "https://example.com:8080",
|
||||
actual: "https://example.com:8080",
|
||||
},
|
||||
{
|
||||
description: "Valid domains with matching schemes should pass",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}},
|
||||
expected: "https://example.com",
|
||||
actual: "https://example.com",
|
||||
},
|
||||
{
|
||||
description: "Valid domains with matching ports should pass",
|
||||
options: DomainValidatorOptions{WithPort: true},
|
||||
expected: "example.com:8080",
|
||||
actual: "example.com:8080",
|
||||
},
|
||||
{
|
||||
description: "Valid domains without ports or schemes should pass",
|
||||
actual: "example.com",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
description: "Unicode valid domains should pass",
|
||||
expected: "xn--bcher-kva.example.com",
|
||||
actual: "bücher.example.com",
|
||||
},
|
||||
{
|
||||
description: "Unicode valid domains should pass (reverse)",
|
||||
expected: "bücher.example.com",
|
||||
actual: "xn--bcher-kva.example.com",
|
||||
},
|
||||
{
|
||||
description: "Non matching hostnames should fail",
|
||||
expected: "example.com",
|
||||
actual: "foo.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.description, func(t *testing.T) {
|
||||
v := NewDomainValidator(test.options)
|
||||
err := v.Validate(test.expected, test.actual)
|
||||
if test.errorFunc != nil {
|
||||
test.errorFunc(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user