VYPR
High severityNVD Advisory· Published May 26, 2026

CVE-2026-43981

CVE-2026-43981

Description

Algernon is a small self-contained pure-Go web server. Prior to 1.17.6, in engine/luahandler.go, the sync.RWMutex protecting LoadCommonFunctions is released before L.Push() and L.PCall() execute. Since gopher-lua's LState is explicitly not goroutine-safe, concurrent requests race on the shared state causing Lua VM corruption. The Go race detector confirms this immediately under modest concurrency (ab -n 1000 -c 100). This vulnerability is fixed in 1.17.6.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Algernon prior to 1.17.6 has a race condition in engine/luahandler.go leading to Lua VM corruption and denial of service via concurrent requests.

Vulnerability

In Algernon before version 1.17.6, the engine/luahandler.go file contains a race condition. The sync.RWMutex protecting LoadCommonFunctions is released before L.Push() and L.PCall() execute. Since gopher-lua's LState is not goroutine-safe, concurrent requests can race on the shared state, leading to Lua VM corruption. No special configuration is required; the bug is triggered whenever the server handles Lua requests. [1]

Exploitation

An attacker can exploit this vulnerability by sending multiple concurrent requests to any Lua endpoint. No authentication or special privileges are needed. The Go race detector confirms the race under modest concurrency (ab -n 1000 -c 100). The attacker only needs network access to the server. [1]

Impact

Successful exploitation causes Lua VM corruption, resulting in a denial of service. The server may crash, hang, or behave unpredictably. The advisory lists impact as "Denial of service when using Lua + Algernon." [1]

Mitigation

The vulnerability is fixed in Algernon version 1.17.6, released on 2026-05-26. No workarounds are available. Users should upgrade to the latest version. [1]

AI Insight generated on May 26, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Xyproto/Algernonreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: <1.17.6

Patches

2
83cd2fbd01b3

Fix path traversal, ref #172

https://github.com/xyproto/algernonAlexander F. RødsethApr 25, 2026Fixed in 1.17.6via llm-release-walk
1 file changed · +26 1
  • lua/upload/upload.go+26 1 modified
    @@ -11,6 +11,7 @@ import (
     	"os"
     	"path/filepath"
     	"strconv"
    +	"strings"
     
     	"github.com/sirupsen/logrus"
     	"github.com/xyproto/algernon/utils"
    @@ -96,8 +97,11 @@ func New(req *http.Request, scriptdir, formID string, uploadLimit int64) (*Uploa
     		}
     	}
     
    +	// use only the base name to prevent path traversal
    +	safeFilename := filepath.Base(handler.Filename)
    +
     	// all ok
    -	return &UploadedFile{req, handler.Header, buf, scriptdir, handler.Filename}, nil
    +	return &UploadedFile{req, handler.Header, buf, scriptdir, safeFilename}, nil
     }
     
     // Get the first argument, "self", and cast it from userdata to
    @@ -206,6 +210,15 @@ func uploadedFileSave(L *lua.LState) int {
     	// Get the full path
     	writeFilename := filepath.Join(ulf.scriptdir, filename)
     
    +	// prevent path traversal
    +	absBase, _ := filepath.Abs(ulf.scriptdir)
    +	absTarget, _ := filepath.Abs(writeFilename)
    +	if !strings.HasPrefix(absTarget, absBase+string(os.PathSeparator)) && absTarget != absBase {
    +		logrus.Error("path traversal attempt blocked: ", writeFilename)
    +		L.Push(lua.LBool(false))
    +		return 1
    +	}
    +
     	// Write the file and return true if successful
     	L.Push(lua.LBool(ulf.write(writeFilename, givenPermissions) == nil))
     	return 1 // number of results
    @@ -224,10 +237,22 @@ func uploadedFileSaveIn(L *lua.LState) int {
     
     	// Get the full path
     	var writeFilename string
    +	var baseDir string
     	if filepath.IsAbs(givenDirectory) {
     		writeFilename = filepath.Join(givenDirectory, ulf.filename)
    +		baseDir = givenDirectory
     	} else {
     		writeFilename = filepath.Join(ulf.scriptdir, givenDirectory, ulf.filename)
    +		baseDir = filepath.Join(ulf.scriptdir, givenDirectory)
    +	}
    +
    +	// prevent path traversal
    +	absBase, _ := filepath.Abs(baseDir)
    +	absTarget, _ := filepath.Abs(writeFilename)
    +	if !strings.HasPrefix(absTarget, absBase+string(os.PathSeparator)) && absTarget != absBase {
    +		logrus.Error("path traversal attempt blocked: ", writeFilename)
    +		L.Push(lua.LBool(false))
    +		return 1
     	}
     
     	// Write the file and return true if successful
    
ddb7896eb33a

Fix a Lua-related race condition in issue #172

https://github.com/xyproto/algernonAlexander F. RødsethApr 22, 2026Fixed in 1.17.6via llm-release-walk
4 files changed · +203 15
  • engine/config.go+5 0 modified
    @@ -50,6 +50,7 @@ type Config struct {
     	pongomutex                   *sync.RWMutex       // workaround for rendering pongo2 pages without concurrency issues
     	fs                           *datablock.FileStat // for checking if file exists, possibly in a cached way
     	luapool                      *pool.LStatePool    // a pool of Lua interpreters
    +	handlerPool                  *handlerPool        // a pool of Lua states for handle() requests
     	cache                        *datablock.FileCache
     	reverseProxyConfig           *ReverseProxyConfig
     	redisAddr                    string
    @@ -110,6 +111,7 @@ type Config struct {
     	defaultCacheMaxEntitySize    uint64        // 64 KiB
     	defaultLargeFileSize         uint64        // 42 MiB: the default size for when a static file is large enough to not be read into memory
     	limitRequests                int64         // rate limit to this many requests per client per second
    +	handlerPoolSize              int           // number of Lua states available for handle() request parallelism
     	writeTimeout                 uint64        // timeout when writing data to a client, in seconds
     	defaultStatCacheRefresh      time.Duration // refresh the stat cache, if the stat cache feature is enabled
     	defaultCacheSize             uint64        // 1 MiB
    @@ -167,6 +169,9 @@ func New(versionString, description string) (*Config, error) {
     
     		shutdownTimeout: 10 * time.Second,
     
    +		// Pool size for parallel Lua handle() requests. One state per CPU by default.
    +		handlerPoolSize: runtime.NumCPU(),
    +
     		defaultWebColonPort:       ":3000",
     		defaultRedisColonPort:     ":6379",
     		defaultEventColonPort:     ":5553",
    
  • engine/handlerpool.go+52 0 added
    @@ -0,0 +1,52 @@
    +package engine
    +
    +import (
    +	lua "github.com/xyproto/gopher-lua"
    +)
    +
    +// handlerPool is a bounded pool of Lua states used to serve handle()
    +// requests. Each state holds its own copy of every handle() function in its
    +// Lua registry, keyed by path. A buffered channel provides Get/Put with
    +// natural backpressure: if all states are busy, Get blocks until one is
    +// returned.
    +type handlerPool struct {
    +	ch     chan *lua.LState
    +	states []*lua.LState // kept for Close()
    +}
    +
    +// newHandlerPool creates a pool of the given size. The caller must add
    +// states with Add() until the pool is full.
    +func newHandlerPool(size int) *handlerPool {
    +	if size < 1 {
    +		size = 1
    +	}
    +	return &handlerPool{
    +		ch:     make(chan *lua.LState, size),
    +		states: make([]*lua.LState, 0, size),
    +	}
    +}
    +
    +// Add enqueues a fully prepared state into the pool. Called during pool
    +// construction only, so no lock is needed for the states slice.
    +func (p *handlerPool) Add(L *lua.LState) {
    +	p.states = append(p.states, L)
    +	p.ch <- L
    +}
    +
    +// Get borrows a state from the pool, blocking if all are in use
    +func (p *handlerPool) Get() *lua.LState {
    +	return <-p.ch
    +}
    +
    +// Put returns a state to the pool
    +func (p *handlerPool) Put(L *lua.LState) {
    +	p.ch <- L
    +}
    +
    +// Close shuts down all states. In-flight handlers holding a state are
    +// not tracked; close is best-effort for server shutdown.
    +func (p *handlerPool) Close() {
    +	for _, L := range p.states {
    +		L.Close()
    +	}
    +}
    
  • engine/lua.go+106 1 modified
    @@ -253,7 +253,7 @@ func (ac *Config) RunConfiguration(filename string, mux *http.ServeMux, withHand
     
     	if withHandlerFunctions {
     		// Lua HTTP handlers
    -		ac.LoadLuaHandlerFunctions(L, filename, mux, false, nil, ac.defaultTheme)
    +		ac.LoadLuaHandlerFunctions(L, filename, mux, false, nil, ac.defaultTheme, true)
     	}
     
     	// Run the script
    @@ -268,6 +268,111 @@ func (ac *Config) RunConfiguration(filename string, mux *http.ServeMux, withHand
     	// Only put the Lua state back if there were no errors
     	ac.luapool.Put(L)
     
    +	// Populate a pool of Lua states dedicated to serving handle() requests,
    +	// so that concurrent requests can execute on different states in parallel.
    +	if withHandlerFunctions {
    +		if err := ac.buildHandlerPool(filename, mux); err != nil {
    +			return err
    +		}
    +	}
    +
    +	return nil
    +}
    +
    +// loadServerConfigNoopFunctions binds the server configuration functions as
    +// no-ops. Used while populating the handler pool: the user's script will call
    +// functions like SetAddr or AddReverseProxy during setup, but those effects
    +// have already been applied on the main state and must not run again per
    +// pool state.
    +func (ac *Config) loadServerConfigNoopFunctions(L *lua.LState) {
    +	noop := L.NewFunction(func(_ *lua.LState) int {
    +		return 0
    +	})
    +	noopTrue := L.NewFunction(func(L *lua.LState) int {
    +		L.Push(lua.LBool(true))
    +		return 1
    +	})
    +	cookieSecret := L.NewFunction(func(L *lua.LState) int {
    +		L.Push(lua.LString(ac.cookieSecret))
    +		return 1
    +	})
    +	serverInfo := L.NewFunction(func(L *lua.LState) int {
    +		L.Push(lua.LString(ac.Info()))
    +		return 1
    +	})
    +	for _, name := range []string{
    +		"SetAddr", "SetCookieSecret", "ClearPermissions",
    +		"AddUserPrefix", "AddAdminPrefix", "AddReverseProxy",
    +		"DenyHandler", "OnReady",
    +	} {
    +		L.SetGlobal(name, noop)
    +	}
    +	for _, name := range []string{"LogTo", "ServerFile", "ServerDir"} {
    +		L.SetGlobal(name, noopTrue)
    +	}
    +	L.SetGlobal("CookieSecret", cookieSecret)
    +	L.SetGlobal("ServerInfo", serverInfo)
    +}
    +
    +// loadPoolStateFunctions binds every function the user's script needs to run
    +// inside a pool state. Request-scoped functions (LoadCommonFunctions) are
    +// rebound on every request, so here we only cover what the setup phase uses.
    +func (ac *Config) loadPoolStateFunctions(L *lua.LState, filename string, mux *http.ServeMux) {
    +	ac.LoadBasicSystemFunctions(L)
    +	ac.loadServerConfigNoopFunctions(L)
    +
    +	if ac.perm != nil {
    +		userstate := ac.perm.UserState()
    +		creator := userstate.Creator()
    +		datastruct.LoadList(L, creator)
    +		datastruct.LoadSet(L, creator)
    +		datastruct.LoadHash(L, creator)
    +		datastruct.LoadKeyValue(L, creator)
    +		codelib.Load(L, creator)
    +		pquery.Load(L)
    +		mssql.Load(L)
    +	}
    +
    +	jnode.LoadJSONFunctions(L)
    +	ac.LoadJFile(L, filepath.Dir(filename))
    +	jnode.Load(L)
    +	pure.Load(L)
    +	ac.LoadPluginFunctions(L, nil)
    +	ac.LoadCacheFunctions(L)
    +	onthefly.Load(L)
    +	ollama.Load(L)
    +	httpclient.Load(L, ac.serverHeaderName)
    +
    +	ac.LoadLuaHandlerFunctions(L, filename, mux, false, nil, ac.defaultTheme, false)
    +}
    +
    +// buildHandlerPool creates ac.handlerPoolSize fresh Lua states, runs the
    +// script in each, and enqueues them in ac.handlerPool. Every state ends up
    +// with its own copy of each handle() function stored in its Lua registry.
    +func (ac *Config) buildHandlerPool(filename string, mux *http.ServeMux) error {
    +	size := ac.handlerPoolSize
    +	if size < 1 {
    +		size = 1
    +	}
    +	pool := newHandlerPool(size)
    +	for i := 0; i < size; i++ {
    +		L := lua.NewState()
    +		ac.loadPoolStateFunctions(L, filename, mux)
    +		if err := L.DoFile(filename); err != nil {
    +			L.Close()
    +			if len(pool.states) == 0 {
    +				// Couldn't build any usable pool state
    +				return err
    +			}
    +			logrus.Errorf("handler pool state %d failed to initialise: %v", i, err)
    +			continue
    +		}
    +		pool.Add(L)
    +	}
    +	ac.handlerPool = pool
    +	AtShutdown(func() {
    +		pool.Close()
    +	})
     	return nil
     }
     
    
  • engine/luahandler.go+40 14 modified
    @@ -3,35 +3,56 @@ package engine
     import (
     	"net/http"
     	"path/filepath"
    -	"sync"
     
     	"github.com/didip/tollbooth"
     	"github.com/sirupsen/logrus"
     	"github.com/xyproto/algernon/themes"
     	lua "github.com/xyproto/gopher-lua"
     )
     
    -// LoadLuaHandlerFunctions makes functions related to handling HTTP requests
    -// available to Lua scripts
    -func (ac *Config) LoadLuaHandlerFunctions(L *lua.LState, filename string, mux *http.ServeMux, addDomain bool, httpStatus *FutureStatus, theme string) {
    -	luahandlermutex := &sync.RWMutex{}
    +// handleRegistryPrefix keys a handler function in a Lua state's registry
    +const handleRegistryPrefix = "algernon:handle:"
     
    +// LoadLuaHandlerFunctions makes functions related to handling HTTP requests
    +// available to Lua scripts.
    +//
    +// When registerRoutes is true, handle() registers the route on the mux. That
    +// wrapped handler borrows a state from ac.handlerPool at request time, looks
    +// up the handler function from the state's registry by path, and runs it.
    +// When registerRoutes is false, handle() only stores the function in the
    +// state's registry; this is the mode used while populating the pool.
    +func (ac *Config) LoadLuaHandlerFunctions(L *lua.LState, filename string, mux *http.ServeMux, addDomain bool, httpStatus *FutureStatus, theme string, registerRoutes bool) {
     	L.SetGlobal("handle", L.NewFunction(func(L *lua.LState) int {
     		handlePath := L.ToString(1)
     		handleFunc := L.ToFunction(2)
     
    -		// TODO: Set up a channel and function for retrieving a lua "handleFunc" and running it,
    -		//       using the common luapool as needed
    +		// Store the function in this state's registry, keyed by path,
    +		// so the request-time wrapper can fetch it from whichever pool
    +		// state it happens to borrow.
    +		L.G.Registry.RawSetString(handleRegistryPrefix+handlePath, handleFunc)
    +
    +		if !registerRoutes {
    +			return 0 // number of results
    +		}
     
     		wrappedHandleFunc := func(w http.ResponseWriter, req *http.Request) {
    -			// Set up a new Lua state with the current http.ResponseWriter and *http.Request
    -			luahandlermutex.Lock()
    -			ac.LoadCommonFunctions(w, req, filename, L, nil, httpStatus)
    -			luahandlermutex.Unlock()
    +			if ac.handlerPool == nil {
    +				logrus.Error("Handler for " + handlePath + " called before the handler pool was built")
    +				return
    +			}
    +			poolL := ac.handlerPool.Get()
    +			defer ac.handlerPool.Put(poolL)
    +
    +			fn := poolL.G.Registry.RawGetString(handleRegistryPrefix + handlePath)
    +			handlerFn, ok := fn.(*lua.LFunction)
    +			if !ok {
    +				logrus.Error("Handler for " + handlePath + " is missing from the pool state")
    +				return
    +			}
     
    -			// Then run the given Lua function
    -			L.Push(handleFunc)
    -			if err := L.PCall(0, lua.MultRet, nil); err != nil {
    +			ac.LoadCommonFunctions(w, req, filename, poolL, nil, httpStatus)
    +			poolL.Push(handlerFn)
    +			if err := poolL.PCall(0, lua.MultRet, nil); err != nil {
     				// Non-fatal error
     				logrus.Error("Handler for "+handlePath+" failed:", err)
     			}
    @@ -56,6 +77,11 @@ func (ac *Config) LoadLuaHandlerFunctions(L *lua.LState, filename string, mux *h
     	}))
     
     	L.SetGlobal("servedir", L.NewFunction(func(L *lua.LState) int {
    +		// servedir only has an effect during the first pass; subsequent
    +		// passes (pool build) must not re-register mux routes.
    +		if !registerRoutes {
    +			return 0 // number of results
    +		}
     		handlePath := L.ToString(1) // serve as (ie. "/")
     		rootdir := L.ToString(2)    // filesystem directory (ie. "./public")
     		if handlePath == "" || rootdir == "" {
    

Vulnerability mechanics

Root cause

"The sync.RWMutex protecting LoadCommonFunctions is released before L.Push() and L.PCall() execute, allowing concurrent goroutines to race on the non-goroutine-safe LState."

Attack vector

An attacker sends concurrent HTTP requests to an Algernon server that uses Lua handlers. Because the mutex is released before `L.Push()` and `L.PCall()` complete, multiple goroutines simultaneously operate on the same `LState` object [ref_id=1]. Since gopher-lua's `LState` is explicitly not goroutine-safe, this race corrupts the Lua VM state [ref_id=1]. The Go race detector confirms the issue under modest concurrency (`ab -n 1000 -c 100`) [ref_id=1]. No authentication is required; the attack is purely network-based.

Affected code

The vulnerability is in `engine/luahandler.go` [ref_id=1]. The `sync.RWMutex` protecting `LoadCommonFunctions` is released before `L.Push()` and `L.PCall()` execute, leaving the shared `LState` exposed to concurrent access [ref_id=1].

What the fix does

The advisory states the fix is in version 1.17.6 [ref_id=1]. No patch diff is provided in the bundle, but the fix logically must extend the mutex lock to cover the full critical section — holding the lock across `L.Push()` and `L.PCall()` so that no two goroutines can access the shared `LState` concurrently [ref_id=1]. The advisory does not specify any workarounds [ref_id=1].

Preconditions

  • configAlgernon server must be running with Lua handler functionality enabled
  • networkAttacker must be able to send multiple concurrent HTTP requests to the server
  • authNo authentication required

Generated on May 26, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.