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
2 changes: 2 additions & 0 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type frankenPHPContext struct {
scriptFilename string
requestURI string

// Whether the final response headers were already sent
headersWritten bool
// Whether the request is already closed by us
isDone bool
// The client's connection state as of the moment isDone was set. Captured
Expand Down
11 changes: 10 additions & 1 deletion frankenphp.c
Original file line number Diff line number Diff line change
Expand Up @@ -1448,7 +1448,16 @@ static void frankenphp_sapi_flush(void *server_context) {
}

static size_t frankenphp_read_post(char *buffer, size_t count_bytes) {
return go_read_post(frankenphp_thread_index(), buffer, count_bytes);
struct go_read_post_return result =
go_read_post(frankenphp_thread_index(), buffer, count_bytes);

// stop the script like exit() does: it can't be caught, so it can't keep
// processing the truncated body
if (result.r1 && EG(current_execute_data)) {
zend_throw_unwind_exit();
}

return result.r0;
}

static char *frankenphp_read_cookies(void) {
Expand Down
15 changes: 13 additions & 2 deletions frankenphp.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ var (
ErrInvalidRequestPath = ErrRejected{"invalid request path", http.StatusBadRequest}
ErrInvalidContentLengthHeader = ErrRejected{"invalid Content-Length header", http.StatusBadRequest}
ErrMaxWaitTimeExceeded = ErrRejected{"maximum request handling time exceeded", http.StatusServiceUnavailable}
ErrRequestBodyTooLarge = ErrRejected{"request body too large", http.StatusRequestEntityTooLarge}

contextKey = contextKeyStruct{}
serverHeader = []string{"FrankenPHP"}
Expand Down Expand Up @@ -688,6 +689,7 @@ func go_write_headers(threadIndex C.uintptr_t, status C.int, headers *C.zend_lli
}

fc.responseWriter.WriteHeader(goStatus)
fc.headersWritten = goStatus >= 200

if goStatus < 200 {
// Clear headers, it's not automatically done by ResponseWriter.WriteHeader() for 1xx responses
Expand Down Expand Up @@ -740,12 +742,12 @@ func go_sapi_flush(threadIndex C.uintptr_t) bool {
}

//export go_read_post
func go_read_post(threadIndex C.uintptr_t, cBuf *C.char, countBytes C.size_t) (readBytes C.size_t) {
func go_read_post(threadIndex C.uintptr_t, cBuf *C.char, countBytes C.size_t) (readBytes C.size_t, tooLarge C.bool) {
fc := phpThreads[threadIndex].handler.frankenPHPContext()

// net/http forbids reading the body after the handler returns; body wrappers may touch the dead stream
if fc.responseWriter == nil || fc.isDone {
return 0
return 0, false
}

var rc *http.ResponseController
Expand Down Expand Up @@ -773,6 +775,15 @@ func go_read_post(threadIndex C.uintptr_t, cBuf *C.char, countBytes C.size_t) (r
_ = rc.SetReadDeadline(time.Time{})
}

// PHP can't see read errors: fail the request rather than expose a truncated body
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
if !fc.headersWritten {
fc.reject(ErrRequestBodyTooLarge)
}

tooLarge = true
}

return
}

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

import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

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

func TestRequestBodyTooLarge(t *testing.T) {
require.NoError(t, frankenphp.Init())
defer frankenphp.Shutdown()

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

for name, size := range map[string]int{"under": 512, "over": 4096} {
t.Run(name, func(t *testing.T) {
req := httptest.NewRequest("POST", "http://example.com/read-input.php", strings.NewReader(strings.Repeat("x", size)))
w := httptest.NewRecorder()
handler(w, req)

if size > 1024 {
require.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
require.NotContains(t, w.Body.String(), "read=")
} else {
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), "read=512")
}
})
}
}

func TestWorkerRequestBodyTooLarge(t *testing.T) {
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) {
// the worker must stay usable after each rejected request
for _, size := range []int{4096, 512, 4096, 512} {
req := httptest.NewRequest("POST", "http://example.com/worker-read-input.php", strings.NewReader(strings.Repeat("x", size)))
req.Body = http.MaxBytesReader(httptest.NewRecorder(), req.Body, 1024)
w := httptest.NewRecorder()
handler(w, req)

if size > 1024 {
assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
assert.NotContains(t, w.Body.String(), "read=")
} else {
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "read=512", w.Body.String())
}
}
}, &testOptions{workerScript: "worker-read-input.php", nbWorkers: 1, nbParallelRequests: 1})
}

// The script must be stopped when the body is too large, not run on with a truncated one.
func TestRequestBodyTooLargeStopsScript(t *testing.T) {
require.NoError(t, frankenphp.Init())
defer frankenphp.Shutdown()

cwd, _ := os.Getwd()
marker := filepath.Join(t.TempDir(), "marker")

r := httptest.NewRequest("POST", "http://example.com/read-input-then-touch.php?marker="+marker, strings.NewReader(strings.Repeat("x", 4096)))
w := httptest.NewRecorder()
r.Body = http.MaxBytesReader(w, r.Body, 1024)
req, err := frankenphp.NewRequestWithContext(r, frankenphp.WithRequestDocumentRoot(cwd+"/testdata/", false))
require.NoError(t, err)
require.NoError(t, frankenphp.ServeHTTP(w, req))

require.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
require.NoFileExists(t, marker)
}
8 changes: 8 additions & 0 deletions testdata/read-input-then-touch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

file_get_contents('php://input');
try {
touch($_GET['marker']);
} finally {
touch($_GET['marker']);
}
8 changes: 8 additions & 0 deletions testdata/worker-read-input.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

$handler = static function () {
echo 'read=' . strlen(file_get_contents('php://input'));
};

while (frankenphp_handle_request($handler)) {
}
Loading