Compare commits
10 Commits
2fb0801dc6
...
22f19c849b
| Author | SHA1 | Date | |
|---|---|---|---|
| 22f19c849b | |||
| 83fa22871e | |||
| 808356f142 | |||
| 5297e6033a | |||
| 813ff63153 | |||
| c0d325156f | |||
| 370545997f | |||
| 11e9206c21 | |||
| 745a461688 | |||
| 5d0b5f864e |
32
TODO.md
32
TODO.md
@@ -19,11 +19,8 @@ on main.
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
P0: manual test pass of the auth and encrypted URL flows, then commit
|
P0: implement cache size management and eviction so the disk cannot
|
||||||
the checked-off results to TODO.md: visit / and see the login form;
|
fill up
|
||||||
wrong key shows an error; correct signing key shows the generator form;
|
|
||||||
a generated encrypted URL serves the image; an expired URL (short TTL)
|
|
||||||
returns 410; logout redirects back to login
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
@@ -33,6 +30,29 @@ returns 410; logout redirects back to login
|
|||||||
to omitted keys), unknown config keys abort startup, a malformed
|
to omitted keys), unknown config keys abort startup, a malformed
|
||||||
config file aborts instead of being skipped, and `state_dir` is
|
config file aborts instead of being skipped, and `state_dir` is
|
||||||
verified creatable and writable before the listener binds
|
verified creatable and writable before the listener binds
|
||||||
|
- 2026-08-07 manual test pass of the auth and encrypted URL flows
|
||||||
|
against a locally built and running `pixad` (built from `main` at
|
||||||
|
`6573b9d`, port 18099, local throwaway config); all six checks
|
||||||
|
passed, plus all nine tests in `scripts/manual-test.sh` (closes #49):
|
||||||
|
- [x] visit `/` and see the login form: HTTP 200, `Pixa - Login`
|
||||||
|
page with `name="key"` password form
|
||||||
|
- [x] wrong key shows an error: POST `/` with `key=wrong-key`
|
||||||
|
returned HTTP 200 login page containing "Invalid signing key"
|
||||||
|
- [x] correct signing key shows the generator form: POST `/`
|
||||||
|
returned HTTP 303 to `/` with
|
||||||
|
`Set-Cookie: pixa_session=...; HttpOnly; Secure; SameSite=Strict`;
|
||||||
|
GET `/` with that cookie rendered `Pixa - URL Generator` with the
|
||||||
|
`/generate` form and logout link
|
||||||
|
- [x] a generated encrypted URL serves the image: POST `/generate`
|
||||||
|
(ttl=3600) produced a `/v1/e/<token>/img.jpeg` URL that returned
|
||||||
|
HTTP 200, `Content-Type: image/jpeg`, an 800x600 baseline JPEG of
|
||||||
|
61706 bytes
|
||||||
|
- [x] an expired URL (short TTL) returns 410: a ttl=1 URL fetched
|
||||||
|
after 3 s returned HTTP 410 Gone with
|
||||||
|
`{"error":"URL has expired","status":410,...}`
|
||||||
|
- [x] logout redirects back to login: GET `/logout` returned HTTP
|
||||||
|
303 to `/` with `Set-Cookie: pixa_session=; Max-Age=0`;
|
||||||
|
subsequent GET `/` rendered the login form again
|
||||||
- 2026-08-07 fix the two remaining gosec findings (G124 in
|
- 2026-08-07 fix the two remaining gosec findings (G124 in
|
||||||
internal/session): session cookies now always carry
|
internal/session): session cookies now always carry
|
||||||
Secure/HttpOnly/SameSite=Strict on both the set and clear paths;
|
Secure/HttpOnly/SameSite=Strict on both the set and clear paths;
|
||||||
@@ -59,8 +79,6 @@ returns 410; logout redirects back to login
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- P0: implement cache size management and eviction so the disk cannot
|
|
||||||
fill up
|
|
||||||
- P1: implement blocked networks configuration to extend SSRF
|
- P1: implement blocked networks configuration to extend SSRF
|
||||||
protection
|
protection
|
||||||
- P1: rate limit global concurrent upstream fetches to prevent
|
- P1: rate limit global concurrent upstream fetches to prevent
|
||||||
|
|||||||
@@ -113,9 +113,19 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
"upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
|
"upstream_connections_per_host", DefaultUpstreamConnectionsPerHost),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build DBURL from StateDir if not explicitly set
|
// Build DBURL from StateDir if not explicitly set. The derived URL
|
||||||
|
// is a default: it applies only when db_url is omitted, never to an
|
||||||
|
// explicitly empty value.
|
||||||
c.DBURL = loader.stringVal("db_url", "")
|
c.DBURL = loader.stringVal("db_url", "")
|
||||||
if c.DBURL == "" {
|
if c.DBURL == "" && loader.err == nil {
|
||||||
|
if sc != nil {
|
||||||
|
if _, present := sc.Get("db_url"); present {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"config key %q: value must not be empty; omit the key to derive it from state_dir",
|
||||||
|
"db_url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_journal_mode=WAL", c.StateDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,10 +142,12 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
|
|
||||||
// validateKnownKeys rejects configuration files containing keys the
|
// validateKnownKeys rejects configuration files containing keys the
|
||||||
// application does not understand, so typos fail at startup instead of
|
// application does not understand, so typos fail at startup instead of
|
||||||
// being silently ignored. The env section is permitted because
|
// being silently ignored, and rejects keys that are explicitly set to
|
||||||
|
// null: a null is a SET value, never an omission, so it must not
|
||||||
|
// silently take the default. The env section is permitted because
|
||||||
// smartconfig consumes it for environment variable injection.
|
// smartconfig consumes it for environment variable injection.
|
||||||
func validateKnownKeys(sc *smartconfig.Config) error {
|
func validateKnownKeys(sc *smartconfig.Config) error {
|
||||||
var unknown []string
|
var unknown, nullKeys []string
|
||||||
|
|
||||||
for key, value := range sc.Data() {
|
for key, value := range sc.Data() {
|
||||||
if !isKnownConfigKey(key) {
|
if !isKnownConfigKey(key) {
|
||||||
@@ -144,6 +156,12 @@ func validateKnownKeys(sc *smartconfig.Config) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if value == nil {
|
||||||
|
nullKeys = append(nullKeys, key)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if key == "metrics" {
|
if key == "metrics" {
|
||||||
metricsMap, ok := value.(map[string]interface{})
|
metricsMap, ok := value.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -152,9 +170,15 @@ func validateKnownKeys(sc *smartconfig.Config) error {
|
|||||||
"metrics", value)
|
"metrics", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
for subkey := range metricsMap {
|
for subkey, subvalue := range metricsMap {
|
||||||
if subkey != "username" && subkey != "password" {
|
if subkey != "username" && subkey != "password" {
|
||||||
unknown = append(unknown, "metrics."+subkey)
|
unknown = append(unknown, "metrics."+subkey)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if subvalue == nil {
|
||||||
|
nullKeys = append(nullKeys, "metrics."+subkey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,9 +190,29 @@ func validateKnownKeys(sc *smartconfig.Config) error {
|
|||||||
return fmt.Errorf("unknown config keys: %s", strings.Join(unknown, ", "))
|
return fmt.Errorf("unknown config keys: %s", strings.Join(unknown, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(nullKeys) > 0 {
|
||||||
|
sort.Strings(nullKeys)
|
||||||
|
|
||||||
|
if len(nullKeys) == 1 {
|
||||||
|
return errNullConfigValue(nullKeys[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf(
|
||||||
|
"config keys %s: value is null; omit a key entirely to use its default",
|
||||||
|
strings.Join(nullKeys, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errNullConfigValue reports a config key that is explicitly set to
|
||||||
|
// null (including the bare "key:" form and the "~" alias). Silently
|
||||||
|
// applying the default would mask a truncated or typo'd config entry.
|
||||||
|
func errNullConfigValue(key string) error {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"config key %q: value is null; omit the key entirely to use the default", key)
|
||||||
|
}
|
||||||
|
|
||||||
// isKnownConfigKey reports whether key is a permitted top-level
|
// isKnownConfigKey reports whether key is a permitted top-level
|
||||||
// configuration key.
|
// configuration key.
|
||||||
func isKnownConfigKey(key string) bool {
|
func isKnownConfigKey(key string) bool {
|
||||||
@@ -206,6 +250,7 @@ func (c *Config) ensureStateDirWritable() error {
|
|||||||
"state_dir", probePath, err)
|
"state_dir", probePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // G703: probePath comes from os.CreateTemp inside the just-validated StateDir
|
||||||
if err := os.Remove(probePath); err != nil {
|
if err := os.Remove(probePath); err != nil {
|
||||||
return fmt.Errorf("config key %q: cannot remove probe file %q: %w",
|
return fmt.Errorf("config key %q: cannot remove probe file %q: %w",
|
||||||
"state_dir", probePath, err)
|
"state_dir", probePath, err)
|
||||||
@@ -217,15 +262,16 @@ func (c *Config) ensureStateDirWritable() error {
|
|||||||
// validate checks that all required configuration values are set and
|
// validate checks that all required configuration values are set and
|
||||||
// that every value is within its valid range.
|
// that every value is within its valid range.
|
||||||
func (c *Config) validate() error {
|
func (c *Config) validate() error {
|
||||||
|
// The signing key value is never echoed in error messages.
|
||||||
if c.SigningKey == "" {
|
if c.SigningKey == "" {
|
||||||
return fmt.Errorf("signing_key is required")
|
return fmt.Errorf("config key %q: a value is required", "signing_key")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimum key length for security (32 bytes = 256 bits)
|
// Minimum key length for security (32 bytes = 256 bits)
|
||||||
const minKeyLength = 32
|
const minKeyLength = 32
|
||||||
if len(c.SigningKey) < minKeyLength {
|
if len(c.SigningKey) < minKeyLength {
|
||||||
return fmt.Errorf("signing_key must be at least %d characters, got %d",
|
return fmt.Errorf("config key %q: value must be at least %d characters, got %d",
|
||||||
minKeyLength, len(c.SigningKey))
|
"signing_key", minKeyLength, len(c.SigningKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxPort = 65535
|
const maxPort = 65535
|
||||||
@@ -267,7 +313,11 @@ func (c *Config) validate() error {
|
|||||||
|
|
||||||
// validateAllowlistHost checks that an allowlist_hosts entry is a bare
|
// validateAllowlistHost checks that an allowlist_hosts entry is a bare
|
||||||
// hostname, optionally with a leading dot for suffix matching. URLs,
|
// hostname, optionally with a leading dot for suffix matching. URLs,
|
||||||
// paths, and whitespace indicate a misconfigured entry.
|
// paths, and whitespace indicate a misconfigured entry. An entry with
|
||||||
|
// no hostname labels (such as ".") is rejected: the allowlist matcher
|
||||||
|
// treats a leading dot as a suffix pattern, so a bare "." would match
|
||||||
|
// any upstream host written in FQDN trailing-dot form and effectively
|
||||||
|
// disable URL signing.
|
||||||
func validateAllowlistHost(host string) error {
|
func validateAllowlistHost(host string) error {
|
||||||
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
|
if strings.Contains(host, "://") || strings.ContainsAny(host, "/ \t") {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
@@ -275,6 +325,12 @@ func validateAllowlistHost(host string) error {
|
|||||||
"allowlist_hosts", host)
|
"allowlist_hosts", host)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Trim(host, ".") == "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"config key %q: entry %q contains no hostname labels",
|
||||||
|
"allowlist_hosts", host)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,17 +426,22 @@ func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getString returns the string value for key, or defaultVal if the key
|
// getString returns the string value for key, or defaultVal if the key
|
||||||
// is omitted. A present value that is not a string is an error.
|
// is omitted. A present value that is not a string, or is explicitly
|
||||||
|
// null, is an error.
|
||||||
func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
|
func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
|
||||||
if sc == nil {
|
if sc == nil {
|
||||||
return defaultVal, nil
|
return defaultVal, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, ok := sc.Get(key)
|
raw, ok := sc.Get(key)
|
||||||
if !ok || raw == nil {
|
if !ok {
|
||||||
return defaultVal, nil
|
return defaultVal, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return "", errNullConfigValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
str, ok := raw.(string)
|
str, ok := raw.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("config key %q: value %v (%T) is not a string",
|
return "", fmt.Errorf("config key %q: value %v (%T) is not a string",
|
||||||
@@ -391,18 +452,22 @@ func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getInt returns the integer value for key, or defaultVal if the key is
|
// getInt returns the integer value for key, or defaultVal if the key is
|
||||||
// omitted. A present value that is not a whole number is an error;
|
// omitted. A present value that is not a whole number, or is explicitly
|
||||||
// fractional values are never truncated.
|
// null, is an error; fractional values are never truncated.
|
||||||
func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
|
func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
|
||||||
if sc == nil {
|
if sc == nil {
|
||||||
return defaultVal, nil
|
return defaultVal, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, ok := sc.Get(key)
|
raw, ok := sc.Get(key)
|
||||||
if !ok || raw == nil {
|
if !ok {
|
||||||
return defaultVal, nil
|
return defaultVal, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return 0, errNullConfigValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
switch val := raw.(type) {
|
switch val := raw.(type) {
|
||||||
case int:
|
case int:
|
||||||
return val, nil
|
return val, nil
|
||||||
@@ -429,17 +494,22 @@ func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
|
|||||||
|
|
||||||
// getBool returns the boolean value for key, or defaultVal if the key
|
// getBool returns the boolean value for key, or defaultVal if the key
|
||||||
// is omitted. A present value that is not a boolean (or a ParseBool-able
|
// is omitted. A present value that is not a boolean (or a ParseBool-able
|
||||||
// string) is an error; numbers are not accepted as booleans.
|
// string), or is explicitly null, is an error; numbers are not accepted
|
||||||
|
// as booleans.
|
||||||
func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) {
|
func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) {
|
||||||
if sc == nil {
|
if sc == nil {
|
||||||
return defaultVal, nil
|
return defaultVal, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, ok := sc.Get(key)
|
raw, ok := sc.Get(key)
|
||||||
if !ok || raw == nil {
|
if !ok {
|
||||||
return defaultVal, nil
|
return defaultVal, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return false, errNullConfigValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
switch val := raw.(type) {
|
switch val := raw.(type) {
|
||||||
case bool:
|
case bool:
|
||||||
return val, nil
|
return val, nil
|
||||||
@@ -458,17 +528,21 @@ func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error)
|
|||||||
|
|
||||||
// validateAllowlistHostsValue checks the raw shape of the
|
// validateAllowlistHostsValue checks the raw shape of the
|
||||||
// allowlist_hosts value before the lenient extraction in getStringSlice
|
// allowlist_hosts value before the lenient extraction in getStringSlice
|
||||||
// runs: a value that is not a list of strings (or a comma-separated
|
// runs: an explicitly null value, a value that is not a list of strings
|
||||||
// string), a non-string entry, or an empty entry is an error, never
|
// (or a comma-separated string), a non-string entry, or an empty entry
|
||||||
// silently skipped.
|
// is an error, never silently skipped.
|
||||||
func validateAllowlistHostsValue(sc *smartconfig.Config) error {
|
func validateAllowlistHostsValue(sc *smartconfig.Config) error {
|
||||||
const key = "allowlist_hosts"
|
const key = "allowlist_hosts"
|
||||||
|
|
||||||
raw, ok := sc.Get(key)
|
raw, ok := sc.Get(key)
|
||||||
if !ok || raw == nil {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if raw == nil {
|
||||||
|
return errNullConfigValue(key)
|
||||||
|
}
|
||||||
|
|
||||||
switch val := raw.(type) {
|
switch val := raw.(type) {
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
for _, item := range val {
|
for _, item := range val {
|
||||||
|
|||||||
@@ -295,6 +295,154 @@ func TestSetButInvalidValueAbortsStartup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestExplicitNullValueAbortsStartup verifies that a key explicitly
|
||||||
|
// set to null (including the bare "key:" form and the "~" alias) aborts
|
||||||
|
// startup naming the key. An explicit null is a SET value: it must
|
||||||
|
// never silently fall back to the default the way an omitted key does.
|
||||||
|
func TestExplicitNullValueAbortsStartup(t *testing.T) {
|
||||||
|
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
// wantErrSubstrings must all appear in the error message.
|
||||||
|
wantErrSubstrings []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "port explicit null",
|
||||||
|
yaml: signingKeyLine + "port: null\n",
|
||||||
|
wantErrSubstrings: []string{"port", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port bare key no value",
|
||||||
|
yaml: signingKeyLine + "port:\n",
|
||||||
|
wantErrSubstrings: []string{"port", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "debug tilde null",
|
||||||
|
yaml: signingKeyLine + "debug: ~\n",
|
||||||
|
wantErrSubstrings: []string{"debug", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "maintenance_mode null",
|
||||||
|
yaml: signingKeyLine + "maintenance_mode: null\n",
|
||||||
|
wantErrSubstrings: []string{"maintenance_mode", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allow_http null",
|
||||||
|
yaml: signingKeyLine + "allow_http: null\n",
|
||||||
|
wantErrSubstrings: []string{"allow_http", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "state_dir null",
|
||||||
|
yaml: signingKeyLine + "state_dir: null\n",
|
||||||
|
wantErrSubstrings: []string{"state_dir", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "db_url null",
|
||||||
|
yaml: signingKeyLine + "db_url: null\n",
|
||||||
|
wantErrSubstrings: []string{"db_url", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentry_dsn null",
|
||||||
|
yaml: signingKeyLine + "sentry_dsn: null\n",
|
||||||
|
wantErrSubstrings: []string{"sentry_dsn", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upstream_connections_per_host null",
|
||||||
|
yaml: signingKeyLine + "upstream_connections_per_host: null\n",
|
||||||
|
wantErrSubstrings: []string{"upstream_connections_per_host", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist_hosts null",
|
||||||
|
yaml: signingKeyLine + "allowlist_hosts: null\n",
|
||||||
|
wantErrSubstrings: []string{"allowlist_hosts", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "signing_key null",
|
||||||
|
yaml: "signing_key: null\n",
|
||||||
|
wantErrSubstrings: []string{"signing_key", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics null",
|
||||||
|
yaml: signingKeyLine + "metrics: null\n",
|
||||||
|
wantErrSubstrings: []string{"metrics", "null"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "metrics subkeys null",
|
||||||
|
yaml: signingKeyLine + "metrics:\n username: null\n password: null\n",
|
||||||
|
wantErrSubstrings: []string{
|
||||||
|
"metrics.username", "metrics.password", "null",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c, err := configFromYAML(t, tc.yaml)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
for _, want := range tc.wantErrSubstrings {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q does not mention %q", err.Error(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExplicitlyEmptyDBURLAbortsStartup verifies that db_url set to an
|
||||||
|
// empty string aborts startup: the derived file:...state.sqlite3 URL is
|
||||||
|
// a default, and defaults apply only to omitted keys. This matches
|
||||||
|
// state_dir, where an explicitly empty value already aborts.
|
||||||
|
func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) {
|
||||||
|
yamlContent := "signing_key: " + validTestSigningKey + "\ndb_url: \"\"\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("explicitly empty db_url must abort startup, got config: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "db_url") {
|
||||||
|
t.Errorf("error %q does not name the offending key db_url", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAllowlistHostsRejectsDotOnlyEntries verifies that entries with no
|
||||||
|
// hostname labels are rejected. The allowlist matcher treats a leading
|
||||||
|
// dot as a suffix pattern, so a bare "." entry would match any upstream
|
||||||
|
// host written in FQDN trailing-dot form (e.g. evil.com.) and
|
||||||
|
// effectively disable URL signing with a single character.
|
||||||
|
func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) {
|
||||||
|
signingKeyLine := "signing_key: " + validTestSigningKey + "\n"
|
||||||
|
|
||||||
|
for _, entry := range []string{".", ".."} {
|
||||||
|
t.Run(entry, func(t *testing.T) {
|
||||||
|
yamlContent := signingKeyLine +
|
||||||
|
"allowlist_hosts:\n - \"" + entry + "\"\n"
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, yamlContent)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("allowlist entry %q must abort startup, got config: %+v",
|
||||||
|
entry, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("got expected error: %v", err)
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "allowlist_hosts") {
|
||||||
|
t.Errorf("error %q does not name the offending key allowlist_hosts",
|
||||||
|
err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnknownTopLevelKeyAbortsStartup(t *testing.T) {
|
func TestUnknownTopLevelKeyAbortsStartup(t *testing.T) {
|
||||||
yamlContent := `signing_key: ` + validTestSigningKey + `
|
yamlContent := `signing_key: ` + validTestSigningKey + `
|
||||||
whitelist_hosts:
|
whitelist_hosts:
|
||||||
|
|||||||
Reference in New Issue
Block a user