security: v2.0 — AES-GCM crypto, SSRF guards, rate limits, hardening
Breaking wire-format change: dual-read (V1+V2) / single-write (V2) so existing user secrets, stored messages, and inter-tenant key keep working. Old clients and relays lose cross-version chat until both sides are upgraded. Relay - Switch clientEncrypt/masterEncrypt to AES-256-GCM with HKDF-derived per-ciphertext salts; keep CBC-V1 fallback on decrypt only. - On-bootstrap migration re-envelopes tenant secret_encrypted and the inter-tenant key to GCM-V2 (idempotent). - CORS: stop reflecting arbitrary Origin; allow own origin + optional RELAY_CORS_ORIGINS env allowlist; drop Access-Control-Allow-Credentials. - Generic error messages replace "no such user: <name>" enumeration. - Exception messages no longer echoed back; logged server-side. - Collab-lock max hold capped at 10min so a member can't grief-refresh forever. - Attachment filenames validated against hex.<ext> pattern. - Passwords min length 10; /api/account/password rate-limited. App / Electron client - Shared hyprcrdRelayEncrypt/Decrypt helpers (GCM-V2 write, V1 read). - Chat + collab-buckets plugins delegate to the shared helpers. - Poll + push + attachments migrated to the dual-format path. - /api/relay/settings GET returns has_key boolean; secret never echoed. - Setup endpoint now CSRF-checked. - SSRF: CURLOPT_PROTOCOLS restricted to HTTP(S) everywhere; bookmarks favicon fetch also blocks private/loopback/link-local. - Login / password-change rate limiter moved to DB-backed IP keying. - Passwords min length 10 throughout. - Zip import enforces per-file (25MB) and total (500MB) caps, extension allowlist, no-overwrite. - Verbose relay-push debug gated behind APP_DEBUG env. Version bumped to 2.0.0 (protocol-breaking). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d50f9fe789
commit
d83de30d6d
27 files changed
+473
-168
No files matched your search
+132
-67
@@ -55,6 +55,22 @@ if (str_starts_with($route, '/api/auth')) {
|
||||
error_reporting(0);
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// IP-keyed rate limiter for auth endpoints — session cookies are trivial
|
||||
// to rotate, so the in-session counter alone is bypassable.
|
||||
$hyprcrdClientIp = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$db->exec("DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-10 minutes')");
|
||||
$hyprcrdIpRateLimited = function (string $endpoint, int $max, int $windowMinutes = 5) use ($db, $hyprcrdClientIp): bool {
|
||||
if ($hyprcrdClientIp === '') return false;
|
||||
$q = $db->prepare("SELECT COUNT(*) FROM login_attempts WHERE ip = :ip AND endpoint = :ep AND attempted_at > datetime('now', '-{$windowMinutes} minutes')");
|
||||
$q->execute(['ip' => $hyprcrdClientIp, 'ep' => $endpoint]);
|
||||
return (int)$q->fetchColumn() >= $max;
|
||||
};
|
||||
$hyprcrdRecordAttempt = function (string $endpoint) use ($db, $hyprcrdClientIp): void {
|
||||
if ($hyprcrdClientIp === '') return;
|
||||
$db->prepare('INSERT INTO login_attempts (ip, endpoint) VALUES (:ip, :ep)')
|
||||
->execute(['ip' => $hyprcrdClientIp, 'ep' => $endpoint]);
|
||||
};
|
||||
|
||||
// GET /api/auth/status
|
||||
if ($route === '/api/auth/status' && $method === 'GET') {
|
||||
echo json_encode(['setup' => $isSetup, 'logged_in' => $isLoggedIn, 'csrf' => $_SESSION['csrf_token'], 'version' => APP_VERSION]);
|
||||
@@ -63,11 +79,20 @@ if (str_starts_with($route, '/api/auth')) {
|
||||
|
||||
// POST /api/auth/setup — set initial password
|
||||
if ($route === '/api/auth/setup' && $method === 'POST' && $isSetup) {
|
||||
// CSRF: same-origin + same-session guarantee. A drive-by POST from
|
||||
// another site won't have the csrf cookie-bound token, so it can't
|
||||
// silently claim a freshly-provisioned instance.
|
||||
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfHeader)) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'invalid csrf token']);
|
||||
exit;
|
||||
}
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$pw = $input['password'] ?? '';
|
||||
if (strlen($pw) < 4) {
|
||||
if (strlen($pw) < 10) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'password too short (min 4)']);
|
||||
echo json_encode(['error' => 'password too short (min 10)']);
|
||||
exit;
|
||||
}
|
||||
$hash = password_hash($pw, PASSWORD_BCRYPT);
|
||||
@@ -79,13 +104,11 @@ if (str_starts_with($route, '/api/auth')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// POST /api/auth/login (rate limited)
|
||||
// POST /api/auth/login (IP-keyed rate limit)
|
||||
if ($route === '/api/auth/login' && $method === 'POST') {
|
||||
$attempts = $_SESSION['login_attempts'] ?? 0;
|
||||
$lastAttempt = $_SESSION['login_last'] ?? 0;
|
||||
if ($attempts >= 5 && time() - $lastAttempt < 60) {
|
||||
if ($hyprcrdIpRateLimited('login', 10, 5)) {
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'too many attempts, wait 60s']);
|
||||
echo json_encode(['error' => 'too many attempts, try again later']);
|
||||
exit;
|
||||
}
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
@@ -94,11 +117,9 @@ if (str_starts_with($route, '/api/auth')) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['hyprcrd_auth'] = true;
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
$_SESSION['login_attempts'] = 0;
|
||||
echo json_encode(['ok' => true, 'csrf' => $_SESSION['csrf_token']]);
|
||||
} else {
|
||||
$_SESSION['login_attempts'] = $attempts + 1;
|
||||
$_SESSION['login_last'] = time();
|
||||
$hyprcrdRecordAttempt('login');
|
||||
usleep(300000); // 300ms delay against timing attacks
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'wrong password']);
|
||||
@@ -159,18 +180,24 @@ if (str_starts_with($route, '/api/auth')) {
|
||||
|
||||
// POST /api/auth/password — change password (requires current password)
|
||||
if ($route === '/api/auth/password' && $method === 'POST') {
|
||||
if ($hyprcrdIpRateLimited('password', 10, 5)) {
|
||||
http_response_code(429);
|
||||
echo json_encode(['error' => 'too many attempts, try again later']);
|
||||
exit;
|
||||
}
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$current = $input['current'] ?? '';
|
||||
$newPass = $input['new'] ?? '';
|
||||
$hash = $db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn();
|
||||
if (!password_verify($current, $hash)) {
|
||||
$hyprcrdRecordAttempt('password');
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'wrong password']);
|
||||
exit;
|
||||
}
|
||||
if (strlen($newPass) < 4) {
|
||||
if (strlen($newPass) < 10) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'min 4 characters']);
|
||||
echo json_encode(['error' => 'min 10 characters']);
|
||||
exit;
|
||||
}
|
||||
$newHash = password_hash($newPass, PASSWORD_BCRYPT);
|
||||
@@ -752,21 +779,37 @@ if (str_starts_with($route, '/api/')) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract uploads
|
||||
// Extract uploads. Guard against zip bombs and malicious payloads:
|
||||
// per-file 25MB cap, 500MB total, whitelist allowed extensions (same
|
||||
// set as the live /api/upload endpoint), never overwrite existing
|
||||
// files, never leak paths via traversal.
|
||||
$uploadDir = UPLOAD_DIR;
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true);
|
||||
$allowedExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'mp3', 'ogg', 'wav', 'm4a', 'pdf'];
|
||||
$perFileMax = 25 * 1024 * 1024; // 25 MB
|
||||
$totalMax = 500 * 1024 * 1024; // 500 MB
|
||||
$totalWritten = 0;
|
||||
$skipped = [];
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if (str_starts_with($name, 'uploads/') && $name !== 'uploads/') {
|
||||
$content = $zip->getFromIndex($i);
|
||||
if ($content !== false) {
|
||||
file_put_contents($uploadDir . '/' . basename($name), $content);
|
||||
}
|
||||
}
|
||||
if (!str_starts_with($name, 'uploads/') || $name === 'uploads/') continue;
|
||||
$base = basename($name);
|
||||
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowedExts, true)) { $skipped[] = $base; continue; }
|
||||
$stat = $zip->statIndex($i);
|
||||
if ($stat && isset($stat['size']) && $stat['size'] > $perFileMax) { $skipped[] = $base; continue; }
|
||||
if ($totalWritten + (int)($stat['size'] ?? 0) > $totalMax) { $skipped[] = $base; continue; }
|
||||
$dest = $uploadDir . '/' . $base;
|
||||
if (file_exists($dest)) { $skipped[] = $base; continue; }
|
||||
$content = $zip->getFromIndex($i);
|
||||
if ($content === false) continue;
|
||||
if (strlen($content) > $perFileMax) { $skipped[] = $base; continue; }
|
||||
file_put_contents($dest, $content);
|
||||
$totalWritten += strlen($content);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
echo json_encode(['ok' => true, 'buckets' => count($data['buckets']), 'cards' => count($data['cards'])]);
|
||||
echo json_encode(['ok' => true, 'buckets' => count($data['buckets']), 'cards' => count($data['cards']), 'skipped_files' => count($skipped)]);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -793,18 +836,27 @@ if (str_starts_with($route, '/api/')) {
|
||||
$url = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '';
|
||||
$username = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '';
|
||||
$key = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '';
|
||||
// Single-user local app — echo the stored secret back so the user can
|
||||
// see and edit it directly without guessing if it's still saved.
|
||||
echo json_encode(['url' => $url, 'username' => $username, 'key' => $key]); exit;
|
||||
// Never echo the plaintext secret to the client — if it leaks via XSS
|
||||
// or a compromised session, the attacker can decrypt all relay traffic.
|
||||
// The UI just needs to know whether a secret is set.
|
||||
echo json_encode(['url' => $url, 'username' => $username, 'has_key' => !empty($key)]); exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/relay/settings' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if (isset($input['url'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_url', :v)")->execute(['v' => $input['url']]);
|
||||
if (isset($input['username'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_username', :v)")->execute(['v' => $input['username']]);
|
||||
// Always overwrite key when present (even empty) — single-user local
|
||||
// app, the input is now pre-populated so an empty submit is intentional
|
||||
if (array_key_exists('key', $input)) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $input['key']]);
|
||||
// The UI no longer receives the stored secret, so empty strings are
|
||||
// just "field untouched" and we only write when the user types a new
|
||||
// value. An explicit null clears the secret for users who need it.
|
||||
if (array_key_exists('key', $input)) {
|
||||
$newKey = $input['key'];
|
||||
if ($newKey === null) {
|
||||
$db->prepare("DELETE FROM settings WHERE key = 'relay_key'")->execute();
|
||||
} elseif (is_string($newKey) && $newKey !== '') {
|
||||
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $newKey]);
|
||||
}
|
||||
}
|
||||
echo json_encode(['ok' => true]); exit;
|
||||
}
|
||||
|
||||
@@ -831,6 +883,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
hyprcrdApplySafeCurlOpts($ch);
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
@@ -851,13 +904,15 @@ if (str_starts_with($route, '/api/')) {
|
||||
if (!is_array($messages)) { echo json_encode(['ok' => false, 'error' => 'invalid response']); exit; }
|
||||
|
||||
$pulled = 0;
|
||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
||||
$decryptField = function($text) use ($encKey) {
|
||||
if (!str_starts_with($text, 'HYPRCRD-RELAY-V1')) return $text;
|
||||
$raw = base64_decode(trim(explode("\n", $text, 2)[1] ?? ''));
|
||||
if (strlen($raw) < 17) return $text;
|
||||
$plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
|
||||
return $plain !== false ? $plain : $text;
|
||||
$decryptField = function($text) use ($relayKey) {
|
||||
$decoded = hyprcrdRelayDecrypt($text, $relayKey);
|
||||
// Dual-format decrypt returns '' only when a magic-prefixed
|
||||
// envelope failed to decrypt — surface the original string in
|
||||
// that case so the UI still shows something useful.
|
||||
if ($decoded === '' && (str_starts_with($text, HYPRCRD_RELAY_GCM_MAGIC) || str_starts_with($text, HYPRCRD_RELAY_CBC_MAGIC))) {
|
||||
return $text;
|
||||
}
|
||||
return $decoded;
|
||||
};
|
||||
|
||||
// Load IDs we pushed ourselves
|
||||
@@ -920,6 +975,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
],
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
]);
|
||||
hyprcrdApplySafeCurlOpts($bch);
|
||||
$bRes = curl_exec($bch);
|
||||
$bStat = curl_getinfo($bch, CURLINFO_HTTP_CODE);
|
||||
curl_close($bch);
|
||||
@@ -936,6 +992,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
hyprcrdApplySafeCurlOpts($ach);
|
||||
$attResponse = curl_exec($ach);
|
||||
$attStatus = curl_getinfo($ach, CURLINFO_HTTP_CODE);
|
||||
curl_close($ach);
|
||||
@@ -949,23 +1006,28 @@ if (str_starts_with($route, '/api/')) {
|
||||
if (!preg_match('/^[a-f0-9]+\.\w+$/', $fn)) continue;
|
||||
$dest = $uploadDir . '/' . $fn;
|
||||
if (file_exists($dest)) continue;
|
||||
// New format: HYPRCRD-RELAY-V1 envelope (decrypt-reencrypted by relay)
|
||||
// Old format: raw base64(iv || cipher)
|
||||
// Dual-format: GCM-V2 envelope (v1.10+) and legacy
|
||||
// CBC-V1 envelope; plaintext inside is base64(file
|
||||
// bytes). Very-old pre-envelope format was raw
|
||||
// base64(iv || cipher) — unlikely at this point but
|
||||
// cheap to keep as a last-resort fallback.
|
||||
$payload = $att['data'] ?? '';
|
||||
if (str_starts_with($payload, 'HYPRCRD-RELAY-V1')) {
|
||||
$b64 = trim(substr($payload, strlen('HYPRCRD-RELAY-V1') + 1));
|
||||
$raw = base64_decode($b64);
|
||||
$decrypted = false;
|
||||
if (str_starts_with($payload, HYPRCRD_RELAY_GCM_MAGIC) || str_starts_with($payload, HYPRCRD_RELAY_CBC_MAGIC)) {
|
||||
$plain = hyprcrdRelayDecrypt($payload, $relayKey);
|
||||
if ($plain !== '') $decrypted = $plain;
|
||||
} else {
|
||||
$legacyKey = substr(hash('sha256', $relayKey), 0, 32);
|
||||
$raw = base64_decode($payload);
|
||||
}
|
||||
if (strlen($raw) >= 17) {
|
||||
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
|
||||
if ($decrypted !== false) {
|
||||
$fileBytes = base64_decode($decrypted);
|
||||
if ($fileBytes !== false) $decrypted = $fileBytes;
|
||||
file_put_contents($dest, $decrypted);
|
||||
if ($raw !== false && strlen($raw) >= 17) {
|
||||
$decrypted = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $legacyKey, OPENSSL_RAW_DATA, substr($raw, 0, 16));
|
||||
}
|
||||
}
|
||||
if ($decrypted !== false) {
|
||||
$fileBytes = base64_decode($decrypted);
|
||||
if ($fileBytes !== false) $decrypted = $fileBytes;
|
||||
file_put_contents($dest, $decrypted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -982,6 +1044,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
]);
|
||||
hyprcrdApplySafeCurlOpts($sch);
|
||||
$sResp = curl_exec($sch);
|
||||
$sStatus = curl_getinfo($sch, CURLINFO_HTTP_CODE);
|
||||
curl_close($sch);
|
||||
@@ -1015,6 +1078,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
hyprcrdApplySafeCurlOpts($ch);
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
@@ -1054,12 +1118,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$relayUid, $cardId]);
|
||||
}
|
||||
|
||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
||||
$encryptField = function($text) use ($encKey) {
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($text, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
||||
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
||||
};
|
||||
$encryptField = fn($text) => hyprcrdRelayEncrypt((string)$text, $relayKey);
|
||||
|
||||
$payloadData = [
|
||||
'title' => $encryptField($card['title'] ?? ''),
|
||||
@@ -1072,21 +1131,27 @@ if (str_starts_with($route, '/api/')) {
|
||||
$payload = json_encode($payloadData);
|
||||
|
||||
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
|
||||
$debugStream = fopen('php://temp', 'w+');
|
||||
$debugStream = APP_DEBUG ? fopen('php://temp', 'w+') : null;
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_VERBOSE => true,
|
||||
CURLOPT_STDERR => $debugStream,
|
||||
]);
|
||||
hyprcrdApplySafeCurlOpts($ch);
|
||||
if ($debugStream) {
|
||||
curl_setopt($ch, CURLOPT_VERBOSE, true);
|
||||
curl_setopt($ch, CURLOPT_STDERR, $debugStream);
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
rewind($debugStream);
|
||||
$curlDebug = stream_get_contents($debugStream);
|
||||
fclose($debugStream);
|
||||
$curlDebug = '';
|
||||
if ($debugStream) {
|
||||
rewind($debugStream);
|
||||
$curlDebug = stream_get_contents($debugStream);
|
||||
fclose($debugStream);
|
||||
}
|
||||
curl_close($ch);
|
||||
|
||||
if ($status === 200 || $status === 201) {
|
||||
@@ -1121,6 +1186,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
hyprcrdApplySafeCurlOpts($ach);
|
||||
curl_exec($ach);
|
||||
curl_close($ach);
|
||||
}
|
||||
@@ -1135,16 +1201,15 @@ if (str_starts_with($route, '/api/')) {
|
||||
} elseif (is_string($response) && $response !== '') {
|
||||
$relayMsg = ' — ' . substr($response, 0, 200);
|
||||
}
|
||||
// Strip the X-Relay-Key value from the debug log so we don't leak the secret
|
||||
$safeDebug = preg_replace('/(X-Relay-Key:\s*)[^\r\n]+/i', '$1<redacted>', $curlDebug);
|
||||
echo json_encode([
|
||||
'ok' => false,
|
||||
'error' => "relay returned HTTP $status$relayMsg",
|
||||
'sent_username' => $relayUser,
|
||||
'relay_url' => rtrim($relayUrl, '/') . '/api/messages',
|
||||
'response_body' => is_string($response) ? substr($response, 0, 500) : null,
|
||||
'curl_debug' => $safeDebug,
|
||||
]);
|
||||
// In production, don't leak upstream URL / raw bodies / curl traces —
|
||||
// APP_DEBUG=1 re-enables them for local troubleshooting.
|
||||
$out = ['ok' => false, 'error' => "relay returned HTTP $status$relayMsg", 'sent_username' => $relayUser];
|
||||
if (APP_DEBUG) {
|
||||
$out['relay_url'] = rtrim($relayUrl, '/') . '/api/messages';
|
||||
$out['response_body'] = is_string($response) ? substr($response, 0, 500) : null;
|
||||
$out['curl_debug'] = preg_replace('/(X-Relay-Key:\s*)[^\r\n]+/i', '$1<redacted>', $curlDebug);
|
||||
}
|
||||
echo json_encode($out);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -194,11 +194,11 @@ async function checkAuth() {
|
||||
|
||||
if (data.setup) {
|
||||
let pw = null;
|
||||
while (!pw || pw.length < 4) {
|
||||
pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 4 chars)...', okLabel: 'Set', cancelLabel: '' });
|
||||
if (pw && pw.length < 4) pw = null;
|
||||
while (!pw || pw.length < 10) {
|
||||
pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 10 chars)...', okLabel: 'Set', cancelLabel: '' });
|
||||
if (pw && pw.length < 10) pw = null;
|
||||
}
|
||||
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
|
||||
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': data.csrf || '' }, body: JSON.stringify({ password: pw }) });
|
||||
const d = await r.json();
|
||||
if (d.error) { location.reload(); return; }
|
||||
if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); }
|
||||
|
||||
@@ -321,8 +321,11 @@ export async function loadRelaySettings() {
|
||||
if (urlInput) urlInput.value = s.url || '';
|
||||
if (userInput) userInput.value = s.username || '';
|
||||
if (keyInput) {
|
||||
keyInput.value = s.key || '';
|
||||
keyInput.placeholder = 'shared secret';
|
||||
// The server no longer echoes the stored secret. Leave the field
|
||||
// blank; placeholder tells the user whether one is set. An empty
|
||||
// save is treated as "field untouched" server-side.
|
||||
keyInput.value = '';
|
||||
keyInput.placeholder = s.has_key ? '•••••••• (saved — type to change)' : 'shared secret';
|
||||
}
|
||||
// Sync-interval slider lives in the Relay panel but drives every
|
||||
// poller. Snap points span 5s..1h; live label updates on drag,
|
||||
|
||||
@@ -411,7 +411,7 @@ $('settings-pw-save')?.addEventListener('click', async () => {
|
||||
|
||||
if (!current || !newPw) { if (status) { status.textContent = 'fill all fields'; status.style.color = 'var(--t-danger)'; } return; }
|
||||
if (newPw !== confirm) { if (status) { status.textContent = 'passwords do not match'; status.style.color = 'var(--t-danger)'; } return; }
|
||||
if (newPw.length < 4) { if (status) { status.textContent = 'min 4 characters'; status.style.color = 'var(--t-danger)'; } return; }
|
||||
if (newPw.length < 10) { if (status) { status.textContent = 'min 10 characters'; status.style.color = 'var(--t-danger)'; } return; }
|
||||
|
||||
const res = await fetch('api/auth/password', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -20,6 +20,17 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
echo json_encode(['error' => 'valid url required']);
|
||||
exit;
|
||||
}
|
||||
$scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
|
||||
if ($scheme !== 'http' && $scheme !== 'https') {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'only http/https URLs allowed']);
|
||||
exit;
|
||||
}
|
||||
if (!hyprcrdUrlHostIsPublic($url)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'URL host is not publicly reachable']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
@@ -30,6 +41,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
hyprcrdApplySafeCurlOpts($ch);
|
||||
$html = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$finalUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url;
|
||||
@@ -63,18 +75,21 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
}
|
||||
|
||||
$faviconData = '';
|
||||
$ch2 = curl_init($faviconUrl);
|
||||
curl_setopt_array($ch2, [
|
||||
$faviconScheme = strtolower((string)parse_url($faviconUrl, PHP_URL_SCHEME));
|
||||
$favCh = ($faviconScheme === 'http' || $faviconScheme === 'https') && hyprcrdUrlHostIsPublic($faviconUrl);
|
||||
$ch2 = $favCh ? curl_init($faviconUrl) : null;
|
||||
if ($ch2) curl_setopt_array($ch2, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)',
|
||||
]);
|
||||
$favBytes = curl_exec($ch2);
|
||||
$favStatus = curl_getinfo($ch2, CURLINFO_HTTP_CODE);
|
||||
$favMime = curl_getinfo($ch2, CURLINFO_CONTENT_TYPE) ?: 'image/x-icon';
|
||||
curl_close($ch2);
|
||||
if ($ch2) hyprcrdApplySafeCurlOpts($ch2);
|
||||
$favBytes = $ch2 ? curl_exec($ch2) : false;
|
||||
$favStatus = $ch2 ? curl_getinfo($ch2, CURLINFO_HTTP_CODE) : 0;
|
||||
$favMime = $ch2 ? (curl_getinfo($ch2, CURLINFO_CONTENT_TYPE) ?: 'image/x-icon') : 'image/x-icon';
|
||||
if ($ch2) curl_close($ch2);
|
||||
|
||||
if ($favBytes && $favStatus >= 200 && $favStatus < 400 && strlen($favBytes) <= 32768) {
|
||||
$mime = preg_replace('/;.*$/', '', $favMime);
|
||||
|
||||
@@ -108,24 +108,20 @@ function chatRelaySettings(): array {
|
||||
];
|
||||
}
|
||||
|
||||
/** Same envelope format as the existing relay sync (HYPRCRD-RELAY-V1). */
|
||||
/** Dual-format envelope shared with the relay sync path. Writes V2
|
||||
* (AES-GCM + HKDF), reads V2 + legacy V1 (CBC). Delegates to the central
|
||||
* helpers so the format stays in lock-step with src/bootstrap.php and
|
||||
* the relay's crypto.php. */
|
||||
function chatEncrypt(string $plain, string $relayKey): string {
|
||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
||||
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
||||
return hyprcrdRelayEncrypt($plain, $relayKey);
|
||||
}
|
||||
|
||||
function chatDecrypt(string $envelope, string $relayKey): string {
|
||||
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return '';
|
||||
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
|
||||
$raw = base64_decode($b64);
|
||||
if (!$raw || strlen($raw) < 17) return '';
|
||||
$encKey = substr(hash('sha256', $relayKey), 0, 32);
|
||||
$iv = substr($raw, 0, 16);
|
||||
$cipher = substr($raw, 16);
|
||||
$result = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
||||
return $result !== false ? $result : '';
|
||||
// Old behaviour returned '' for non-envelope strings; preserve that.
|
||||
if (!str_starts_with($envelope, HYPRCRD_RELAY_GCM_MAGIC) && !str_starts_with($envelope, HYPRCRD_RELAY_CBC_MAGIC)) {
|
||||
return '';
|
||||
}
|
||||
return hyprcrdRelayDecrypt($envelope, $relayKey);
|
||||
}
|
||||
|
||||
function chatGetState(string $key, $default = null) {
|
||||
|
||||
@@ -110,32 +110,24 @@
|
||||
");
|
||||
})();
|
||||
|
||||
/** Encrypt a JSON payload with the local relay_key using the same envelope
|
||||
* format the rest of hyprcrd uses (HYPRCRD-RELAY-V1). */
|
||||
/** Encrypt a JSON payload with the local relay_key using the shared
|
||||
* dual-format envelope (GCM-V2 writes, V1 read-only fallback lives in
|
||||
* hyprcrdRelayDecrypt). */
|
||||
function collabEncryptPayload(array $fields): ?string {
|
||||
$key = collabRelaySettings()['key'] ?? '';
|
||||
if ($key === '') return null;
|
||||
$encKey = substr(hash('sha256', $key), 0, 32);
|
||||
$plain = json_encode($fields);
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
||||
if ($cipher === false) return null;
|
||||
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
|
||||
if ($plain === false) return null;
|
||||
return hyprcrdRelayEncrypt($plain, $key);
|
||||
}
|
||||
|
||||
/** Decrypt the inverse. Returns an associative array, or null on failure. */
|
||||
function collabDecryptPayload(string $envelope): ?array {
|
||||
if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return null;
|
||||
$b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
|
||||
$raw = base64_decode($b64);
|
||||
if (!$raw || strlen($raw) < 17) return null;
|
||||
if (!str_starts_with($envelope, HYPRCRD_RELAY_GCM_MAGIC) && !str_starts_with($envelope, HYPRCRD_RELAY_CBC_MAGIC)) return null;
|
||||
$key = collabRelaySettings()['key'] ?? '';
|
||||
if ($key === '') return null;
|
||||
$encKey = substr(hash('sha256', $key), 0, 32);
|
||||
$iv = substr($raw, 0, 16);
|
||||
$cipher = substr($raw, 16);
|
||||
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
|
||||
if ($plain === false) return null;
|
||||
$plain = hyprcrdRelayDecrypt($envelope, $key);
|
||||
if ($plain === '') return null;
|
||||
$data = json_decode($plain, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { describe, it, expect, csrf } from './runner.js?v=4';
|
||||
describe('Relay: Push API', () => {
|
||||
it('push without config returns error', async () => {
|
||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||
// The GET endpoint echoes the secret directly as `key` (single-user local app).
|
||||
if (settings.url && settings.key) return; // skip if configured
|
||||
// GET only reports `has_key` (boolean) — the secret itself is never
|
||||
// echoed. If a relay is configured the real push may succeed or fail
|
||||
// for other reasons, so skip in that case.
|
||||
if (settings.url && settings.has_key) return; // skip if configured
|
||||
|
||||
const res = await fetch('api/relay/push', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
@@ -16,7 +18,7 @@ describe('Relay: Push API', () => {
|
||||
|
||||
it('push without card_id returns error', async () => {
|
||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||
if (!settings.url || !settings.key) return; // card_id check is after config check
|
||||
if (!settings.url || !settings.has_key) return; // card_id check is after config check
|
||||
const res = await fetch('api/relay/push', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({}),
|
||||
@@ -28,7 +30,7 @@ describe('Relay: Push API', () => {
|
||||
describe('Relay: Poll API', () => {
|
||||
it('poll without config returns 0 pulled', async () => {
|
||||
const settings = await fetch('api/relay/settings').then(r => r.json());
|
||||
if (settings.url && settings.key) return;
|
||||
if (settings.url && settings.has_key) return;
|
||||
|
||||
const res = await fetch('api/relay/poll', {
|
||||
method: 'POST', headers: { 'X-CSRF-Token': csrf() },
|
||||
|
||||
Reference in new issue
Block a user