Skip to content

Commit 3d328a8

Browse files
authored
Merge pull request #7 from flightphp/json-by-default
added json by default functionality for security
2 parents 50ee1d7 + e2a3bea commit 3d328a8

File tree

2 files changed

+340
-38
lines changed

2 files changed

+340
-38
lines changed

src/Session.php

Lines changed: 92 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class Session implements SessionHandlerInterface
2121
private bool $autoCommit = true;
2222
private bool $testMode = false;
2323
private bool $inRegenerate = false;
24+
private ?string $serialization = null; // 'json' (default) or 'php'
2425

2526
/**
2627
* Constructor to initialize the session handler.
@@ -41,6 +42,11 @@ public function __construct(array $config = [])
4142
$this->autoCommit = $config['auto_commit'] ?? true;
4243
$startSession = $config['start_session'] ?? true;
4344
$this->testMode = $config['test_mode'] ?? false;
45+
$this->serialization = $config['serialization'] ?? 'json'; // 'json' (default) or 'php'
46+
47+
if (!in_array($this->serialization, ['json', 'php'], true)) {
48+
throw new \InvalidArgumentException("Invalid serialization method: {$this->serialization}. Use 'json' or 'php'.");
49+
}
4450

4551
// Set test session ID if provided
4652
if ($this->testMode === true && isset($config['test_session_id'])) {
@@ -138,47 +144,60 @@ public function read($id): string
138144
$this->sessionId = $id;
139145
$file = $this->getSessionFile($id);
140146

141-
// Fail fast: no file exists
142-
if (file_exists($file) === false) {
147+
if (file_exists($file) !== true) {
143148
$this->data = [];
144-
return ''; // Return empty string for new sessions
149+
return '';
145150
}
146151

147-
// Fail fast: unable to read file or empty content
148152
$content = file_get_contents($file);
149153
if ($content === false || strlen($content) < 1) {
150154
$this->data = [];
151155
return '';
152156
}
153157

154-
// Extract prefix and data
155158
$prefix = $content[0];
156159
$dataStr = substr($content, 1);
157160

158-
// Handle plain data (no encryption)
159-
if ($prefix === 'P' && $this->encryptionKey === null) {
160-
$unserialized = unserialize($dataStr);
161-
if ($unserialized !== false) {
162-
$this->data = $unserialized;
163-
return ''; // Return empty string to let PHP handle serialization
164-
}
165-
}
166-
167-
// Handle encrypted data
168-
if ($prefix === 'E' && $this->encryptionKey !== null) {
161+
if ($this->serialization === 'json') {
162+
if ($prefix === 'J' && $this->encryptionKey === null) {
163+
$decoded = json_decode($dataStr, true);
164+
if (json_last_error() === JSON_ERROR_NONE) {
165+
$this->data = $decoded;
166+
return '';
167+
}
168+
} elseif ($prefix === 'F' && $this->encryptionKey !== null) {
169169
$iv = substr($dataStr, 0, 16);
170170
$encrypted = substr($dataStr, 16);
171171
$decrypted = openssl_decrypt($encrypted, 'AES-256-CBC', $this->encryptionKey, 0, $iv);
172-
173-
if ($decrypted !== false) {
174-
$unserialized = unserialize($decrypted);
172+
if ($decrypted !== false) {
173+
$decoded = json_decode($decrypted, true);
174+
if (json_last_error() === JSON_ERROR_NONE) {
175+
$this->data = $decoded;
176+
return '';
177+
}
178+
}
179+
}
180+
} elseif ($this->serialization === 'php') {
181+
if ($prefix === 'P' && $this->encryptionKey === null) {
182+
$unserialized = unserialize($dataStr);
175183
if ($unserialized !== false) {
176184
$this->data = $unserialized;
177185
return '';
178186
}
187+
} elseif ($prefix === 'E' && $this->encryptionKey !== null) {
188+
$iv = substr($dataStr, 0, 16);
189+
$encrypted = substr($dataStr, 16);
190+
$decrypted = openssl_decrypt($encrypted, 'AES-256-CBC', $this->encryptionKey, 0, $iv);
191+
if ($decrypted !== false) {
192+
$unserialized = unserialize($decrypted);
193+
if ($unserialized !== false) {
194+
$this->data = $unserialized;
195+
return '';
196+
}
197+
}
179198
}
180199
}
181-
// Fail fast: mismatch between prefix and encryption state or corruption
200+
// Fail fast: mismatch or corruption
182201
$this->data = [];
183202
return '';
184203
}
@@ -207,32 +226,46 @@ protected function encryptData(string $data)
207226
*/
208227
public function write($id, $data): bool
209228
{
210-
// When PHP calls this method, it passes serialized data
211-
// We ignore this parameter because we maintain our data internally
212-
// and handle serialization ourselves
213-
214-
// Fail fast: no changes to write
215-
if ($this->changed === false && empty($this->data) === false) {
229+
if ($this->changed !== true && !empty($this->data)) {
216230
return true;
217231
}
218232

219233
$file = $this->getSessionFile($id);
220-
$serialized = serialize($this->data);
221-
222-
// Handle encryption if key is provided
223-
if ($this->encryptionKey !== null) {
224-
$content = $this->encryptData($serialized);
225234

226-
// Fail fast: encryption failed
227-
if ($content === false) {
228-
return false;
235+
if ($this->serialization === 'json') {
236+
if (!empty($this->data)) {
237+
$this->assertNoObjects($this->data);
238+
}
239+
$serialized = json_encode($this->data);
240+
if ($serialized === false) {
241+
return false; // @codeCoverageIgnore
242+
}
243+
if ($this->encryptionKey !== null) {
244+
$iv = openssl_random_pseudo_bytes(16);
245+
$encrypted = openssl_encrypt($serialized, 'AES-256-CBC', $this->encryptionKey, 0, $iv);
246+
if ($encrypted === false) {
247+
return false; // @codeCoverageIgnore
248+
}
249+
$content = 'F' . $iv . $encrypted;
250+
} else {
251+
$content = 'J' . $serialized;
252+
}
253+
} elseif ($this->serialization === 'php') {
254+
$serialized = serialize($this->data);
255+
if ($this->encryptionKey !== null) {
256+
$content = $this->encryptData($serialized); // returns 'E' . $iv . $encrypted
257+
if ($content === false) {
258+
return false; // @codeCoverageIgnore
259+
}
260+
} else {
261+
$content = 'P' . $serialized;
229262
}
230263
} else {
231-
$content = 'P' . $serialized;
264+
// Should never happen
265+
return false; // @codeCoverageIgnore
232266
}
233267

234-
// Write to file and return success
235-
return file_put_contents($file, $content) !== false;
268+
return file_put_contents($file, $content) !== false; // @codeCoverageIgnore
236269
}
237270

238271
/**
@@ -430,4 +463,26 @@ private function getSessionFile(string $id): string
430463
{
431464
return $this->savePath . '/' . $this->prefix . $id;
432465
}
466+
467+
/**
468+
* Recursively check for objects in data (for JSON safety).
469+
* Throws exception if object is found.
470+
* @param mixed $data
471+
* @throws \InvalidArgumentException
472+
*/
473+
private function assertNoObjects($data): void
474+
{
475+
// Iterative stack to avoid recursion
476+
$stack = [$data];
477+
while ($stack) {
478+
$current = array_pop($stack);
479+
foreach ($current as $v) {
480+
if (is_object($v) === true) {
481+
throw new \InvalidArgumentException('Session data contains an object, which cannot be safely stored with JSON serialization.');
482+
} elseif (is_array($v) === true) {
483+
$stack[] = $v;
484+
}
485+
}
486+
}
487+
}
433488
}

0 commit comments

Comments
 (0)