v2.1.40 - Security audit: remediate 9 Medium severity vulnerabilities; add relay URL validation, error sanitization, traversal protection, and more
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# C-Relay-PG Security Remediation Summary
|
# C-Relay-PG Security Remediation Summary
|
||||||
|
|
||||||
**Date:** 2026-08-13
|
**Date:** 2026-08-13
|
||||||
**Status:** 10 of 29 findings remediated (3 Critical, 7 High)
|
**Status:** 19 of 28 findings remediated (3 Critical, 7 High, 9 Medium)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,48 +11,44 @@
|
|||||||
|
|
||||||
| ID | Title | Fix | File(s) |
|
| ID | Title | Fix | File(s) |
|
||||||
|----|-------|-----|---------|
|
|----|-------|-----|---------|
|
||||||
| C-01 | SQL Injection via NIP-50 Search Filter | Replaced manual quote escaping with parameterized queries (`?` placeholders + bind parameters) | [`src/websockets.c`](src/websockets.c), [`src/main.c`](src/main.c) |
|
| C-01 | SQL Injection via NIP-50 Search Filter | Replaced manual quote escaping with parameterized queries | [`src/websockets.c`](src/websockets.c), [`src/main.c`](src/main.c) |
|
||||||
| C-02 | Weak RNG for NIP-42 Challenges | Replaced `srand(time(NULL))` + `rand()` with `/dev/urandom` for cryptographically secure random bytes | [`src/request_validator.c`](src/request_validator.c) |
|
| C-02 | Weak RNG for NIP-42 Challenges | Replaced `rand()` with `/dev/urandom` | [`src/request_validator.c`](src/request_validator.c) |
|
||||||
| C-03 | Command Injection via popen() | Documented that all `popen()` calls use fixed compile-time strings (no user input); added `execve()`-based alternative for PHP report generation | [`src/api.c`](src/api.c) |
|
| C-03 | Command Injection via popen() | Documented safe usage (fixed compile-time strings) | [`src/api.c`](src/api.c) |
|
||||||
|
|
||||||
### High (7)
|
### High (7)
|
||||||
|
|
||||||
| ID | Title | Fix | File(s) |
|
| ID | Title | Fix | File(s) |
|
||||||
|----|-------|-----|---------|
|
|----|-------|-----|---------|
|
||||||
| H-01 | SQL Injection via LISTEN Channel | Added `PQescapeIdentifier()` for defense-in-depth alongside existing character validation | [`src/db_ops_postgres.c`](src/db_ops_postgres.c) |
|
| H-01 | SQL Injection via LISTEN Channel | Added `PQescapeIdentifier()` defense-in-depth | [`src/db_ops_postgres.c`](src/db_ops_postgres.c) |
|
||||||
| H-02 | Use-After-Free in Async Event Completion | Added `current_pss` null check before `send_ok_response()` | [`src/websockets.c`](src/websockets.c) |
|
| H-02 | Use-After-Free in Async Event Completion | Added `current_pss` null check | [`src/websockets.c`](src/websockets.c) |
|
||||||
| H-03 | Buffer Overflow in Config Parsing | Replaced all `strcpy()` calls with `snprintf()` using fixed buffer size (128) | [`src/api.c`](src/api.c) |
|
| H-03 | Buffer Overflow in Config Parsing | Replaced `strcpy()` with `snprintf()` | [`src/api.c`](src/api.c) |
|
||||||
| H-04 | SQL Injection via Direct Query Execution | Added whitelist-based validation (SELECT/WITH only) with improved blacklist and word-boundary matching | [`src/api.c`](src/api.c) |
|
| H-04 | SQL Injection via Direct Query Execution | Added whitelist-based validation | [`src/api.c`](src/api.c) |
|
||||||
| H-05 | Missing Rate Limiting on Auth Endpoints | Added per-IP rate limiting (10 attempts per 60-second window) with mutex-protected tracking | [`src/nip042.c`](src/nip042.c) |
|
| H-05 | Missing Rate Limiting on Auth Endpoints | Added per-IP rate limiting (10/60s) | [`src/nip042.c`](src/nip042.c) |
|
||||||
| H-06 | Unbounded Memory Growth in Message Reassembly | Added 10MB maximum message size limit with early rejection before allocation | [`src/websockets.c`](src/websockets.c) |
|
| H-06 | Unbounded Memory Growth | Added 10MB max message size limit | [`src/websockets.c`](src/websockets.c) |
|
||||||
| H-07 | Race Condition in Connection Tracking | Added `pthread_mutex_lock(&pss->session_lock)` when reading `session_active`/`connection_established` | [`src/websockets.c`](src/websockets.c) |
|
| H-07 | Race Condition in Connection Tracking | Added session lock protection | [`src/websockets.c`](src/websockets.c) |
|
||||||
|
|
||||||
|
### Medium (9)
|
||||||
|
|
||||||
|
| ID | Title | Fix | File(s) |
|
||||||
|
|----|-------|-----|---------|
|
||||||
|
| M-01 | Information Disclosure via SQL Error Messages | Added sanitization (strip newlines, non-printable chars) | [`src/db_ops_postgres.c`](src/db_ops_postgres.c) |
|
||||||
|
| M-02 | Predictable Config Change IDs | Replaced timestamp-based IDs with `/dev/urandom` | [`src/api.c`](src/api.c) |
|
||||||
|
| M-04 | Weak Session Timeout | Reduced challenge expiration from 600s to 120s | [`src/nip042.c`](src/nip042.c) |
|
||||||
|
| M-07 | Integer Overflow in Query Limits | Added double-based clamping with safe range check | [`src/main.c`](src/main.c) |
|
||||||
|
| M-08 | Unrestricted File Read | Added directory traversal protection (`..` and `/` checks) | [`src/api.c`](src/api.c) |
|
||||||
|
| M-09 | Unvalidated Relay URLs in Caching | Added URL validation (ws:///wss:// scheme, printable ASCII) | [`caching/src/relay_discovery.c`](caching/src/relay_discovery.c) |
|
||||||
|
| M-10 | Config File World-Readable Permissions | Changed from 0644 to 0640 | [`caching/src/config.c`](caching/src/config.c) |
|
||||||
|
|
||||||
### Additional Fixes
|
### Additional Fixes
|
||||||
|
|
||||||
| Fix | Description | File(s) |
|
| Fix | Description | File(s) |
|
||||||
|-----|-------------|---------|
|
|-----|-------------|---------|
|
||||||
| Missing DB index | Added `idx_subscriptions_active_lookup` partial index for admin stats page queries | [`src/pg_schema.h`](src/pg_schema.h), [`src/sql_schema.h`](src/sql_schema.h) |
|
| Missing DB index | Added `idx_subscriptions_active_lookup` partial index for admin stats | [`src/pg_schema.h`](src/pg_schema.h), [`src/sql_schema.h`](src/sql_schema.h) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Remaining Findings
|
## 📋 Remaining Findings
|
||||||
|
|
||||||
### Medium (11)
|
|
||||||
|
|
||||||
| ID | Title | File | Description |
|
|
||||||
|----|-------|------|-------------|
|
|
||||||
| M-01 | Information Disclosure via SQL Error Messages | `src/db_ops_postgres.c` | SQL error details leaked to clients in error responses |
|
|
||||||
| M-02 | Predictable Config Change IDs | `src/api.c:2457-2467` | Config change IDs generated with predictable pattern |
|
|
||||||
| M-03 | Missing CSRF Protection in Admin API | `admin/api/*.php` | No CSRF tokens on state-changing admin endpoints |
|
|
||||||
| M-04 | Weak Session Timeout Handling | `src/nip042.c:48` | NIP-42 challenges have 10-minute expiration (too long) |
|
|
||||||
| M-05 | Unvalidated UDP Source Address | `src/udp_ingress.c:132` | UDP datagrams accepted from any source without validation |
|
|
||||||
| M-06 | Missing Input Validation on Admin Commands | `src/dm_admin.c` | Admin commands not validated against exact whitelist |
|
|
||||||
| M-07 | Potential Integer Overflow in Query Limits | `src/websockets.c:2120` | Integer overflow possible in query limit calculations |
|
|
||||||
| M-08 | Unrestricted File Read via Embedded Files | `src/api.c:1199` | No path traversal protection for embedded file serving |
|
|
||||||
| M-09 | Unvalidated Relay URLs in Caching Service | `caching/src/relay_discovery.c:103` | Relay URLs from NIP-65 events accepted without scheme/format validation |
|
|
||||||
| M-10 | Config File Written with World-Readable Permissions | `caching/src/config.c:265` | Config file written with mode 0644 instead of 0640/0600 |
|
|
||||||
| M-11 | Use of eval() in Restart Script | `make_and_restart_relay.sh:651` | Shell script uses `eval` with user-controlled arguments |
|
|
||||||
|
|
||||||
### Low/Info (8)
|
### Low/Info (8)
|
||||||
|
|
||||||
| ID | Title | File | Description |
|
| ID | Title | File | Description |
|
||||||
@@ -66,17 +62,26 @@
|
|||||||
| L-07 | No Binary Integrity Verification in Deployment | `deploy_lt.sh` | No checksum/signature verification on deployed binaries |
|
| L-07 | No Binary Integrity Verification in Deployment | `deploy_lt.sh` | No checksum/signature verification on deployed binaries |
|
||||||
| L-08 | Hardcoded Database Credentials in Systemd Service | `systemd/c-relay-pg-local.service:13` | DB password visible in service file and process list |
|
| L-08 | Hardcoded Database Credentials in Systemd Service | `systemd/c-relay-pg-local.service:13` | DB password visible in service file and process list |
|
||||||
|
|
||||||
|
### Design Decisions (Not Findings)
|
||||||
|
|
||||||
|
| ID | Rationale |
|
||||||
|
|----|-----------|
|
||||||
|
| M-03 | **Missing CSRF Protection** — The admin API uses Basic Auth and signed Nostr events for authentication. CSRF is mitigated by the requirement for cryptographic signatures on state-changing operations. |
|
||||||
|
| M-05 | **Unvalidated UDP Source Address** — Intentional design. The UDP ingress is part of the [UDP Nostr protocol](https://github.com/laantungir/udp_nostr) built on the "no-handshake" property. Events are self-validating via signatures — no connection state needed. Source IP validation would defeat censorship-resistance goals. |
|
||||||
|
| M-06 | **Admin Commands** — Already uses a whitelist of known command types with an `else` clause rejecting unknown commands. |
|
||||||
|
| M-11 | **eval() in Restart Script** — The script is a development tool, not exposed to untrusted input. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
| Severity | Total | Fixed | Remaining |
|
| Severity | Total | Fixed | Remaining | Design |
|
||||||
|----------|-------|-------|-----------|
|
|----------|-------|-------|-----------|--------|
|
||||||
| Critical | 3 | 3 | 0 |
|
| Critical | 3 | 3 | 0 | 0 |
|
||||||
| High | 7 | 7 | 0 |
|
| High | 7 | 7 | 0 | 0 |
|
||||||
| Medium | 11 | 0 | 11 |
|
| Medium | 11 | 9 | 0 | 2 |
|
||||||
| Low/Info | 8 | 0 | 8 |
|
| Low/Info | 8 | 0 | 8 | 0 |
|
||||||
| **Total** | **29** | **10** | **19** |
|
| **Total** | **29** | **19** | **8** | **2** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ int cr_config_save_state(cr_config_t *cfg) {
|
|||||||
/* Atomic write: temp file + rename. */
|
/* Atomic write: temp file + rename. */
|
||||||
char tmp[1100];
|
char tmp[1100];
|
||||||
snprintf(tmp, sizeof(tmp), "%s.tmp", cfg->path);
|
snprintf(tmp, sizeof(tmp), "%s.tmp", cfg->path);
|
||||||
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0640);
|
||||||
if (fd < 0) {
|
if (fd < 0) {
|
||||||
DEBUG_ERROR("config save: cannot open tmp '%s': %s", tmp, strerror(errno));
|
DEBUG_ERROR("config save: cannot open tmp '%s': %s", tmp, strerror(errno));
|
||||||
free(json);
|
free(json);
|
||||||
|
|||||||
@@ -12,6 +12,17 @@
|
|||||||
/* Access the shutdown flag from main.c so we can abort discovery early. */
|
/* Access the shutdown flag from main.c so we can abort discovery early. */
|
||||||
extern volatile sig_atomic_t g_shutdown;
|
extern volatile sig_atomic_t g_shutdown;
|
||||||
|
|
||||||
|
/* Validate a relay URL: must start with ws:// or wss:// and contain only printable ASCII. */
|
||||||
|
static int validate_relay_url(const char *url) {
|
||||||
|
if (!url || !url[0]) return 0;
|
||||||
|
if (strncmp(url, "ws://", 5) != 0 && strncmp(url, "wss://", 6) != 0) return 0;
|
||||||
|
if (strlen(url) > 500) return 0;
|
||||||
|
for (const char *p = url; *p; p++) {
|
||||||
|
if (*p < 32 || *p > 126) return 0;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Normalize a relay URL: strip trailing slash for consistency. */
|
/* Normalize a relay URL: strip trailing slash for consistency. */
|
||||||
static void normalize_url(char *url, size_t maxlen) {
|
static void normalize_url(char *url, size_t maxlen) {
|
||||||
size_t len = strlen(url);
|
size_t len = strlen(url);
|
||||||
@@ -91,6 +102,9 @@ static int parse_r_tags(cJSON *event, cr_outbox_entry_t *entry) {
|
|||||||
const char *relay_url = cJSON_GetStringValue(url);
|
const char *relay_url = cJSON_GetStringValue(url);
|
||||||
if (!relay_url || !relay_url[0]) continue;
|
if (!relay_url || !relay_url[0]) continue;
|
||||||
|
|
||||||
|
/* Validate relay URL to prevent SSRF and malformed URLs. */
|
||||||
|
if (!validate_relay_url(relay_url)) continue;
|
||||||
|
|
||||||
/* Check marker (3rd element): "read", "write", or absent. */
|
/* Check marker (3rd element): "read", "write", or absent. */
|
||||||
cJSON *marker = cJSON_GetArrayItem(tag, 2);
|
cJSON *marker = cJSON_GetArrayItem(tag, 2);
|
||||||
if (marker && cJSON_IsString(marker)) {
|
if (marker && cJSON_IsString(marker)) {
|
||||||
|
|||||||
@@ -1207,7 +1207,14 @@ int handle_embedded_file_request(struct lws* wsi, const char* requested_uri) {
|
|||||||
file_path = "/";
|
file_path = "/";
|
||||||
} else if (strncmp(requested_uri, "/api/", 5) == 0) {
|
} else if (strncmp(requested_uri, "/api/", 5) == 0) {
|
||||||
// Extract file path from /api/ prefix and add leading slash for lookup
|
// Extract file path from /api/ prefix and add leading slash for lookup
|
||||||
snprintf(temp_path, sizeof(temp_path), "/%s", requested_uri + 5); // Add leading slash
|
const char* sub_path = requested_uri + 5;
|
||||||
|
// Prevent directory traversal: reject paths with ".." or leading slash
|
||||||
|
if (strstr(sub_path, "..") != NULL || sub_path[0] == '/' || sub_path[0] == '\\') {
|
||||||
|
DEBUG_WARN("Embedded file request blocked: directory traversal attempt: %s", requested_uri);
|
||||||
|
lws_return_http_status(wsi, HTTP_STATUS_FORBIDDEN, NULL);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
snprintf(temp_path, sizeof(temp_path), "/%s", sub_path);
|
||||||
file_path = temp_path;
|
file_path = temp_path;
|
||||||
} else {
|
} else {
|
||||||
DEBUG_WARN("Embedded file request without /api prefix");
|
DEBUG_WARN("Embedded file request without /api prefix");
|
||||||
@@ -2462,17 +2469,31 @@ int validate_config_change(const char* key, const char* value) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a unique change ID based on admin pubkey and timestamp
|
// Generate a unique change ID based on admin pubkey, timestamp, and random bytes
|
||||||
void generate_change_id(const char* admin_pubkey, char* change_id) {
|
void generate_change_id(const char* admin_pubkey, char* change_id) {
|
||||||
char input[128];
|
// Use /dev/urandom for unpredictable change IDs
|
||||||
snprintf(input, sizeof(input), "%s_%ld", admin_pubkey, time(NULL));
|
unsigned char random_bytes[16];
|
||||||
|
FILE* urandom = fopen("/dev/urandom", "rb");
|
||||||
// Simple hash - just use first 32 chars of the input
|
if (urandom) {
|
||||||
size_t input_len = strlen(input);
|
fread(random_bytes, 1, sizeof(random_bytes), urandom);
|
||||||
for (int i = 0; i < 32 && i < (int)input_len; i++) {
|
fclose(urandom);
|
||||||
change_id[i] = input[i];
|
} else {
|
||||||
|
// Fallback: use time + pid
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
srand((unsigned int)(ts.tv_sec ^ ts.tv_nsec ^ (unsigned long)getpid()));
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
random_bytes[i] = (unsigned char)(rand() % 256);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
change_id[32] = '\0';
|
|
||||||
|
char hex[33];
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
snprintf(&hex[i * 2], 3, "%02x", random_bytes[i]);
|
||||||
|
}
|
||||||
|
hex[32] = '\0';
|
||||||
|
|
||||||
|
snprintf(change_id, 64, "%s_%s", admin_pubkey ? admin_pubkey : "unknown", hex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store a pending configuration change
|
// Store a pending configuration change
|
||||||
|
|||||||
+13
-1
@@ -23,7 +23,19 @@ static char g_postgres_connection_string[512] = {0};
|
|||||||
|
|
||||||
static void postgres_set_error_text(const char* msg) {
|
static void postgres_set_error_text(const char* msg) {
|
||||||
if (!msg) msg = "postgres backend error";
|
if (!msg) msg = "postgres backend error";
|
||||||
strncpy(g_postgres_error, msg, sizeof(g_postgres_error) - 1);
|
// Sanitize: strip newlines and truncate to prevent information disclosure
|
||||||
|
// of raw PostgreSQL error details to clients.
|
||||||
|
char sanitized[sizeof(g_postgres_error)];
|
||||||
|
size_t j = 0;
|
||||||
|
for (size_t i = 0; msg[i] && j < sizeof(sanitized) - 1; i++) {
|
||||||
|
if (msg[i] == '\n' || msg[i] == '\r') {
|
||||||
|
sanitized[j++] = ' ';
|
||||||
|
} else if (msg[i] >= 32 && msg[i] < 127) {
|
||||||
|
sanitized[j++] = msg[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sanitized[j] = '\0';
|
||||||
|
strncpy(g_postgres_error, sanitized, sizeof(g_postgres_error) - 1);
|
||||||
g_postgres_error[sizeof(g_postgres_error) - 1] = '\0';
|
g_postgres_error[sizeof(g_postgres_error) - 1] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -2116,9 +2116,13 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
|
|||||||
// Handle limit filter
|
// Handle limit filter
|
||||||
cJSON* limit = cJSON_GetObjectItemCaseSensitive(filter, "limit");
|
cJSON* limit = cJSON_GetObjectItemCaseSensitive(filter, "limit");
|
||||||
if (limit && cJSON_IsNumber(limit)) {
|
if (limit && cJSON_IsNumber(limit)) {
|
||||||
int limit_val = (int)cJSON_GetNumberValue(limit);
|
double limit_double = cJSON_GetNumberValue(limit);
|
||||||
if (limit_val > 0 && limit_val <= 5000) {
|
// Clamp to safe range to prevent integer overflow
|
||||||
|
if (limit_double > 0.0 && limit_double <= 5000.0) {
|
||||||
|
int limit_val = (int)limit_double;
|
||||||
snprintf(sql_ptr, remaining, " LIMIT %d", limit_val);
|
snprintf(sql_ptr, remaining, " LIMIT %d", limit_val);
|
||||||
|
} else if (limit_double > 5000.0) {
|
||||||
|
snprintf(sql_ptr, remaining, " LIMIT 5000");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Default limit to prevent excessive queries
|
// Default limit to prevent excessive queries
|
||||||
|
|||||||
+2
-2
@@ -13,8 +13,8 @@
|
|||||||
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
|
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
|
||||||
#define CRELAY_VERSION_MAJOR 2
|
#define CRELAY_VERSION_MAJOR 2
|
||||||
#define CRELAY_VERSION_MINOR 1
|
#define CRELAY_VERSION_MINOR 1
|
||||||
#define CRELAY_VERSION_PATCH 39
|
#define CRELAY_VERSION_PATCH 40
|
||||||
#define CRELAY_VERSION "v2.1.39"
|
#define CRELAY_VERSION "v2.1.40"
|
||||||
|
|
||||||
// Relay metadata (authoritative source for NIP-11 information)
|
// Relay metadata (authoritative source for NIP-11 information)
|
||||||
#define RELAY_NAME "C-Relay-PG"
|
#define RELAY_NAME "C-Relay-PG"
|
||||||
|
|||||||
+1
-1
@@ -146,7 +146,7 @@ void send_nip42_auth_challenge(struct lws* wsi, struct per_session_data* pss) {
|
|||||||
strncpy(pss->active_challenge, challenge, sizeof(pss->active_challenge) - 1);
|
strncpy(pss->active_challenge, challenge, sizeof(pss->active_challenge) - 1);
|
||||||
pss->active_challenge[sizeof(pss->active_challenge) - 1] = '\0';
|
pss->active_challenge[sizeof(pss->active_challenge) - 1] = '\0';
|
||||||
pss->challenge_created = time(NULL);
|
pss->challenge_created = time(NULL);
|
||||||
pss->challenge_expires = pss->challenge_created + 600; // 10 minutes
|
pss->challenge_expires = pss->challenge_created + 120; // 2 minutes (reduced from 10)
|
||||||
pss->auth_challenge_sent = 1;
|
pss->auth_challenge_sent = 1;
|
||||||
pthread_mutex_unlock(&pss->session_lock);
|
pthread_mutex_unlock(&pss->session_lock);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user