mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-08 05:23:18 +01:00
fix(api): snapshot the output filename before echo recycles its context
This commit is contained in:
@@ -51,6 +51,13 @@ type Context struct {
|
|||||||
outputPaths []string
|
outputPaths []string
|
||||||
cancelled bool
|
cancelled bool
|
||||||
|
|
||||||
|
// outputFilename is the sanitized Gotenberg-Output-Filename header,
|
||||||
|
// snapshotted while the [echo.Context] is still live. Echo returns that
|
||||||
|
// context to a pool as soon as the handler returns, and an asynchronous
|
||||||
|
// conversion outlives it, so reading the header from the pooled store later
|
||||||
|
// yields whichever request happens to own it by then.
|
||||||
|
outputFilename string
|
||||||
|
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
echoCtx echo.Context
|
echoCtx echo.Context
|
||||||
mkdirAll gotenberg.MkdirAll
|
mkdirAll gotenberg.MkdirAll
|
||||||
@@ -159,14 +166,18 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot now, while echoCtx still belongs to this request.
|
||||||
|
outputFilename, _ := echoCtx.Get("outputFilename").(string)
|
||||||
|
|
||||||
ctx := &Context{
|
ctx := &Context{
|
||||||
outputPaths: make([]string, 0),
|
outputPaths: make([]string, 0),
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
logger: logger,
|
outputFilename: outputFilename,
|
||||||
echoCtx: echoCtx,
|
logger: logger,
|
||||||
mkdirAll: new(gotenberg.OsMkdirAll),
|
echoCtx: echoCtx,
|
||||||
pathRename: new(gotenberg.OsPathRename),
|
mkdirAll: new(gotenberg.OsMkdirAll),
|
||||||
Context: processCtx,
|
pathRename: new(gotenberg.OsPathRename),
|
||||||
|
Context: processCtx,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A custom cancel function which removes the context's working directory
|
// A custom cancel function which removes the context's working directory
|
||||||
@@ -754,12 +765,12 @@ func (ctx *Context) BuildOutputFile() (string, error) {
|
|||||||
// OutputFilename returns the filename based on the given output path or the
|
// OutputFilename returns the filename based on the given output path or the
|
||||||
// "Gotenberg-Output-Filename" header's value.
|
// "Gotenberg-Output-Filename" header's value.
|
||||||
func (ctx *Context) OutputFilename(outputPath string) string {
|
func (ctx *Context) OutputFilename(outputPath string) string {
|
||||||
filename := ctx.echoCtx.Get("outputFilename").(string)
|
if ctx.outputFilename == "" {
|
||||||
|
|
||||||
if filename == "" {
|
|
||||||
return ctx.OriginalFilename(outputPath)
|
return ctx.OriginalFilename(outputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filename := ctx.outputFilename
|
||||||
|
|
||||||
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -602,3 +602,70 @@ func TestDecodeDownloadFrom_StopsBeforeMaterializingTheArray(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Logf("allocated %d KiB decoding a %d-entry array with a limit of 1000", allocated>>10, entries)
|
t.Logf("allocated %d KiB decoding a %d-entry array with a limit of 1000", allocated>>10, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An asynchronous conversion outlives the [echo.Context]. Echo returns that
|
||||||
|
// context to a sync.Pool as soon as the handler returns, and
|
||||||
|
// outputFilenameMiddleware runs in srv.Pre on every request, including
|
||||||
|
// /health, so a later request overwrites the store. Reading the output
|
||||||
|
// filename from it after the fact returned another caller's value.
|
||||||
|
func TestContext_OutputFilename_SurvivesEchoContextRecycling(t *testing.T) {
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
err := writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
// What outputFilenameMiddleware does for this request.
|
||||||
|
echoCtx.Set("outputFilename", "victim")
|
||||||
|
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
|
|
||||||
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{disable: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newContext returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Echo recycles the context and another request claims the store.
|
||||||
|
echoCtx.Set("outputFilename", "attacker-controlled")
|
||||||
|
|
||||||
|
if got := ctx.OutputFilename("/tmp/out.pdf"); got != "victim.pdf" {
|
||||||
|
t.Fatalf("OutputFilename = %q, want %q", got, "victim.pdf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A recycled context has a nil store, so the previous unguarded type assertion
|
||||||
|
// could panic. The snapshot must tolerate an absent value.
|
||||||
|
func TestContext_OutputFilename_NoHeader(t *testing.T) {
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
err := writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
// No Set call at all: the store holds nothing for "outputFilename".
|
||||||
|
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
|
|
||||||
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{disable: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newContext returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if got := ctx.OutputFilename("/tmp/out.pdf"); got != "out.pdf" {
|
||||||
|
t.Fatalf("OutputFilename = %q, want the original filename %q", got, "out.pdf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user