Compare commits
3
Commits
3689685680
...
6f50eb20df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f50eb20df | ||
|
|
1aacf6cbf6 | ||
|
|
efa431ba18 |
No files matched your search
@@ -725,11 +725,14 @@ Key settings in more detail:
|
|||||||
- `signing_key` — HMAC secret for URL signatures
|
- `signing_key` — HMAC secret for URL signatures
|
||||||
- `db_url` — the SQLite database to open; omitted, it is
|
- `db_url` — the SQLite database to open; omitted, it is
|
||||||
`file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)`, which keeps the
|
`file:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL)`, which keeps the
|
||||||
database in WAL mode. pixa opens one connection to it, so its own reads and
|
database in WAL mode. `state_dir` is percent-encoded in it, so a `%`, `?` or
|
||||||
writes run one at a time. pixa adds `_pragma=busy_timeout(5000)` to any
|
`#` in the name is written as `%25`, `%3F` or `%23`. For a `state_dir` of `/`
|
||||||
`db_url`, so a write that finds another program writing to the file waits up
|
or one starting with `//` it starts `file:////`, so SQLite does not read the
|
||||||
to five seconds for it instead of failing. WAL mode comes only from the URL:
|
first name in the path as a host. pixa opens one connection to it, so its own
|
||||||
keep `_pragma=journal_mode(WAL)` in one you set
|
reads and writes run one at a time. pixa adds `_pragma=busy_timeout(5000)` to
|
||||||
|
any `db_url`, so a write that finds another program writing to the file waits
|
||||||
|
up to five seconds for it instead of failing. WAL mode comes only from the
|
||||||
|
URL: keep `_pragma=journal_mode(WAL)` in one you set
|
||||||
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the disk
|
- `cache_max_bytes` — disk cache size limit in bytes; `0` disables the disk
|
||||||
cache entirely; omitted defaults to 75% of the sum of the free space on the
|
cache entirely; omitted defaults to 75% of the sum of the free space on the
|
||||||
filesystem containing `<state_dir>/cache/` and the bytes of source and
|
filesystem containing `<state_dir>/cache/` and the bytes of source and
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ P2: security: per-origin rate limiting
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-16 a source host that is an IPv6 address in brackets with no port,
|
||||||
|
such as `[2001:db8::a]`, is fetched like one with a port, through `/v1/image/`
|
||||||
|
and through an encrypted URL (closes #295). The fetcher's check of the source
|
||||||
|
URL kept the brackets when no port followed them, could not look the host up,
|
||||||
|
and the request answered 500. It now checks the address without brackets, so a
|
||||||
|
private one is refused with 403, as with a port.
|
||||||
|
- 2026-10-16 a `state_dir` whose name holds `%`, `?` or `#` keeps its database
|
||||||
|
at `state.sqlite3` in that directory (closes #290): the `db_url` derived from
|
||||||
|
it put the name into a `file:` URL as it was, so SQLite read `%41` as `A`, a
|
||||||
|
`?` or `#` ended the path early, and the journal mode parameter was lost. The
|
||||||
|
derived `db_url` is now built with Go's `net/url`, which writes those
|
||||||
|
characters as `%25`, `%3F` and `%23`. For a `state_dir` of `/` or one starting
|
||||||
|
with `//` it starts `file:////`, so SQLite no longer reads the first name in
|
||||||
|
the path as a host.
|
||||||
- 2026-10-16 a size with one side `0` keeps the side computed from the source's
|
- 2026-10-16 a size with one side `0` keeps the side computed from the source's
|
||||||
proportions at 1 or more (closes #283): it was rounded down, so for a thin
|
proportions at 1 or more (closes #283): it was rounded down, so for a thin
|
||||||
source it came out at 0, and libvips then made the size square. An 8192x1
|
source it came out at 0, and libvips then made the size square. An 8192x1
|
||||||
|
|||||||
@@ -338,8 +338,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
settingName(keyDBURL), errValueEmpty)
|
settingName(keyDBURL), errValueEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The driver sets the journal mode only through a _pragma parameter.
|
c.DBURL = defaultDBURL(c.StateDir)
|
||||||
c.DBURL = fmt.Sprintf("file:%s/state.sqlite3?_pragma=journal_mode(WAL)", c.StateDir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if loader.err != nil {
|
if loader.err != nil {
|
||||||
@@ -354,6 +353,30 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultDBURL returns the db_url used when db_url is omitted: the file
|
||||||
|
// state.sqlite3 in stateDir, in WAL mode, which the driver sets only
|
||||||
|
// through a _pragma parameter. String() percent-encodes any %, ? or # in
|
||||||
|
// the directory name, which SQLite decodes back, so the URL names exactly
|
||||||
|
// this file. OmitHost writes file:/var/lib/pixa/... instead of
|
||||||
|
// file:///var/lib/pixa/..., and keeps a relative stateDir from being read
|
||||||
|
// as a host name. The path written into the URL is stateDir followed by
|
||||||
|
// /state.sqlite3; when it starts with //, as it does for a stateDir of /
|
||||||
|
// or one starting with //, the empty host is kept, so the URL starts
|
||||||
|
// file://// and SQLite does not read the first name in the path as a
|
||||||
|
// host.
|
||||||
|
func defaultDBURL(stateDir string) string {
|
||||||
|
dbPath := stateDir + "/state.sqlite3"
|
||||||
|
|
||||||
|
dbURL := url.URL{
|
||||||
|
Scheme: "file",
|
||||||
|
OmitHost: !strings.HasPrefix(dbPath, "//"),
|
||||||
|
Path: dbPath,
|
||||||
|
RawQuery: "_pragma=journal_mode(WAL)",
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbURL.String()
|
||||||
|
}
|
||||||
|
|
||||||
// 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, and rejects keys that are explicitly set to
|
// being silently ignored, and rejects keys that are explicitly set to
|
||||||
|
|||||||
@@ -135,6 +135,104 @@ func TestDefaultDBURLOpensTheDatabaseInWALMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDerivedDBURLOpensStateDirHoldingURLCharacters opens the db_url
|
||||||
|
// derived from a state_dir whose name holds %41, ? and #, and checks that
|
||||||
|
// the database is state.sqlite3 in that directory, in WAL mode. Put into
|
||||||
|
// a file: URL as it is, the name would lose its meaning: %41 is read as
|
||||||
|
// A, and the path ends at the ?, which also loses the journal mode.
|
||||||
|
func TestDerivedDBURLOpensStateDirHoldingURLCharacters(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stateDir := filepath.Join(t.TempDir(), "a%41b?c#d")
|
||||||
|
|
||||||
|
err := os.Mkdir(stateDir, 0o750)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create %q: %v", stateDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quoted so YAML takes the whole name as it is.
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"state_dir: '"+stateDir+"'\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config with state_dir %q should be valid, got: %v", stateDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("state_dir %q gives db_url %q", stateDir, c.DBURL)
|
||||||
|
|
||||||
|
checkDBURLOpensFileInWALMode(t, c.DBURL, filepath.Join(stateDir, "state.sqlite3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDerivedDBURLOpensStateDirStartingWithTwoSlashes opens the db_url
|
||||||
|
// derived from a state_dir that starts with //, and checks that the
|
||||||
|
// database is state.sqlite3 in that directory, in WAL mode. Written right
|
||||||
|
// after file:, such a name gives a URL starting file://, and SQLite reads
|
||||||
|
// the first directory name as a host and refuses to open it.
|
||||||
|
func TestDerivedDBURLOpensStateDirStartingWithTwoSlashes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
stateDir := "/" + dir
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"state_dir: "+stateDir+"\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config with state_dir %q should be valid, got: %v", stateDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("state_dir %q gives db_url %q", stateDir, c.DBURL)
|
||||||
|
|
||||||
|
checkDBURLOpensFileInWALMode(t, c.DBURL, filepath.Join(dir, "state.sqlite3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDerivedDBURLForRootStateDir checks the db_url derived from a
|
||||||
|
// state_dir of /. The path written into the URL is then
|
||||||
|
// //state.sqlite3, and written right after file: it gives a URL starting
|
||||||
|
// file://, where SQLite reads state.sqlite3 as a host and refuses to open
|
||||||
|
// it. The test only checks the URL, since it cannot write to /.
|
||||||
|
func TestDerivedDBURLForRootStateDir(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := configFromYAML(t, signingKeyLine+"state_dir: /\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config with state_dir / should be valid, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("state_dir / gives db_url %q", c.DBURL)
|
||||||
|
|
||||||
|
wantDBURL := "file:////state.sqlite3?_pragma=journal_mode(WAL)"
|
||||||
|
if c.DBURL != wantDBURL {
|
||||||
|
t.Errorf("DBURL = %q, want %q", c.DBURL, wantDBURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDBURLOpensFileInWALMode opens dbURL with the SQLite driver pixad
|
||||||
|
// uses, and checks that the database is in WAL mode and is the file
|
||||||
|
// dbFile.
|
||||||
|
func checkDBURLOpensFileInWALMode(t *testing.T, dbURL, dbFile string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dbURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open %q: %v", dbURL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
var journalMode string
|
||||||
|
|
||||||
|
err = db.QueryRowContext(t.Context(), "PRAGMA journal_mode").Scan(&journalMode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read the journal mode of %q: %v", dbURL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if journalMode != "wal" {
|
||||||
|
t.Errorf("journal mode of %q = %q, want wal", dbURL, journalMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stat(dbFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("opening %q did not create %q: %v", dbURL, dbFile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
func TestExplicitValidValuesAreUsed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -473,16 +473,16 @@ func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
|
|||||||
return ErrUnsupportedScheme
|
return ErrUnsupportedScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse to extract host
|
parsedURL, err := neturl.Parse(rawURL)
|
||||||
host := extractHost(rawURL)
|
if err != nil {
|
||||||
if host == "" {
|
return fmt.Errorf("%w: %w", ErrInvalidHost, err)
|
||||||
return ErrInvalidHost
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove port if present
|
// Hostname drops any port, and the brackets of an IPv6 address whether
|
||||||
h, _, err := net.SplitHostPort(host)
|
// or not a port follows them
|
||||||
if err == nil {
|
host := parsedURL.Hostname()
|
||||||
host = h
|
if host == "" {
|
||||||
|
return ErrInvalidHost
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block obvious localhost patterns
|
// Block obvious localhost patterns
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
"sneak.berlin/go/pixa/internal/imgcache"
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
"sneak.berlin/go/pixa/internal/session"
|
"sneak.berlin/go/pixa/internal/session"
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
)
|
)
|
||||||
|
|
||||||
// percentSignPath is a source path holding a "%", which a URL writes %25.
|
// percentSignPath is a source path holding a "%", which a URL writes %25.
|
||||||
@@ -433,3 +437,109 @@ func TestGeneratorRefusesSourceURLItCannotFetchAsWritten(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ipv6SourcePath is the source path of the image URLs signedImageURL and
|
||||||
|
// encryptedImageURL make.
|
||||||
|
const ipv6SourcePath = "/ipv6.png"
|
||||||
|
|
||||||
|
// TestImageRoutesFetchIPv6SourceHostWithOrWithoutPort requests ipv6SourcePath
|
||||||
|
// on an IPv6 source host, written with and without a port, through a
|
||||||
|
// /v1/image/ URL with a signature and through an encrypted URL. The public
|
||||||
|
// address 2001:db8::a, a documentation address (RFC 3849) the fetcher accepts
|
||||||
|
// as public, answers 200, and the upstream receives the request for
|
||||||
|
// ipv6SourcePath. The private address fd00::1 answers 403, and the upstream
|
||||||
|
// receives no request: the test's fetcher connects every address to the test
|
||||||
|
// upstream, so only the fetcher's check of the source URL can refuse it.
|
||||||
|
func TestImageRoutesFetchIPv6SourceHostWithOrWithoutPort(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sourceHost string
|
||||||
|
wantStatus int
|
||||||
|
// wantRequests are the request targets the upstream must receive
|
||||||
|
wantRequests []string
|
||||||
|
}{
|
||||||
|
{"public address without a port", "[2001:db8::a]",
|
||||||
|
http.StatusOK, []string{ipv6SourcePath}},
|
||||||
|
{"public address with a port", "[2001:db8::a]:8443",
|
||||||
|
http.StatusOK, []string{ipv6SourcePath}},
|
||||||
|
{"private address without a port", "[fd00::1]",
|
||||||
|
http.StatusForbidden, nil},
|
||||||
|
{"private address with a port", "[fd00::1]:8443",
|
||||||
|
http.StatusForbidden, nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
imageURLs := map[string]string{
|
||||||
|
"image URL with a signature": signedImageURL(t, tt.sourceHost),
|
||||||
|
"encrypted URL": encryptedImageURL(t, tt.sourceHost),
|
||||||
|
}
|
||||||
|
|
||||||
|
for route, imageURL := range imageURLs {
|
||||||
|
t.Run(tt.name+" "+route, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
upstream := startRecordingUpstream(t)
|
||||||
|
s, _, _ := startImageProxy(t, upstream.Server)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, imageURL, nil))
|
||||||
|
t.Logf("GET %s: %d", imageURL, rec.Code)
|
||||||
|
|
||||||
|
if rec.Code != tt.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d; body %s",
|
||||||
|
rec.Code, tt.wantStatus, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
got := upstream.requestTargets()
|
||||||
|
t.Logf("upstream received %q", got)
|
||||||
|
|
||||||
|
if !slices.Equal(got, tt.wantRequests) {
|
||||||
|
t.Errorf("upstream received %q, want %q", got, tt.wantRequests)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// signedImageURL returns the /v1/image/ URL of ipv6SourcePath on sourceHost at
|
||||||
|
// its original size as PNG, with a signature made with the test signing key,
|
||||||
|
// which a host not on the allowlist needs. The URL has no q or fit, so it is
|
||||||
|
// signed with the quality 85 and the fit cover.
|
||||||
|
func signedImageURL(t *testing.T, sourceHost string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path, sig, exp := signature.New(testSigningKey).GenerateSignedURL(
|
||||||
|
&signature.Request{
|
||||||
|
SourceHost: sourceHost,
|
||||||
|
SourcePath: ipv6SourcePath,
|
||||||
|
Format: string(imgcache.FormatPNG),
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: string(imgcache.FitCover),
|
||||||
|
}, time.Hour)
|
||||||
|
|
||||||
|
return path + "?sig=" + sig + "&exp=" + strconv.FormatInt(exp, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptedImageURL returns the encrypted URL of ipv6SourcePath on sourceHost
|
||||||
|
// at its original size as PNG, made with the test signing key.
|
||||||
|
func encryptedImageURL(t *testing.T, sourceHost string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
generator, err := encurl.NewGenerator(testSigningKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl.NewGenerator: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := generator.Generate(&encurl.Payload{
|
||||||
|
SourceHost: sourceHost, SourcePath: ipv6SourcePath,
|
||||||
|
Format: imgcache.FormatPNG,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encurl Generate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/v1/e/" + token + "/img.png"
|
||||||
|
}
|
||||||
Reference in new issue
Block a user