Add proposal to normalize and protect API queries

Add new sections for Pagination Metadata, Date Format Normalization, and Observability Plan to the API query normalization proposal document, and renumber existing sections accordingly.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: d9c34e72-a841-40c0-9e5f-c5e839501957
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Event-Id: 9d148101-339f-436a-bbbe-56da2f911abb
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
bewest
2026-01-19 13:13:13 -08:00
committed by Ben West
parent f09e97449f
commit 191250f376
+811
View File
@@ -0,0 +1,811 @@
# Proposal: API Query Normalization & Protection
**Status:** Draft
**Authors:** Nightscout Community
**Created:** 2026-01-13
**Priority:** HIGH
**Related:** [API Layer Audit](../api-layer-audit.md), [Security Audit](../security-audit.md)
---
## 1. Executive Summary
This proposal addresses critical query handling issues across Nightscout's REST APIs (v1, v2, v3). The core problems are:
1. **Type coercion bugs** - Query params arrive as strings with inconsistent parsing
2. **No input validation** - Malicious or malformed queries reach the database
3. **Self-inflicted DDoS** - Clients can request unbounded data or expensive queries
4. **MongoDB injection** - `req.query.find` passes directly to MongoDB in v1/v2
5. **No pagination metadata** - Responses lack total count, next/prev links
6. **Mixed date formats** - Inconsistent date handling across endpoints
### Approach
- **v1/v2:** Shared normalization middleware for legacy query patterns
- **v3:** Schema validation on already-structured params with same limits
- **Rollout:** Warn mode first, then enforce mode
---
## 2. Problem Statement
### 2.1 Evidence from Codebase
**Inconsistent type parsing:**
```javascript
// profile/index.js - Number() without validation
const limit = req.query && req.query.count ? Number(req.query.count) : consts.PROFILES_DEFAULT_COUNT;
// api3/collection.js - parseInt without default handling
limit = parseInt(req.query.limit);
// pebble.js - parseInt with fallback
req.count = parseInt(req.query.count) || 1;
```
**Direct MongoDB query injection:**
```javascript
// entries/index.js - req.query.find used directly
req.query.find = req.query.find || {};
req.query.find.type = req.params.type;
// Later passed to MongoDB collection.find()
```
**No protection against expensive queries:**
- No maximum count limits enforced
- No date range restrictions
- No query complexity analysis
### 2.2 Real-World Impact
| Issue | Impact | Frequency |
|-------|--------|-----------|
| `count=999999` queries | Memory exhaustion, slow responses | Common |
| Malformed date filters | Query failures, 500 errors | Occasional |
| Unbounded devicestatus fetch | Database overload | Common with AAPS |
| MongoDB operator injection | Potential data exfiltration | Unknown |
---
## 3. Design Goals
1. **Consistent parsing** - All query params parsed identically across API versions
2. **Safe defaults** - Missing/invalid params get sensible defaults, not errors
3. **Bounded queries** - Enforce maximum limits to prevent self-inflicted DDoS
4. **Injection prevention** - Sanitize MongoDB query operators
5. **Auth-aware limits** - Higher limits for authenticated/admin users
6. **Graceful rollout** - Log violations before enforcing, minimize client breakage
7. **Observability** - Track violation patterns to inform limit tuning
---
## 4. Architecture
### 4.1 Shared Library
**Location:** `lib/api/shared/query-normalize.js`
```
┌─────────────────────────────────────────────────────────────┐
│ query-normalize.js │
├─────────────────────────────────────────────────────────────┤
│ Type Coercion │ Sanitization │
│ ───────────── │ ──────────── │
│ • toPositiveInt() │ • sanitizeFindQuery() │
│ • toNonNegativeInt() │ • allowedFields whitelist │
│ • toDateRange() │ • blockedOperators blacklist │
│ • toBoolean() │ • depth limiting │
├─────────────────────────────────────────────────────────────┤
│ Limit Enforcement │ Violation Logging │
│ ───────────────── │ ───────────────── │
│ • applyCollectionLimits() │ • logViolation() │
│ • getAuthTierMultiplier() │ • ViolationTypes enum │
│ • clampToMax() │ • warn vs enforce mode │
└─────────────────────────────────────────────────────────────┘
```
### 4.2 Integration Pattern
**v1/v2 (Middleware):**
```javascript
const { normalizeQuery } = require('../shared/query-normalize');
router.get('/entries', normalizeQuery('entries'), function(req, res) {
// req.query is now normalized and safe
// req.queryViolations contains any violations (for logging)
});
```
**v3 (Schema Validation):**
```javascript
const { validateV3Query, collectionLimits } = require('../shared/query-normalize');
// v3 already has structured params, just add validation
const validated = validateV3Query(req.query, 'entries', authTier);
```
---
## 5. Limit Configuration
### 5.1 Per-Collection Defaults
| Collection | Default Count | Max Count | Max Date Range | Notes |
|------------|---------------|-----------|----------------|-------|
| entries | 10 | 1000 | 30 days | High volume, CGM readings |
| treatments | 10 | 500 | 90 days | Lower volume |
| devicestatus | 10 | 100 | 7 days | Very high volume, AAPS uploads frequently |
| profile | 1 | 50 | N/A | Rarely queried in bulk |
| food | 10 | 500 | N/A | Static reference data |
| activity | 10 | 200 | 30 days | Moderate volume |
### 5.2 Auth Tier Multipliers
| Auth Level | Detection | Count Multiplier | Date Range Multiplier |
|------------|-----------|------------------|----------------------|
| Anonymous | No token/secret | 1x | 1x |
| Authenticated | Valid token with limited perms | 2x | 2x |
| Admin | API_SECRET or `*` permission | 5x | 5x |
**Effective limits example (entries):**
| Auth Level | Max Count | Max Date Range |
|------------|-----------|----------------|
| Anonymous | 1000 | 30 days |
| Authenticated | 2000 | 60 days |
| Admin | 5000 | 150 days |
### 5.3 Auth Tier Derivation
The auth tier is derived from existing authorization middleware. Integration points:
**Location:** `lib/authorization/index.js`
```javascript
function getAuthTier(req) {
// Check if request has been authorized
if (!req.isAuthorized) {
return 'anonymous';
}
// Check for admin/API_SECRET access
// req.authedSubject is populated by authorization middleware
if (req.authedSubject && req.authedSubject.accessToken === 'admin') {
return 'admin';
}
// Check for wildcard permission (full access)
if (req.authedSubject && hasPermission(req.authedSubject, '*')) {
return 'admin';
}
// Valid token with limited permissions
return 'authenticated';
}
```
**Integration with existing auth flow:**
1. Request arrives at API endpoint
2. Existing `authorization.isPermitted()` middleware runs
3. Sets `req.isAuthorized`, `req.authedSubject`, `req.authedRoles`
4. Query normalization middleware calls `getAuthTier(req)`
5. Auth tier determines limit multipliers
**No changes to auth middleware required** - we read existing state only.
### 5.4 Configuration
Limits configurable via environment variables:
```bash
# Override default limits
QUERY_LIMIT_ENTRIES_MAX=2000
QUERY_LIMIT_DEVICESTATUS_MAX=50
# Global multiplier (for constrained deployments)
QUERY_LIMIT_GLOBAL_MULTIPLIER=0.5
# Mode: warn (log only) or enforce (reject/clamp)
QUERY_LIMITS_MODE=warn
```
---
## 6. Query Sanitization
### 6.1 Allowed Fields (Whitelist)
Per-collection whitelists of fields that can appear in `find` queries:
```javascript
const allowedFields = {
entries: ['type', 'date', 'dateString', 'sgv', 'mbg', 'direction', 'device', '_id'],
treatments: ['eventType', 'created_at', 'enteredBy', 'insulin', 'carbs', '_id'],
devicestatus: ['device', 'created_at', 'uploaderBattery', '_id'],
profile: ['defaultProfile', 'startDate', '_id']
};
```
### 6.2 Blocked Operators (Blacklist)
MongoDB operators that are never allowed:
```javascript
const blockedOperators = [
'$where', // JavaScript execution
'$function', // User-defined functions
'$accumulator', // Aggregation with JS
'$expr', // Expression evaluation (limited allow)
'$jsonSchema', // Schema validation bypass
'$text', // Full-text search (expensive)
'$regex' // Regex (allow only with restrictions)
];
```
### 6.3 Safe Operators (Allowlist)
Operators explicitly allowed in queries:
```javascript
const safeOperators = [
'$eq', '$ne', '$gt', '$gte', '$lt', '$lte',
'$in', '$nin', '$exists', '$type',
'$and', '$or', '$not' // Logical with depth limit
];
```
### 6.4 Query Depth Limiting
Prevent deeply nested queries:
```javascript
const MAX_QUERY_DEPTH = 3;
const MAX_ARRAY_LENGTH = 100; // For $in/$nin arrays
```
---
## 7. Pagination Metadata
### 7.1 Current State
API v1/v2 responses return raw arrays with no pagination metadata:
```json
[
{ "sgv": 120, "date": 1234567890000 },
{ "sgv": 118, "date": 1234567830000 }
]
```
Clients cannot determine:
- Total number of matching documents
- Whether more results exist
- How to fetch the next page
### 7.2 Proposed Response Format
Add optional pagination envelope (backwards compatible):
**Request with pagination metadata:**
```http
GET /api/v1/entries?count=10&skip=0&envelope=true
```
**Response:**
```json
{
"status": 200,
"result": [
{ "sgv": 120, "date": 1234567890000 },
{ "sgv": 118, "date": 1234567830000 }
],
"pagination": {
"count": 10,
"skip": 0,
"total": 2847,
"hasMore": true
}
}
```
**Request without envelope (default, backwards compatible):**
```http
GET /api/v1/entries?count=10
```
**Response (unchanged):**
```json
[
{ "sgv": 120, "date": 1234567890000 },
{ "sgv": 118, "date": 1234567830000 }
]
```
### 7.3 Implementation Notes
- `envelope=true` query param opts into new format
- `total` count requires additional query (optional, expensive)
- `hasMore` can be determined by requesting count+1, returning count
- v3 already has envelope format, add `pagination` field
### 7.4 Performance Consideration
Total count queries can be expensive on large collections. Options:
| Approach | Trade-off |
|----------|-----------|
| Always include total | Slow on large collections |
| Include only if < 10k docs | Fast but inconsistent |
| Require explicit `&total=true` | Opt-in for expensive operation |
| Estimate using collection stats | Fast but approximate |
**Recommendation:** Require `&total=true` for total count, default to `hasMore` only.
---
## 8. Date Format Normalization
### 8.1 Current State
Date fields are handled inconsistently:
| Field | Format | Notes |
|-------|--------|-------|
| `date` | Unix timestamp (ms) | Number |
| `dateString` | ISO 8601 | String |
| `created_at` | ISO 8601 | String |
| `srvCreated` | Unix timestamp (ms) | Number |
| Query `date[gte]` | Accepts multiple formats | Inconsistent parsing |
### 8.2 Proposed Normalization
**Input parsing (queries):**
```javascript
function parseDate(value) {
if (value === undefined || value === null) return null;
// Unix timestamp (ms)
if (typeof value === 'number') return value;
if (/^\d{13}$/.test(value)) return parseInt(value, 10);
// Unix timestamp (seconds) - common mistake
if (/^\d{10}$/.test(value)) return parseInt(value, 10) * 1000;
// ISO 8601
const parsed = Date.parse(value);
if (!isNaN(parsed)) return parsed;
return null; // Invalid, will be filtered out
}
```
**Output normalization:**
All date fields in responses include both formats:
```json
{
"date": 1234567890000,
"dateString": "2009-02-13T23:31:30.000Z"
}
```
### 8.3 Query Date Filters
Standardize date filter parsing across all endpoints:
```javascript
// Accept multiple formats
const validFormats = [
'date[gte]=1234567890000', // Unix ms
'date[gte]=2024-01-01T00:00:00Z', // ISO 8601
'date[gte]=2024-01-01', // Date only (start of day UTC)
'dateString[gte]=2024-01-01T00:00:00Z' // Explicit dateString
];
// Normalize all to Unix ms internally
```
---
## 9. Observability Plan
### 9.1 Violation Logging
Violations are logged to console in structured JSON format for ingestion by log aggregators:
```javascript
console.log(JSON.stringify({
event: 'query_violation',
timestamp: new Date().toISOString(),
violation: 'count_exceeded',
collection: 'entries',
endpoint: '/api/v1/entries',
requested: 50000,
limit: 1000,
authTier: 'anonymous',
clientIP: hashIP(req.ip), // Anonymized
userAgent: req.headers['user-agent'],
action: 'warn'
}));
```
### 9.2 Metrics for Monitoring
| Metric | Type | Labels |
|--------|------|--------|
| `nightscout_query_violations_total` | Counter | `violation_type`, `collection`, `auth_tier`, `action` |
| `nightscout_query_limit_hits_total` | Counter | `collection`, `auth_tier` |
| `nightscout_query_count_requested` | Histogram | `collection` |
| `nightscout_query_date_range_days` | Histogram | `collection` |
### 9.3 Dashboard Recommendations
**Violation Dashboard:**
- Violations per hour by type
- Top violating endpoints
- Auth tier distribution of violations
- User agent breakdown (identify misbehaving clients)
**Query Pattern Dashboard:**
- Requested count distribution per collection
- Date range distribution per collection
- Percentage of queries hitting limits
### 9.4 Alerting Thresholds
| Alert | Condition | Severity |
|-------|-----------|----------|
| High violation rate | > 100 violations/minute | Warning |
| Blocked operator detected | Any `blocked_operator` violation | Critical |
| Single client abuse | > 50 violations/minute from one IP | Warning |
| Limit too restrictive | > 50% of queries clamped | Info |
### 9.5 Integration Points
**For deployments with monitoring infrastructure:**
```javascript
// Optional Prometheus integration
if (process.env.ENABLE_PROMETHEUS_METRICS) {
const { violationsCounter, queryHistogram } = require('./metrics');
violationsCounter.inc({ type, collection, tier, action });
}
// Optional external logging (Datadog, Splunk, etc.)
if (process.env.EXTERNAL_LOG_ENDPOINT) {
sendToExternalLogger(violationEvent);
}
```
**For basic deployments:**
- Console JSON logs are sufficient
- Can be piped to file: `node server.js 2>&1 | tee /var/log/nightscout.log`
- grep/jq analysis: `grep query_violation /var/log/nightscout.log | jq '.violation'`
---
## 10. Rollout Strategy
### Phase 1: Warn Mode (Default)
```
QUERY_LIMITS_MODE=warn
```
Behavior:
- Parse and validate all queries
- Log violations to console/monitoring
- **Do not reject or modify queries**
- Collect metrics on violation patterns
Duration: 2-4 weeks to gather data
### Phase 2: Soft Enforce Mode
```
QUERY_LIMITS_MODE=soft
```
Behavior:
- Clamp values to limits (don't reject)
- Log when clamping occurs
- Return `X-Query-Modified: true` header when clamped
### Phase 3: Enforce Mode
```
QUERY_LIMITS_MODE=enforce
```
Behavior:
- Reject queries that exceed limits
- Return 400 Bad Request with clear error message
- Continue logging for monitoring
---
## 11. Error Responses
### 11.1 Validation Error (400)
```json
{
"status": 400,
"message": "Query validation failed",
"errors": [
{
"field": "count",
"violation": "count_exceeded",
"requested": 50000,
"maximum": 1000,
"suggestion": "Use count=1000 or paginate with skip parameter"
}
]
}
```
### 11.2 Modified Query Header
When in soft-enforce mode:
```http
HTTP/1.1 200 OK
X-Query-Modified: true
X-Query-Modifications: count:50000->1000
```
---
## 12. API Version Integration
### 12.1 API v1 Integration Points
| Endpoint | File | Integration Point |
|----------|------|-------------------|
| `/api/v1/entries` | `lib/api/entries/index.js` | Before `prepareQuery()` |
| `/api/v1/treatments` | `lib/api/treatments/index.js` | Before query execution |
| `/api/v1/devicestatus` | `lib/api/devicestatus/index.js` | Before query execution |
| `/api/v1/profile` | `lib/api/profile/index.js` | Before query execution |
| `/api/v1/food` | `lib/api/food/index.js` | Before query execution |
| `/api/v1/activity` | `lib/api/activity/index.js` | Before query execution |
### 12.2 API v2 Integration Points
| Endpoint | File | Integration Point |
|----------|------|-------------------|
| `/api/v2/properties` | `lib/api2/properties/index.js` | Before query execution |
| `/api/v2/authorization/*` | `lib/api2/authorization/index.js` | Subject queries |
### 12.3 API v3 Integration Points
v3 already has structured query handling in `lib/api3/generic/`. Integration adds:
| Component | File | Changes |
|-----------|------|---------|
| Search input | `lib/api3/generic/search/input.js` | Add Zod validation |
| Collection | `lib/api3/generic/collection.js` | Apply limits after parsing |
| Operation | `lib/api3/generic/operation.js` | Violation logging |
---
## 13. Testing Strategy
### 13.1 Unit Tests
**Location:** `tests/api.query-normalize.test.js`
| Test Category | Coverage |
|---------------|----------|
| Type coercion | All parser functions with edge cases |
| Sanitization | Blocked operators, unknown fields |
| Limit enforcement | All collection limits, auth tiers |
| Violation logging | All violation types |
### 13.2 Integration Tests
| Test Category | Coverage |
|---------------|----------|
| v1 endpoints | Each endpoint with limit violations |
| v2 endpoints | Each endpoint with limit violations |
| v3 endpoints | Schema validation failures |
| Auth tier behavior | Anonymous vs authenticated vs admin |
| Mode switching | Warn vs soft vs enforce modes |
### 13.3 Regression Tests
Ensure existing client behavior is preserved in warn mode:
- xDrip queries still work
- Loop queries still work
- AAPS queries still work
- Careportal queries still work
---
## 14. Implementation Phases
### Phase 1: Core Library (Week 1)
- [ ] Create `lib/api/shared/query-normalize.js`
- [ ] Implement type coercion functions
- [ ] Implement sanitization functions
- [ ] Implement limit configuration
- [ ] Add unit tests
### Phase 2: v1 Integration (Week 1-2)
- [ ] Add middleware to entries endpoint
- [ ] Add middleware to treatments endpoint
- [ ] Add middleware to devicestatus endpoint
- [ ] Add middleware to profile endpoint
- [ ] Add middleware to remaining v1 endpoints
- [ ] Integration tests
### Phase 3: v2 Integration (Week 2)
- [ ] Add middleware to v2 endpoints
- [ ] Integration tests
### Phase 4: v3 Integration (Week 2-3)
- [ ] Add Zod schemas for v3 query params
- [ ] Integrate validation in search/input.js
- [ ] Apply same limits as v1/v2
- [ ] Integration tests
### Phase 5: Rollout (Week 3-4)
- [ ] Deploy in warn mode
- [ ] Monitor violation logs
- [ ] Tune limits based on real-world data
- [ ] Document enforced limits in API docs
- [ ] Switch to enforce mode
---
## 15. Migration Risks
### 15.1 Known High-Volume Clients
| Client | Concern | Mitigation |
|--------|---------|------------|
| xDrip | May request large entry counts | Auth tier gives 2x limit |
| Loop | Frequent devicestatus uploads | Focus on query limits, not write limits |
| AAPS | Very frequent queries | Socket subscription reduces query need |
| Careportal | Dashboard data fetches | Review actual query patterns |
### 15.2 Rollback Plan
If significant issues detected:
1. Set `QUERY_LIMITS_MODE=off` to disable entirely
2. Or increase specific limits via env vars
3. No code deployment needed for rollback
---
## 16. Open Questions
### 16.1 Resolved
| Question | Decision |
|----------|----------|
| Should we log violations by default? | Yes, warn mode is default |
| Should authenticated users get higher limits? | Yes, tiered multipliers |
| Should v3 be included? | Yes, same collections deserve same protections |
### 16.2 Open
| Question | Notes |
|----------|-------|
| Exact limit values | Need production data from warn mode |
| GraphQL exploration for v4? | Deferred to Control Plane RFC |
| Per-client rate limiting | Separate from per-query limits, may be Phase 2 |
---
## 17. Related Documents
- [API Layer Audit](../api-layer-audit.md) - Documents the issues this proposal addresses
- [Security Audit](../security-audit.md) - Related security concerns
- [Agent Control Plane RFC](./agent-control-plane-rfc.md) - Future API direction
- [Modernization Roadmap](../modernization-roadmap.md) - Overall modernization context
---
## 18. Appendix: Example Implementations
### A.1 Type Coercion Functions
```javascript
function toPositiveInt(value, defaultValue, options = {}) {
if (value === undefined || value === null || value === '') {
return defaultValue;
}
const parsed = parseInt(value, 10);
if (isNaN(parsed) || parsed < 1) {
return defaultValue;
}
if (options.max && parsed > options.max) {
return options.clamp ? options.max : defaultValue;
}
return parsed;
}
function toDateRange(gte, lte, maxRangeDays) {
const now = Date.now();
let startDate = gte ? Date.parse(gte) : null;
let endDate = lte ? Date.parse(lte) : now;
if (startDate && isNaN(startDate)) startDate = null;
if (isNaN(endDate)) endDate = now;
// Enforce max range
const maxRangeMs = maxRangeDays * 24 * 60 * 60 * 1000;
if (startDate && (endDate - startDate) > maxRangeMs) {
startDate = endDate - maxRangeMs;
}
return { startDate, endDate };
}
```
### A.2 Query Sanitization
```javascript
function sanitizeFindQuery(query, collection, options = {}) {
const allowed = allowedFields[collection] || [];
const violations = [];
function sanitizeValue(key, value, depth = 0) {
if (depth > MAX_QUERY_DEPTH) {
violations.push({ type: 'query_too_deep', key, depth });
return undefined;
}
if (typeof value === 'object' && value !== null) {
const sanitized = {};
for (const [k, v] of Object.entries(value)) {
if (k.startsWith('$')) {
if (blockedOperators.includes(k)) {
violations.push({ type: 'blocked_operator', operator: k });
continue;
}
if (!safeOperators.includes(k)) {
violations.push({ type: 'unknown_operator', operator: k });
continue;
}
}
const sanitizedValue = sanitizeValue(k, v, depth + 1);
if (sanitizedValue !== undefined) {
sanitized[k] = sanitizedValue;
}
}
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
}
return value;
}
const sanitized = {};
for (const [key, value] of Object.entries(query || {})) {
if (!allowed.includes(key) && !key.startsWith('$')) {
violations.push({ type: 'unknown_field', field: key });
continue;
}
const sanitizedValue = sanitizeValue(key, value);
if (sanitizedValue !== undefined) {
sanitized[key] = sanitizedValue;
}
}
return { query: sanitized, violations };
}
```