Skip to content

Conversation

@BuilderFred
Copy link

Fixes Scottcjn/rustchain-bounties#3.

This PR implements the following security hardenings for the hardware attestation endpoint:

  • Replay Protection: Nonces are now strictly consumed upon a successful or expired attestation submission.
  • Rate Limiting: Added in-memory rate limiting based on IP and Miner Wallet to prevent brute-force and spam attacks.
  • Anti-Spoofing: Fully integrated Model Context Protocol (MCP) fingerprint validation to detect and zero-reward VM/Emulator-based mining attempts.
  • Database Stability: Fixed schema bugs (missing columns in blocked_wallets and hardware_bindings) that caused 500 errors during attestation.

Copy link
Owner

@Scottcjn Scottcjn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Attestation Endpoint Hardening (PR #3)

@BuilderFred - Good security additions. The rate limiting and nonce validation are real improvements. Some issues to address:

Issues

1. Nonce validation references non-existent table

row = c.execute("SELECT expires_at FROM nonces WHERE nonce = ?", (nonce,)).fetchone()

There's no CREATE TABLE IF NOT EXISTS nonces in the init_db() changes. The nonces table doesn't exist in the schema — you added tickets but the code references nonces. This will crash at runtime.

2. Breaking change: nonce now required

if not nonce:
    return jsonify({"error": "missing_nonce"}), 401

Existing miners don't submit pre-fetched nonces from a server challenge. They generate their own nonce (timestamp-based) in the attestation payload. This change would reject ALL current miners immediately. You need backward compatibility:

  • Accept miner-generated nonces (current behavior) as a fallback
  • Only enforce server-issued nonces when the miner includes a challenge token

3. Rate limit memory leak
ATTEST_RATE_LIMIT dict grows unbounded. Old entries are reset when accessed, but if a key is never accessed again it stays forever. Add periodic cleanup:

# In check_rate_limit, periodically purge expired entries
if len(ATTEST_RATE_LIMIT) > 10000:
    now = time.time()
    ATTEST_RATE_LIMIT = {k: v for k, v in ATTEST_RATE_LIMIT.items() if v[1] > now}

4. Schema changes need migration path
You changed epoch_state and balances schemas (added columns). CREATE TABLE IF NOT EXISTS won't add columns to existing tables. Need ALTER TABLE fallbacks for existing databases:

try:
    c.execute("ALTER TABLE epoch_state ADD COLUMN settled INTEGER DEFAULT 0")
except: pass  # Column already exists

5. IP rate limiting with X-Forwarded-For

ip = request.headers.get("X-Forwarded-For", request.remote_addr)

X-Forwarded-For is client-controlled when there's no trusted proxy. An attacker can rotate this header to bypass IP rate limiting. Should parse only the rightmost trusted proxy entry, or use request.remote_addr when there's no trusted reverse proxy list.

What's Good

  • Rate limiting on both IP and miner ID is the right approach
  • Nonce-based replay prevention is the right direction
  • New security tables (blocked_wallets, hardware_bindings, miner_macs, etc.) are useful
  • Code is clean and well-structured

Fix the nonces table creation, backward compatibility with existing miners, and the memory leak. Those are the blockers.

Also: your node at 27.145.146.131:8099 is unreachable (timed out on HTTP, HTTPS, and alternate ports). Is the firewall configured?

@Scottcjn
Copy link
Owner

Scottcjn commented Feb 2, 2026

@BuilderFred Here's the full list of fixes needed before this can merge. Address all of these and push updated commits:

1. Create the nonces table

Your code references SELECT expires_at FROM nonces but the table is never created. Add to init_db():

c.execute("CREATE TABLE IF NOT EXISTS nonces (nonce TEXT PRIMARY KEY, expires_at INTEGER)")

2. Don't break existing miners

Your nonce validation rejects attestations without server-issued nonces:

if not nonce:
    return jsonify({"error": "missing_nonce"}), 401

Current miners generate their own nonces (timestamp-based). This would brick the entire network. Fix:

  • Accept miner-generated nonces as fallback (check timestamp freshness ±60s)
  • Only enforce server-issued nonces when a challenge token is present
  • Log a warning for miners not using challenge-response, don't reject them

3. Fix rate limiter memory leak

ATTEST_RATE_LIMIT grows forever. Add cleanup:

def check_rate_limit(key: str) -> bool:
    now = time.time()
    # Periodic cleanup
    if len(ATTEST_RATE_LIMIT) > 10000:
        expired = [k for k, (_, reset_ts) in ATTEST_RATE_LIMIT.items() if now > reset_ts]
        for k in expired:
            del ATTEST_RATE_LIMIT[k]
    # ... rest of function

4. Schema migration for existing databases

CREATE TABLE IF NOT EXISTS won't add new columns to existing tables. Add fallback ALTERs:

for col, default in [("settled", "0"), ("settled_ts", "NULL")]:
    try:
        c.execute(f"ALTER TABLE epoch_state ADD COLUMN {col} INTEGER DEFAULT {default}")
    except:
        pass

for col, default in [("amount_i64", "0")]:
    try:
        c.execute(f"ALTER TABLE balances ADD COLUMN {col} INTEGER DEFAULT {default}")
    except:
        pass

5. Fix X-Forwarded-For spoofing

# Don't trust X-Forwarded-For blindly - attacker can set it to bypass rate limits
# Use remote_addr unless behind a known reverse proxy
ip = request.remote_addr
# Only use XFF if behind nginx (check for trusted proxy)
if request.remote_addr in ('127.0.0.1', '::1'):
    ip = request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr

6. Your node is unreachable

27.145.146.131:8099 timed out on HTTP, HTTPS, and port 8088. Check:

  • Is the service actually running? (systemctl status rustchain)
  • Is the firewall allowing port 8099? (sudo ufw status or iptables -L)
  • Is it bound to 0.0.0.0 not just 127.0.0.1?

Fix all 5 code issues, push, and I'll re-review. The security additions are good in concept, just need these fixes to work in production.

@Scottcjn
Copy link
Owner

Scottcjn commented Feb 2, 2026

@BuilderFred Here's the full checklist of fixes needed before this can merge. Address all of these and push updated commits:

Fix List

1. Create the nonces table (BLOCKER)

Your code references SELECT expires_at FROM nonces WHERE nonce = ? but the table doesn't exist. You added tickets to init_db() but not nonces. Add:

c.execute("""CREATE TABLE IF NOT EXISTS nonces (
    nonce TEXT PRIMARY KEY,
    miner TEXT NOT NULL,
    issued_at INTEGER NOT NULL,
    expires_at INTEGER NOT NULL,
    used INTEGER DEFAULT 0
)""")

2. Backward compatibility for nonces (BLOCKER)

Current miners generate their own nonces (timestamp-based). Your code rejects any attestation without a server-issued nonce. This would brick every active miner. Fix:

# Accept both server-issued and miner-generated nonces
if nonce:
    row = c.execute("SELECT expires_at FROM nonces WHERE nonce = ?", (nonce,)).fetchone()
    if row:
        # Server-issued nonce - validate expiry
        if row[0] < time.time():
            return jsonify({"error": "nonce_expired"}), 401
    # else: miner-generated nonce - accept (legacy compat)

3. Rate limiter memory leak

ATTEST_RATE_LIMIT dict grows forever. Add periodic cleanup:

def check_rate_limit(key, max_requests=10, window=60):
    now = time.time()
    # Periodic cleanup when dict gets large
    if len(ATTEST_RATE_LIMIT) > 10000:
        expired = [k for k, v in ATTEST_RATE_LIMIT.items() if v[1] < now - window]
        for k in expired:
            del ATTEST_RATE_LIMIT[k]
    # ... rest of rate limit logic

4. Schema migration for existing databases

CREATE TABLE IF NOT EXISTS won't add new columns to existing tables. Wrap column additions in try/except:

for col, default in [("settled", 0), ("settlement_hash", "''")]:
    try:
        c.execute(f"ALTER TABLE epoch_state ADD COLUMN {col} DEFAULT {default}")
    except Exception:
        pass  # Column already exists

5. Fix X-Forwarded-For spoofing

Use request.remote_addr unless behind a known reverse proxy. If behind nginx, parse only the rightmost entry:

# Only trust X-Forwarded-For if behind known proxy
if request.remote_addr in ('127.0.0.1', '::1'):
    ip = request.headers.get("X-Forwarded-For", request.remote_addr).split(",")[-1].strip()
else:
    ip = request.remote_addr

6. Your node is unreachable

27.145.146.131:8099 timed out on HTTP, HTTPS, and all tested ports. Check your firewall rules. A third attestation node is valuable but it needs to be reachable.


Fix items 1-5 and push. Item 6 is a separate concern but important for the network.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BOUNTY] Harden attestation endpoint against replay and spoofing

2 participants