Skip to content

Commit 3d0292d

Browse files
Merge pull request #39 from niteeshkanna-sh/claude/stoic-rubin-fnglq7
Say why the sign-in page reports "Session expired"
2 parents a9e3cdf + bccb777 commit 3d0292d

2 files changed

Lines changed: 214 additions & 2 deletions

File tree

‎admin/install.php‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,24 @@ function install(array $form): array
216216
return [];
217217
}
218218

219+
/**
220+
* Did the visitor arrive over HTTPS?
221+
*
222+
* Behind a proxy that terminates TLS -- which is how this host works -- PHP
223+
* sees a plain HTTP request and $_SERVER['HTTPS'] is unset, so asking PHP
224+
* alone gives the wrong answer for every visitor on the secure site.
225+
*/
226+
function request_is_https(): bool
227+
{
228+
if ((($_SERVER['HTTPS'] ?? '') !== '') && $_SERVER['HTTPS'] !== 'off') {
229+
return true;
230+
}
231+
if (strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https') {
232+
return true;
233+
}
234+
return strtolower((string) ($_SERVER['REQUEST_SCHEME'] ?? '')) === 'https';
235+
}
236+
219237
function build_config(array $form): array
220238
{
221239
$origins = array_values(array_filter(array_map(
@@ -237,8 +255,17 @@ function build_config(array $form): array
237255
'public_site_origin' => $origins,
238256
// Hostinger serves these domains over HTTPS, and the session cookie
239257
// carries the sign-in, so it should never travel in the clear.
240-
'https_only' => (($_SERVER['HTTPS'] ?? '') !== '' && $_SERVER['HTTPS'] !== 'off'),
241-
'session_minutes' => 120,
258+
// $_SERVER['HTTPS'] alone is not enough on this host. Hostinger
259+
// terminates TLS upstream, so PHP can be handed a plain HTTP request
260+
// for a visitor who arrived over HTTPS -- the site's own .htaccess
261+
// says exactly this about %{HTTPS} and redirects on X-Forwarded-Proto
262+
// instead. Getting it wrong here decides whether the session cookie
263+
// carries the Secure flag, so it is worth reading both.
264+
'https_only' => request_is_https(),
265+
// The panel reads session_idle_minutes. This wrote session_minutes, so
266+
// the value was ignored and a hardcoded fallback was doing the work --
267+
// which means changing it here had no effect at all.
268+
'session_idle_minutes' => 120,
242269
];
243270
}
244271

‎admin/session-check.php‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Why the sign-in page says "Session expired".
6+
*
7+
* That message means the CSRF check failed, and the CSRF check fails when the
8+
* token stored in the session on one request is not there on the next. Which
9+
* is almost never about tokens: it is the session itself not surviving between
10+
* the page loading and the form being submitted. From the outside those look
11+
* identical, and there are four quite different reasons for it.
12+
*
13+
* So this does exactly what signing in does -- puts a token in the session,
14+
* renders it in a form, and checks it when the form comes back -- and reports
15+
* what actually happened, with the settings that decide it.
16+
*
17+
* It changes nothing and reads no credentials. It does print server paths and
18+
* cookie settings, which are not secrets but are nobody's business either, so
19+
* delete it once the panel works. It is the same bargain as install.php.
20+
*/
21+
22+
require_once __DIR__ . '/src/csrf.php';
23+
24+
header('X-Robots-Tag: noindex, nofollow');
25+
header('X-Frame-Options: DENY');
26+
header('Content-Type: text/html; charset=utf-8');
27+
28+
$cookieName = 'nitesha_admin';
29+
$sentCookie = isset($_COOKIE[$cookieName]);
30+
31+
session_start_secure();
32+
33+
$posted = $_SERVER['REQUEST_METHOD'] === 'POST';
34+
$expected = $_SESSION['check_token'] ?? '';
35+
$given = (string) ($_POST['check_token'] ?? '');
36+
37+
// The one combination that silently eats every session: a cookie the browser
38+
// is told to keep only for HTTPS, handed out over HTTP. Worked out before the
39+
// verdict, because it outranks it -- browsers exempt localhost from the Secure
40+
// rule, so a test on a local copy can round-trip happily while the same
41+
// settings lose every session on a real domain. A page that reports success
42+
// and names a fault in the same breath is worse than one that reports neither.
43+
$cookieTrap = false;
44+
45+
$verdict = null;
46+
if ($posted) {
47+
if ($expected === '') {
48+
$verdict = ['bad', 'The session was empty when the form came back.',
49+
'The token was stored, the browser was told to keep a cookie, and on this '
50+
. 'request there was no session to read it from. That is the same failure as '
51+
. '"Session expired" on the sign-in page, so whatever is below is the cause.'];
52+
} elseif (hash_equals($expected, $given)) {
53+
$verdict = ['ok', 'The session survived the round trip.',
54+
'Sessions are working, so "Session expired" on the sign-in page is not what '
55+
. 'it says. Try signing in again -- if it still fails, the password is being '
56+
. 'rejected rather than the session, and the panel would say so.'];
57+
} else {
58+
$verdict = ['bad', 'A session came back, but holding a different token.',
59+
'Two sessions are in play -- usually the page and the form being on different '
60+
. 'addresses, such as one with www and one without.'];
61+
}
62+
}
63+
64+
// A fresh token every time, so a reload is always a real test rather than a
65+
// replay of the last one.
66+
$_SESSION['check_token'] = bin2hex(random_bytes(16));
67+
68+
$params = session_get_cookie_params();
69+
$savePath = session_save_path() ?: sys_get_temp_dir();
70+
71+
/** Did the visitor arrive over HTTPS, as opposed to what PHP was handed? */
72+
$phpHttps = (($_SERVER['HTTPS'] ?? '') !== '') && $_SERVER['HTTPS'] !== 'off';
73+
$forwarded = strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''));
74+
$browserHttps = $phpHttps || $forwarded === 'https';
75+
76+
$cookieTrap = ((bool) $params['secure']) && !$browserHttps;
77+
78+
if ($cookieTrap) {
79+
// Overrides whatever the round trip appeared to show. On a real domain
80+
// this configuration loses every session; if the test just passed, it
81+
// passed because of a local exemption and not because this works.
82+
$verdict = ['bad', 'The session cookie cannot come back over this connection.',
83+
'The cookie is marked Secure and this page is not on HTTPS, so the browser '
84+
. 'takes it and then refuses to send it again. That is what "Session expired" '
85+
. 'is reporting.'];
86+
}
87+
88+
$rows = [
89+
['The browser sent a session cookie', $sentCookie,
90+
$sentCookie ? 'Yes' : 'No — either this is the first visit, or the browser is refusing to keep it'],
91+
['Session storage is writable', is_writable($savePath),
92+
$savePath . (is_writable($savePath) ? '' : ' — PHP cannot save sessions here, so nothing persists')],
93+
['Cookie marked Secure', (bool) $params['secure'],
94+
$params['secure']
95+
? 'Yes — the browser will only send it over HTTPS'
96+
: 'No — it will be sent over plain HTTP too'],
97+
['Visitor is on HTTPS', $browserHttps,
98+
$browserHttps ? 'Yes' : 'No'],
99+
];
100+
101+
$e = static fn (?string $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
102+
?>
103+
<!doctype html>
104+
<html lang="en">
105+
<head>
106+
<meta charset="utf-8">
107+
<meta name="viewport" content="width=device-width, initial-scale=1">
108+
<title>Session check</title>
109+
<style>
110+
body{margin:0;padding:28px 16px;background:#FAF7F2;color:#1A2233;
111+
font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
112+
main{max-width:680px;margin:0 auto}
113+
h1{font-size:1.35rem;margin:0 0 4px}
114+
.sub{color:#5C5348;margin:0 0 20px}
115+
.card{background:#fff;border:1px solid #E7E0D6;border-radius:14px;padding:18px;margin-bottom:14px}
116+
.verdict{font-weight:700;margin:0 0 6px}
117+
.ok{color:#2F6B4F}.bad{color:#B3261E}
118+
table{border-collapse:collapse;width:100%;margin-top:6px;font-size:.92rem}
119+
td{padding:7px 4px;border-bottom:1px solid #E7E0D6;vertical-align:top}
120+
td:first-child{width:46%}
121+
.mark{font-weight:800;width:22px}
122+
.note{color:#5C5348;font-size:.88rem}
123+
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.82rem;
124+
background:#FAF7F2;padding:2px 5px;border-radius:5px;word-break:break-all}
125+
button{background:#E8A317;color:#1A2233;border:0;font:inherit;font-weight:700;
126+
padding:11px 20px;border-radius:10px;cursor:pointer}
127+
.trap{background:#FFF4E0;border:1px solid #E8A317;border-radius:10px;padding:12px;margin-top:12px}
128+
</style>
129+
</head>
130+
<body>
131+
<main>
132+
<h1>Session check</h1>
133+
<p class="sub">Does a session survive from one request to the next? That is all "Session expired" is about.</p>
134+
135+
<?php if ($verdict !== null): ?>
136+
<div class="card">
137+
<p class="verdict <?= $verdict[0] === 'ok' ? 'ok' : 'bad' ?>"><?= $e($verdict[1]) ?></p>
138+
<p class="note"><?= $e($verdict[2]) ?></p>
139+
</div>
140+
<?php endif; ?>
141+
142+
<div class="card">
143+
<form method="post">
144+
<input type="hidden" name="check_token" value="<?= $e($_SESSION['check_token']) ?>">
145+
<p style="margin:0 0 12px">Press this. It stores a token in the session and checks it comes back — exactly what signing in does.</p>
146+
<button type="submit">Run the test</button>
147+
</form>
148+
</div>
149+
150+
<div class="card">
151+
<table>
152+
<?php foreach ($rows as [$label, $ok, $detail]): ?>
153+
<tr>
154+
<td class="mark <?= $ok ? 'ok' : 'bad' ?>"><?= $ok ? '&check;' : '&times;' ?></td>
155+
<td><?= $e($label) ?></td>
156+
<td class="note"><?= $e($detail) ?></td>
157+
</tr>
158+
<?php endforeach; ?>
159+
</table>
160+
161+
<?php if ($cookieTrap): ?>
162+
<div class="trap">
163+
<strong>This is the fault.</strong> The session cookie is marked Secure, but this page
164+
was not served over HTTPS — so the browser accepts the cookie and then refuses to send
165+
it back, and every session is lost the instant it is created. Reach the panel over
166+
<code>https://</code>, or set <code>'https_only' =&gt; false</code> in config.php.
167+
</div>
168+
<?php endif; ?>
169+
</div>
170+
171+
<div class="card note">
172+
<p style="margin:0 0 8px"><strong>What the server says:</strong></p>
173+
<p style="margin:0 0 4px">Session name: <code><?= $e(session_name()) ?></code>,
174+
path <code><?= $e($params['path']) ?></code>,
175+
SameSite <code><?= $e($params['samesite'] ?: 'not set') ?></code></p>
176+
<p style="margin:0 0 4px"><code>$_SERVER['HTTPS']</code>: <code><?= $e(($_SERVER['HTTPS'] ?? '') === '' ? 'not set' : $_SERVER['HTTPS']) ?></code></p>
177+
<p style="margin:0 0 4px"><code>X-Forwarded-Proto</code>: <code><?= $e($forwarded === '' ? 'not set' : $forwarded) ?></code></p>
178+
<p style="margin:0 0 4px"><code>https_only</code> in config.php: <code><?= config('https_only') ? 'true' : 'false' ?></code></p>
179+
<p style="margin:0">Address in use: <code><?= $e(($_SERVER['HTTP_HOST'] ?? '') . ($_SERVER['REQUEST_URI'] ?? '')) ?></code></p>
180+
</div>
181+
182+
<p class="note">Delete this file once the panel works.</p>
183+
</main>
184+
</body>
185+
</html>

0 commit comments

Comments
 (0)