litespeedtech / litespeedtech/lscache_wp
Race condition in Optimizer::serve() causes PHP warnings when concurrent requests generate the same CSS file
Nobody has claimed this yet.
- Dominant language
- PHP
- Stars
- 257
- Forks
- 123
- PR merge metrics
- No merged PRs in 30d
Description
## Description
`Optimizer::serve()` in `src/optimizer.cls.php` contains a TOCTOU (time-of-check/time-of-use) race condition that produces PHP warnings under concurrent load when two requests attempt to generate the same combined/minified CSS file simultaneously.
## Affected version
7.8.1
## User-facing impact
When the race fires, `md5_file()` returns `false`, causing `serve()` to return `['.css', 'css']` (a filename with no hash). This has two consequences:
1. **Unstyled page for the affected visitor** — the browser receives `` which 404s, causing all combined styles to fail to load.
2. **Broken URL persists in the database** — `Data::save_url()` is called with `$filecon_md5 = false` and stores the broken filename. Subsequent visitors hitting the same URL may also receive the broken stylesheet reference until the LiteSpeed cache is manually purged.
## Error messages observed in WP_DEBUG_LOG
```
PHP Warning: md5_file(/path/to/wp-content/litespeed/css/abc123.css.tmp): Failed to open stream: No such file or directory in .../litespeed-cache/src/optimizer.cls.php on line 148
PHP Warning: rename(/path/to/wp-content/litespeed/css/abc123.css.tmp, /path/to/wp-content/litespeed/css/.css): No such file or directory in .../litespeed-cache/src/optimizer.cls.php on line 153
```
## Root cause
The guard at lines 109–114 is intended to prevent two concurrent requests from generating the same file:
```php
$tmp_static_file = $static_file . '.tmp';
if (file_exists($tmp_static_file) && time() - filemtime($tmp_static_file) <= 600) {
// some other request is generating
return false;
}
File::save($tmp_static_file, '', true);
```
However, `file_exists()` and `File::save()` are two separate non-atomic operations. Two concurrent requests for the same URL can both pass the `file_exists()` check before either has created the file:
1. **Request A**: `file_exists(tmp)` → `false` → passes guard
2. **Request B**: `file_exists(tmp)` → `false` → passes guard (both slip through simultaneously)
3. **Request A**: `File::save(tmp, '')` → creates tmp file
4. **Request B**: `File::save(tmp, '')` → overwrites with empty content
5. **Request A**: writes CSS content, minifies, calls `md5_file(tmp)` → gets hash, calls `rename(tmp, finalfile)` → **tmp file is now gone**
6. **Request B**: `md5_file(tmp)` → ⚠️ `false` — No such file or directory (line 148)
7. **Request B**: `rename(tmp, '.css')` → ⚠️ fails — No such file or directory (line 153)
Note: `File::save()` uses `file_put_contents(..., LOCK_EX)` internally, but `LOCK_EX` is an advisory flock — it does not block `rename()`, which operates on directory entries and ignores advisory locks entirely. The lock does not protect against this race.
## Steps to reproduce
The race window is narrow (between `rename()` in one worker and `md5_file()` in another), but the following PHP script confirms the bug using `pcntl_fork()` to synchronise two processes to the exact vulnerable moment:
```php
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/optimizer.cls.php at the temporary-file guard around lines 109–114 and the md5_file()/rename() calls around lines 148–153. Use the provided pcntl_fork() reproducer to confirm concurrent requests cannot remove each other’s temporary file. Done means concurrent generation avoids PHP warnings, broken .css URLs, and invalid Data::save_url() entries.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- php
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 58/100