Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions caddy/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,26 @@ package caddy

import (
"testing"
"time"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/stretchr/testify/require"
)

func TestModuleRequestBodyTimeout(t *testing.T) {
d := caddyfile.NewTestDispenser(`
{
php {
request_body_timeout 5s
}
}`)
module := &FrankenPHPModule{}

require.NoError(t, module.UnmarshalCaddyfile(d))
require.Equal(t, caddy.Duration(5*time.Second), module.RequestBodyTimeout)
}

func TestModuleWorkerDuplicateFilenamesFail(t *testing.T) {
// Create a test configuration with duplicate worker filenames
configWithDuplicateFilenames := `
Expand Down
22 changes: 21 additions & 1 deletion caddy/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"slices"
"strconv"
"strings"
"time"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig"
Expand Down Expand Up @@ -45,6 +46,8 @@ type FrankenPHPModule struct {
Env map[string]string `json:"env,omitempty"`
// Workers configures the worker scripts to start.
Workers []workerConfig `json:"workers,omitempty"`
// RequestBodyTimeout is an idle timeout on request body reads: a stalled (slow POST) client is cut off while a steady upload of any size succeeds. Zero (the default) disables it.
RequestBodyTimeout caddy.Duration `json:"request_body_timeout,omitempty"`

resolvedDocumentRoot string
preparedEnv frankenphp.PreparedEnv
Expand Down Expand Up @@ -123,6 +126,10 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
}
f.requestOptions = append(f.requestOptions, opt)

if f.RequestBodyTimeout > 0 {
f.requestOptions = append(f.requestOptions, frankenphp.WithRequestBodyTimeout(time.Duration(f.RequestBodyTimeout)))
}

if f.ResolveRootSymlink == nil {
f.ResolveRootSymlink = new(true)
}
Expand Down Expand Up @@ -310,8 +317,21 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return err
}

case "request_body_timeout":
if !d.NextArg() {
return d.ArgErr()
}
v, err := caddy.ParseDuration(d.Val())
if err != nil {
return err
}
if d.NextArg() {
return d.ArgErr()
}
f.RequestBodyTimeout = caddy.Duration(v)

default:
return wrongSubDirectiveError("php or php_server", "hot_reload, name, root, split, env, resolve_root_symlink, worker", d.Val())
return wrongSubDirectiveError("php or php_server", "hot_reload, name, root, split, env, resolve_root_symlink, request_body_timeout, worker", d.Val())
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ type frankenPHPContext struct {
originalRequest *http.Request
worker *worker

// idle timeout per body read; zero disables it
requestBodyTimeout time.Duration

docURI string
pathInfo string
scriptName string
Expand Down
17 changes: 17 additions & 0 deletions frankenphp.go
Original file line number Diff line number Diff line change
Expand Up @@ -639,14 +639,31 @@ func go_read_post(threadIndex C.uintptr_t, cBuf *C.char, countBytes C.size_t) (r
return 0
}

var rc *http.ResponseController
if fc.requestBodyTimeout > 0 {
if fc.responseController == nil {
fc.responseController = http.NewResponseController(fc.responseWriter)
}
rc = fc.responseController
}

p := unsafe.Slice((*byte)(unsafe.Pointer(cBuf)), countBytes)
var err error
for readBytes < countBytes && err == nil {
if rc != nil {
// reset before each read: bound a stall, not a steady upload
_ = rc.SetReadDeadline(time.Now().Add(fc.requestBodyTimeout))
}

var n int
n, err = fc.request.Body.Read(p[readBytes:])
readBytes += C.size_t(n)
}

if rc != nil {
_ = rc.SetReadDeadline(time.Time{})
}

return
}

Expand Down
87 changes: 87 additions & 0 deletions requestbodytimeout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package frankenphp_test

import (
"fmt"
"io"
"net"
"net/http"
"os"
"testing"
"time"

"github.com/dunglas/frankenphp"
"github.com/stretchr/testify/require"
)

// TestRequestBodyTimeout proves that WithRequestBodyTimeout bounds a slow-POST
// client: it announces a large Content-Length, then stalls without sending the
// body. Without the option the PHP thread would block in Body.Read until the
// connection closed; with it, the read is cut off and the request completes.
func TestRequestBodyTimeout(t *testing.T) {
require.NoError(t, frankenphp.Init())
defer frankenphp.Shutdown()

cwd, _ := os.Getwd()
handler := func(w http.ResponseWriter, r *http.Request) {
req, err := frankenphp.NewRequestWithContext(r,
frankenphp.WithRequestDocumentRoot(cwd+"/testdata/", false),
frankenphp.WithRequestBodyTimeout(300*time.Millisecond),
)
require.NoError(t, err)
require.NoError(t, frankenphp.ServeHTTP(w, req))
}

ts := newRawServer(t, handler)
defer ts.Close()

conn, err := net.Dial("tcp", ts.Addr())
require.NoError(t, err)
defer conn.Close()

// Announce a 1 MiB body, then send nothing: a classic slow POST.
fmt.Fprintf(conn,
"POST /read-input.php HTTP/1.1\r\nHost: %s\r\nContent-Type: application/octet-stream\r\nContent-Length: 1048576\r\nConnection: close\r\n\r\n",
ts.Addr(),
)

require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
start := time.Now()
resp, err := io.ReadAll(conn)
elapsed := time.Since(start)
require.NoError(t, err)

// The 300ms idle timeout (applied at most twice by PHP's read loop) must
// release the thread well before the client's 5s read deadline.
require.Less(t, elapsed, 4*time.Second, "slow body must be bounded by the timeout")
require.Contains(t, string(resp), "200 OK")
// PHP saw an empty body: php://input read zero bytes.
require.Contains(t, string(resp), "read=0")
Comment on lines +56 to +58
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we want to silently pass a failed read to php as if everything was fine and then expect 200 back? I'm a bit confused here.

}

// rawServer is a minimal HTTP server exposing its listener address so a test
// can drive it with a raw TCP connection (needed to simulate a stalled body).
type rawServer struct {
ln net.Listener
srv *http.Server
}

func newRawServer(t *testing.T, handler http.HandlerFunc) *rawServer {
t.Helper()

ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)

srv := &http.Server{Handler: handler}
go func() {
_ = srv.Serve(ln)
}()

return &rawServer{ln: ln, srv: srv}
}

func (s *rawServer) Addr() string { return s.ln.Addr().String() }

func (s *rawServer) Close() {
_ = s.srv.Close()
_ = s.ln.Close()
}
13 changes: 13 additions & 0 deletions requestoptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"

"github.com/dunglas/frankenphp/internal/fastabs"
Expand Down Expand Up @@ -154,6 +155,18 @@ func WithRequestLogger(logger *slog.Logger) RequestOption {
}
}

// WithRequestBodyTimeout sets an idle timeout on request body reads: a stalled
// (slow POST) client is cut off while a steady upload of any size succeeds.
// Zero (the default) disables it. Requires a ResponseWriter that exposes a read
// deadline (net/http and Caddy do); otherwise the read has no timeout.
func WithRequestBodyTimeout(timeout time.Duration) RequestOption {
return func(o *frankenPHPContext) error {
o.requestBodyTimeout = timeout

return nil
}
}

// WithWorkerName sets the worker that should handle the request
func WithWorkerName(name string) RequestOption {
return func(o *frankenPHPContext) error {
Expand Down
3 changes: 3 additions & 0 deletions testdata/read-input.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?php

echo 'read=' . strlen(file_get_contents('php://input'));
Loading