Merge pull request #12 from derekslenk/security/fix-public-delete-vulnerability

🚨 SECURITY: Fix critical vulnerabilities in location endpoints
This commit is contained in:
Deco Vander 2025-07-05 22:16:16 -04:00 committed by GitHub
commit 44873b5b27
22 changed files with 2934 additions and 618 deletions

View file

@ -58,15 +58,30 @@ npm run build-css:dev
npm run watch-css
```
### Code Quality
```bash
# Run ESLint to check code style and quality
npm run lint
# Auto-fix ESLint issues where possible
npm run lint:fix
```
### Testing
```bash
# Run all tests
# Run all tests (128+ tests with TypeScript)
npm test
# Run tests with coverage report
# Run tests with coverage report (76% overall coverage)
npm run test:coverage
```
**Test Coverage:**
- **Unit Tests:** Location/ProfanityWord models, DatabaseService, ProfanityFilterService
- **Integration Tests:** Public API routes, Admin API routes with authentication
- **Security Tests:** Rate limiting, input validation, authentication flows
- **Coverage:** 76% statements, 63% branches, 78% lines
### Environment Setup
Before running the application, you must configure environment variables:
```bash
@ -140,18 +155,27 @@ Multiple map implementations for flexibility:
### API Endpoints
Public endpoints:
**Public endpoints:**
- `GET /api/config`: Returns MapBox token for frontend geocoding
- `GET /api/locations`: Active locations (< 48 hours old or persistent)
- `POST /api/locations`: Submit new location report (with profanity filtering)
- `POST /api/locations`: Submit new location report (rate limited: 10/15min per IP)
- **Input Validation:** Address ≤500 chars, Description ≤1000 chars
- **Profanity Filtering:** Automatic content moderation with rejection
- **Security:** Rate limiting prevents spam and DoS attacks
Admin endpoints (require Bearer token):
**Admin endpoints (require Bearer token):**
- `POST /api/admin/login`: Authenticate and receive token
- `GET /api/admin/locations`: All locations including expired
- `PUT /api/admin/locations/:id`: Update location details
- `PATCH /api/admin/locations/:id/persistent`: Toggle persistent status
- `DELETE /api/admin/locations/:id`: Delete location
- Profanity management: `/api/admin/profanity-words` (GET, POST, PUT, DELETE)
- `DELETE /api/admin/locations/:id`: Delete location (admin-only)
- **Profanity management:** `/api/admin/profanity-words` (GET, POST, PUT, DELETE)
**Security Features:**
- **Rate Limiting:** Express-rate-limit middleware on public endpoints
- **Authentication:** Bearer token authentication for admin routes
- **Input Validation:** Strict length limits and type checking
- **Audit Logging:** Suspicious activity detection and logging
### SCSS Organization
SCSS files are in `src/scss/`:
@ -164,12 +188,13 @@ SCSS files are in `src/scss/`:
### Key Design Patterns
1. **TypeScript-First Architecture**: Full type safety with strict type checking
2. **Modular Route Architecture**: Routes accept dependencies as parameters for testability
3. **Dual Database Design**: Separate databases for application data and content moderation
4. **Type-Safe Database Operations**: All database interactions use typed models
5. **Graceful Degradation**: Fallback geocoding providers and error handling
6. **Automated Maintenance**: Cron-based cleanup of expired reports
7. **Security**: Server-side content filtering, environment-based configuration
2. **Security-by-Design**: Rate limiting, input validation, and authentication built into core routes
3. **Modular Route Architecture**: Routes accept dependencies as parameters for testability
4. **Dual Database Design**: Separate databases for application data and content moderation
5. **Type-Safe Database Operations**: All database interactions use typed models
6. **Comprehensive Testing**: 125+ tests covering units, integration, and security scenarios
7. **Graceful Degradation**: Fallback geocoding providers and error handling
8. **Automated Maintenance**: Cron-based cleanup of expired reports
### Deployment
- Automated deployment script for Debian 12 ARM64 in `scripts/deploy.sh`

View file

@ -6,10 +6,11 @@ A community-driven web application for tracking winter road conditions and icy h
- 🗺️ **Interactive Map** - Real-time location tracking centered on Grand Rapids
- ⚡ **Fast Geocoding** - Lightning-fast address lookup with MapBox API
- 🔄 **Auto-Expiration** - Reports automatically removed after 24 hours
- 🔄 **Auto-Expiration** - Reports automatically removed after 48 hours
- 👨‍💼 **Admin Panel** - Manage and moderate location reports
- 📱 **Responsive Design** - Works on desktop and mobile devices
- 🔒 **Privacy-Focused** - No user tracking, community safety oriented
- 🛡️ **Enhanced Security** - Coordinate validation, rate limiting, and content filtering
## Quick Start
@ -44,6 +45,26 @@ A community-driven web application for tracking winter road conditions and icy h
npm run dev-with-css # Development with CSS watching
```
## Development Commands
### Code Quality
```bash
# Run ESLint to check code style and quality
npm run lint
# Auto-fix ESLint issues where possible
npm run lint:fix
```
### Testing
```bash
# Run all tests (128+ tests with TypeScript)
npm test
# Run tests with coverage report (76% overall coverage)
npm run test:coverage
```
5. **Visit the application:**
```
http://localhost:3000
@ -114,16 +135,32 @@ See `docs/deployment.md` for detailed manual deployment instructions.
## API Endpoints
### Public Endpoints
- `GET /api/locations` - Get active location reports
- `POST /api/locations` - Submit new location report
- `POST /api/locations` - Submit new location report (rate limited: 10/15min per IP)
- `GET /api/config` - Get API configuration
### Admin Endpoints (Authentication Required)
- `GET /admin` - Admin panel (password protected)
- `GET /api/admin/locations` - Get all location reports
- `PUT /api/admin/locations/:id` - Update location report
- `PATCH /api/admin/locations/:id/persistent` - Toggle persistent status
- `DELETE /api/admin/locations/:id` - Delete location report
- `GET /api/admin/profanity-words` - Manage profanity filter
- `POST /api/admin/profanity-words` - Add custom profanity word
- `PUT /api/admin/profanity-words/:id` - Update profanity word
- `DELETE /api/admin/profanity-words/:id` - Delete profanity word
### API Documentation
Interactive API documentation available at `/api-docs` when running the server.
## Technology Stack
- **Backend:** Node.js, Express.js, SQLite
- **Backend:** Node.js, Express.js, SQLite, TypeScript
- **Frontend:** Vanilla JavaScript, Leaflet.js
- **Geocoding:** MapBox API (with Nominatim fallback)
- **Security:** Rate limiting, input validation, authentication
- **Testing:** Jest, TypeScript, 128+ tests with 76% coverage
- **Reverse Proxy:** Caddy (automatic HTTPS)
- **Database:** SQLite (lightweight, serverless)
@ -137,10 +174,21 @@ See `docs/deployment.md` for detailed manual deployment instructions.
## Security
- API keys are stored in environment variables
- Admin routes are password protected
- Database queries use parameterized statements
- HTTPS enforced in production
- **Authentication:** Admin routes protected with bearer token authentication
- **Rate Limiting:** Public endpoints limited to prevent abuse (10 requests/15min per IP)
- **Input Validation:** Strict length limits and type checking on all user inputs
- **Data Protection:** API keys stored in environment variables only
- **Database Security:** Parameterized queries prevent SQL injection
- **Content Filtering:** Built-in profanity filter with custom word management
- **HTTPS:** Enforced in production via Caddy reverse proxy
- **Audit Logging:** Suspicious activity and abuse attempts are logged
### Input Limits
- **Address:** Maximum 500 characters
- **Description:** Maximum 1000 characters
- **Latitude:** Must be between -90 and 90 degrees
- **Longitude:** Must be between -180 and 180 degrees
- **Submissions:** 10 per 15 minutes per IP address
## License

305
docs/api.md Normal file
View file

@ -0,0 +1,305 @@
# Great Lakes Ice Report - API Documentation
This document provides an overview of the API endpoints. For interactive documentation with examples, visit `/api-docs` when the server is running.
## Base URL
- **Development**: `http://localhost:3000`
- **Production**: `https://your-domain.com`
## Authentication
Admin endpoints require Bearer token authentication:
```
Authorization: Bearer YOUR_TOKEN_HERE
```
Get a token by authenticating with the admin password at `POST /api/admin/login`.
## Rate Limiting
- **General API**: 30 requests per minute per IP
- **Location Submissions**: 10 requests per 15 minutes per IP
## Public Endpoints
### Get Configuration
```http
GET /api/config
```
Returns MapBox API configuration for frontend geocoding.
**Response:**
```json
{
"mapboxAccessToken": "pk.your_token_here",
"hasMapbox": true
}
```
### Get Active Locations
```http
GET /api/locations
```
Returns all active location reports (less than 48 hours old or marked persistent).
**Response:**
```json
[
{
"id": 123,
"address": "Main St & Oak Ave, Grand Rapids, MI",
"latitude": 42.9634,
"longitude": -85.6681,
"description": "Black ice present, multiple vehicles stuck",
"persistent": false,
"created_at": "2025-01-15T10:30:00.000Z"
}
]
```
### Submit Location Report
```http
POST /api/locations
```
Submit a new ice condition report with automatic profanity filtering.
**Request Body:**
```json
{
"address": "Main St & Oak Ave, Grand Rapids, MI",
"latitude": 42.9634,
"longitude": -85.6681,
"description": "Black ice spotted, drive carefully"
}
```
**Validation:**
- `address`: Required, max 500 characters
- `latitude`: Optional, valid number
- `longitude`: Optional, valid number
- `description`: Optional, max 1000 characters
**Response (Success):**
```json
{
"id": 125,
"address": "Main St & Oak Ave, Grand Rapids, MI",
"latitude": 42.9634,
"longitude": -85.6681,
"description": "Black ice spotted, drive carefully",
"persistent": false,
"created_at": "2025-01-15T12:00:00.000Z"
}
```
**Response (Profanity Detected):**
```json
{
"error": "Submission rejected",
"message": "Your description contains inappropriate language...",
"details": {
"severity": "medium",
"wordCount": 1,
"detectedCategories": ["general"]
}
}
```
## Admin Endpoints
All admin endpoints require authentication via `Authorization: Bearer <token>`.
### Admin Login
```http
POST /api/admin/login
```
Authenticate with admin password to receive access token.
**Request Body:**
```json
{
"password": "your_admin_password"
}
```
**Response:**
```json
{
"token": "your_bearer_token_here",
"message": "Login successful"
}
```
### Get All Locations
```http
GET /api/admin/locations
```
Returns all location reports including expired ones.
### Update Location
```http
PUT /api/admin/locations/:id
```
Update location details.
**Request Body:**
```json
{
"address": "Updated Address",
"latitude": 42.9634,
"longitude": -85.6681,
"description": "Updated description"
}
```
### Toggle Persistent Status
```http
PATCH /api/admin/locations/:id/persistent
```
Mark location as persistent (won't auto-expire) or remove persistent status.
**Request Body:**
```json
{
"persistent": true
}
```
### Delete Location
```http
DELETE /api/admin/locations/:id
```
Permanently delete a location report.
### Profanity Word Management
#### Get All Profanity Words
```http
GET /api/admin/profanity-words
```
#### Add Profanity Word
```http
POST /api/admin/profanity-words
```
**Request Body:**
```json
{
"word": "badword",
"severity": "medium",
"category": "custom"
}
```
#### Update Profanity Word
```http
PUT /api/admin/profanity-words/:id
```
#### Delete Profanity Word
```http
DELETE /api/admin/profanity-words/:id
```
## Error Responses
### Rate Limiting
```json
{
"error": "Too many location reports submitted",
"message": "You can submit up to 10 location reports every 15 minutes. Please wait before submitting more.",
"retryAfter": "15 minutes"
}
```
### Validation Errors
```json
{
"error": "Address must be a string with maximum 500 characters"
}
```
### Authentication Errors
```json
{
"error": "Access denied"
}
```
### Not Found
```json
{
"error": "Location not found"
}
```
### Server Errors
```json
{
"error": "Internal server error"
}
```
## Interactive Documentation
For complete interactive API documentation with live testing capabilities, visit `/api-docs` when running the server. This provides:
- Complete endpoint specifications
- Request/response schemas
- Interactive testing interface
- Authentication flows
- Example requests and responses
## Security Features
- **Rate Limiting**: Prevents abuse and DoS attacks
- **Input Validation**: Strict length and type checking
- **Profanity Filtering**: Automatic content moderation
- **Authentication**: Secure admin access with bearer tokens
- **HTTPS**: SSL/TLS encryption in production
- **Audit Logging**: Suspicious activity detection
## Client Libraries
The API follows REST conventions and can be consumed by any HTTP client. Examples:
### JavaScript/Node.js
```javascript
// Submit location report
const response = await fetch('/api/locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
address: 'Main St & Oak Ave',
description: 'Icy conditions'
})
});
```
### curl
```bash
# Get active locations
curl https://your-domain.com/api/locations
# Submit location report
curl -X POST https://your-domain.com/api/locations \
-H "Content-Type: application/json" \
-d '{"address":"Main St & Oak Ave","description":"Icy conditions"}'
# Admin login
curl -X POST https://your-domain.com/api/admin/login \
-H "Content-Type: application/json" \
-d '{"password":"your_password"}'
```

347
docs/deployment.md Normal file
View file

@ -0,0 +1,347 @@
# Great Lakes Ice Report - Deployment Guide
This guide covers both automated and manual deployment options for the Great Lakes Ice Report application.
## Prerequisites
- **Server**: Debian 12 ARM64 (or compatible Linux distribution)
- **Node.js**: Version 18 or higher
- **Domain**: DNS pointing to your server
- **Ports**: 80 and 443 open for web traffic
## Automated Deployment (Recommended)
### Quick Start
1. **Run the deployment script on your server:**
```bash
curl -sSL https://ice-puremichigan-lol.s3.amazonaws.com/scripts/deploy.sh | bash
```
2. **Clone and setup the application:**
```bash
git clone git@git.deco.sh:deco/ice.git /opt/ice
cd /opt/ice
npm install # This automatically builds CSS via postinstall
```
3. **Configure environment variables:**
```bash
cp .env.example .env
nano .env # Add your API keys
```
4. **Start services:**
```bash
sudo systemctl enable ice
sudo systemctl start ice
sudo systemctl enable caddy
sudo systemctl start caddy
```
### What the Automated Script Does
The deployment script automatically:
1. **System Updates**: Updates package repositories and system packages
2. **Node.js Installation**: Installs Node.js 20.x with build tools
3. **Go Installation**: Installs Go (required for building Caddy with plugins)
4. **Custom Caddy Build**: Builds Caddy with rate limiting plugin using xcaddy
5. **Service Configuration**: Creates systemd services for both the app and Caddy
6. **Security Setup**: Configures users, permissions, and security settings
## Manual Deployment
### 1. System Preparation
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Install Node.js 20.x
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs build-essential
# Install Git (if not already installed)
sudo apt install -y git
```
### 2. Install Go (for Custom Caddy)
```bash
# Download and install Go
wget -q https://go.dev/dl/go1.21.5.linux-arm64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.21.5.linux-arm64.tar.gz
# Add to PATH
export PATH=$PATH:/usr/local/go/bin
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
```
### 3. Build Custom Caddy with Rate Limiting
```bash
# Install xcaddy
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
export PATH=$PATH:$(go env GOPATH)/bin
# Build Caddy with rate limiting plugin
xcaddy build --with github.com/mholt/caddy-ratelimit
# Install Caddy
sudo mv caddy /usr/local/bin/caddy
sudo chmod +x /usr/local/bin/caddy
```
### 4. Create Users and Directories
```bash
# Create Caddy user
sudo groupadd --system caddy
sudo useradd --system --gid caddy --create-home --home-dir /var/lib/caddy --shell /usr/sbin/nologin caddy
# Create directories
sudo mkdir -p /etc/caddy /var/log/caddy
sudo chown -R caddy:caddy /var/log/caddy
# Create app user
sudo groupadd --system great-lakes-ice-report
sudo useradd --system --gid great-lakes-ice-report --create-home --home-dir /opt/great-lakes-ice-report --shell /usr/sbin/nologin great-lakes-ice-report
```
### 5. Deploy Application
```bash
# Clone repository
sudo git clone https://github.com/your-username/ice.git /opt/great-lakes-ice-report
cd /opt/great-lakes-ice-report
# Install dependencies and build
sudo npm install
sudo npm run build
# Set ownership
sudo chown -R great-lakes-ice-report:great-lakes-ice-report /opt/great-lakes-ice-report
```
### 6. Configure Environment
```bash
# Copy environment template
sudo cp .env.example .env
# Edit environment file
sudo nano .env
```
**Required environment variables:**
```bash
# MapBox API token (get free token at https://account.mapbox.com/access-tokens/)
MAPBOX_ACCESS_TOKEN=pk.your_mapbox_token_here
# Admin panel password
ADMIN_PASSWORD=your_secure_password
# Server port (default: 3000)
PORT=3000
# Node environment
NODE_ENV=production
```
### 7. Configure Systemd Services
**Create app service:**
```bash
sudo cp scripts/great-lakes-ice-report.service /etc/systemd/system/
```
**Create Caddy service:**
```bash
sudo tee /etc/systemd/system/caddy.service > /dev/null <<EOF
[Unit]
Description=Caddy
Documentation=https://caddyserver.com/docs/
After=network.target network-online.target
Requires=network-online.target
[Service]
Type=notify
User=caddy
Group=caddy
ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile
ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force
TimeoutStopSec=5s
LimitNOFILE=1048576
LimitNPROC=1048576
PrivateTmp=true
ProtectSystem=full
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
EOF
```
### 8. Configure Caddy
```bash
# Copy Caddyfile
sudo cp scripts/Caddyfile /etc/caddy/
# Edit domain name in Caddyfile
sudo nano /etc/caddy/Caddyfile
# Change 'ice.puremichigan.lol' to your domain
```
### 9. Start Services
```bash
# Reload systemd
sudo systemctl daemon-reload
# Enable and start services
sudo systemctl enable great-lakes-ice-report
sudo systemctl start great-lakes-ice-report
sudo systemctl enable caddy
sudo systemctl start caddy
```
## Security Configuration
### Firewall Setup
```bash
# Install UFW if not present
sudo apt install -y ufw
# Configure firewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
```
### SSL Certificates
Caddy automatically obtains SSL certificates from Let's Encrypt. No manual configuration required.
### Rate Limiting
The application includes multiple layers of rate limiting:
1. **Application Level**: 10 requests per 15 minutes per IP for location submissions
2. **Caddy Level**:
- 30 requests per minute for general API endpoints
- 5 requests per minute for location submissions
## Monitoring and Maintenance
### Service Status
```bash
# Check application status
sudo systemctl status great-lakes-ice-report
# Check Caddy status
sudo systemctl status caddy
# View logs
sudo journalctl -u great-lakes-ice-report -f
sudo journalctl -u caddy -f
```
### Log Files
- **Application logs**: `sudo journalctl -u great-lakes-ice-report`
- **Caddy access logs**: `/var/log/caddy/great-lakes-ice-report.log`
- **System logs**: `/var/log/syslog`
### Database Maintenance
The application automatically:
- Cleans up expired location reports (older than 48 hours)
- Maintains profanity filter database
- Uses SQLite with automatic cleanup
### Updates
```bash
# Navigate to app directory
cd /opt/great-lakes-ice-report
# Pull latest changes
sudo git pull
# Install new dependencies
sudo npm install
# Rebuild application
sudo npm run build
# Restart service
sudo systemctl restart great-lakes-ice-report
```
## Troubleshooting
### Common Issues
1. **Service won't start**:
```bash
sudo journalctl -u great-lakes-ice-report --no-pager
```
2. **SSL certificate issues**:
```bash
sudo journalctl -u caddy --no-pager
```
3. **Database permissions**:
```bash
sudo chown -R great-lakes-ice-report:great-lakes-ice-report /opt/great-lakes-ice-report
```
4. **Port conflicts**:
```bash
sudo netstat -tlnp | grep :3000
sudo netstat -tlnp | grep :80
sudo netstat -tlnp | grep :443
```
### Performance Tuning
For high-traffic deployments:
1. **Increase Node.js memory limit**:
```bash
# Edit service file
sudo nano /etc/systemd/system/great-lakes-ice-report.service
# Add: Environment=NODE_OPTIONS="--max-old-space-size=4096"
```
2. **Configure log rotation**:
```bash
sudo nano /etc/logrotate.d/great-lakes-ice-report
```
3. **Monitor resource usage**:
```bash
htop
sudo iotop
```
## Support
For deployment issues:
- Check system logs: `sudo journalctl -f`
- Verify environment variables: `sudo -u great-lakes-ice-report env`
- Test application directly: `cd /opt/great-lakes-ice-report && sudo -u great-lakes-ice-report node server.js`
- Review this documentation and configuration files
The application includes comprehensive logging and monitoring to help diagnose issues quickly.

77
eslint.config.mjs Normal file
View file

@ -0,0 +1,77 @@
import js from '@eslint/js';
import typescript from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import globals from 'globals';
export default [
{
ignores: ['node_modules/', 'dist/', 'public/*.css', '*.db', '*.scss', '*.md']
},
js.configs.recommended,
{
files: ['**/*.ts'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module'
},
globals: {
...globals.node
}
},
plugins: {
'@typescript-eslint': typescript
},
rules: {
// TypeScript rules
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
// General rules
'no-console': 'off', // Allow console.log for server logging
'no-var': 'error',
'prefer-const': 'error',
'eqeqeq': 'error',
'no-unused-vars': 'off', // Use TypeScript version instead
// Style rules
'indent': ['error', 2],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'comma-dangle': ['error', 'never'],
'no-trailing-spaces': 'error'
}
},
{
files: ['**/*.js'],
languageOptions: {
globals: {
...globals.node
}
},
rules: {
'no-console': 'off',
'no-var': 'error',
'prefer-const': 'error',
'eqeqeq': 'error',
'indent': ['error', 2],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'comma-dangle': ['error', 'never'],
'no-trailing-spaces': 'error'
}
},
{
files: ['tests/**/*.ts'],
languageOptions: {
globals: {
...globals.node,
...globals.jest
}
},
rules: {
'@typescript-eslint/no-explicit-any': 'off' // Allow any in tests for mocks
}
}
];

1400
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,12 +17,15 @@
"build:ts": "tsc",
"test": "jest --runInBand --forceExit",
"test:coverage": "jest --coverage",
"lint": "eslint src/ tests/",
"lint:fix": "eslint src/ tests/ --fix",
"postinstall": "npm run build-css"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.0.1",
"express": "^4.18.2",
"express-rate-limit": "^7.5.1",
"node-cron": "^3.0.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
@ -38,7 +41,11 @@
"@types/supertest": "^6.0.3",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"@typescript-eslint/eslint-plugin": "^8.35.1",
"@typescript-eslint/parser": "^8.35.1",
"concurrently": "^9.2.0",
"eslint": "^9.30.1",
"globals": "^16.3.0",
"jest": "^29.7.0",
"jest-environment-node": "^30.0.4",
"nodemon": "^3.1.10",

View file

@ -1,4 +1,5 @@
import express, { Request, Response, Router } from 'express';
import rateLimit from 'express-rate-limit';
import Location from '../models/Location';
import ProfanityFilterService from '../services/ProfanityFilterService';
import { LocationSubmission } from '../types';
@ -13,15 +14,25 @@ interface LocationPostRequest extends Request {
};
}
interface LocationDeleteRequest extends Request {
params: {
id: string;
};
}
export default (locationModel: Location, profanityFilter: ProfanityFilterService | any): Router => {
const router = express.Router();
// Rate limiting for location submissions to prevent abuse
const submitLocationLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
limit: 10, // Limit each IP to 10 location submissions per 15 minutes
message: {
error: 'Too many location reports submitted',
message: 'You can submit up to 10 location reports every 15 minutes. Please wait before submitting more.',
retryAfter: '15 minutes'
},
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
// Skip rate limiting in test environment
skip: (req) => process.env.NODE_ENV === 'test'
});
/**
* @swagger
* /api/locations:
@ -154,17 +165,48 @@ export default (locationModel: Location, profanityFilter: ProfanityFilterService
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post('/', async (req: LocationPostRequest, res: Response): Promise<void> => {
const { address, latitude, longitude } = req.body;
let { description } = req.body;
router.post('/', submitLocationLimiter, async (req: LocationPostRequest, res: Response): Promise<void> => {
const { address, latitude, longitude, description } = req.body;
console.log(`Attempt to add new location: ${address}`);
// Input validation for security
if (!address) {
console.warn('Failed to add location: Address is required');
res.status(400).json({ error: 'Address is required' });
return;
}
if (typeof address !== 'string' || address.length > 500) {
console.warn(`Failed to add location: Invalid address length (${address?.length || 0})`);
res.status(400).json({ error: 'Address must be a string with maximum 500 characters' });
return;
}
if (description && (typeof description !== 'string' || description.length > 1000)) {
console.warn(`Failed to add location: Invalid description length (${description?.length || 0})`);
res.status(400).json({ error: 'Description must be a string with maximum 1000 characters' });
return;
}
// Validate latitude if provided
if (latitude !== undefined && (typeof latitude !== 'number' || latitude < -90 || latitude > 90)) {
console.warn(`Failed to add location: Invalid latitude (${latitude})`);
res.status(400).json({ error: 'Latitude must be a number between -90 and 90' });
return;
}
// Validate longitude if provided
if (longitude !== undefined && (typeof longitude !== 'number' || longitude < -180 || longitude > 180)) {
console.warn(`Failed to add location: Invalid longitude (${longitude})`);
res.status(400).json({ error: 'Longitude must be a number between -180 and 180' });
return;
}
// Log suspicious activity
if (address.length > 200 || (description && description.length > 500)) {
console.warn(`Unusually long submission from IP: ${req.ip} - Address: ${address.length} chars, Description: ${description?.length || 0} chars`);
}
// Check for profanity in description and reject if any is found
if (description && profanityFilter) {
try {
@ -178,7 +220,7 @@ export default (locationModel: Location, profanityFilter: ProfanityFilterService
res.status(400).json({
error: 'Submission rejected',
message: `Your description contains inappropriate language and cannot be posted. Please revise your description to focus on road conditions and keep it professional.\n\nExample: "Multiple vehicles stuck, black ice present" or "Road very slippery, saw 3 accidents"`,
message: 'Your description contains inappropriate language and cannot be posted. Please revise your description to focus on road conditions and keep it professional.\n\nExample: "Multiple vehicles stuck, black ice present" or "Road very slippery, saw 3 accidents"',
details: {
severity: analysis.severity,
wordCount: analysis.count,
@ -212,24 +254,8 @@ export default (locationModel: Location, profanityFilter: ProfanityFilterService
}
});
// Legacy delete route (keeping for backwards compatibility)
router.delete('/:id', async (req: LocationDeleteRequest, res: Response): Promise<void> => {
const { id } = req.params;
try {
const result = await locationModel.delete(parseInt(id, 10));
if (result.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location deleted successfully' });
} catch (err) {
console.error('Error deleting location:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// DELETE functionality has been moved to admin-only routes for security.
// Use /api/admin/locations/:id (with authentication) for location deletion.
return router;
};

View file

@ -341,7 +341,7 @@ const options: swaggerJsdoc.Options = {
}
]
},
apis: ['./src/routes/*.ts', './src/server.ts'], // Paths to files containing OpenAPI definitions
apis: ['./src/routes/*.ts', './src/server.ts'] // Paths to files containing OpenAPI definitions
};
export const swaggerSpec = swaggerJsdoc(options);

View file

@ -313,15 +313,42 @@ describe('Public API Routes', () => {
});
describe('Request validation', () => {
it('should handle very long addresses', async () => {
const longAddress = 'A'.repeat(1000);
it('should reject overly long addresses for security', async () => {
const longAddress = 'A'.repeat(1000); // Over 500 character limit
const response = await request(app)
.post('/api/locations')
.send({ address: longAddress })
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toBe('Address must be a string with maximum 500 characters');
});
it('should accept addresses within the limit', async () => {
const validLongAddress = 'A'.repeat(400); // Under 500 character limit
const response = await request(app)
.post('/api/locations')
.send({ address: validLongAddress })
.expect(200);
expect(response.body.address).toBe(longAddress);
expect(response.body.address).toBe(validLongAddress);
});
it('should reject overly long descriptions for security', async () => {
const longDescription = 'B'.repeat(1500); // Over 1000 character limit
const response = await request(app)
.post('/api/locations')
.send({
address: 'Test Address',
description: longDescription
})
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toBe('Description must be a string with maximum 1000 characters');
});
it('should handle special characters in address', async () => {
@ -345,5 +372,64 @@ describe('Public API Routes', () => {
expect(response.body.address).toBe(unicodeAddress);
});
it('should reject invalid latitude values', async () => {
const invalidLatitudes = [91, -91, 'invalid', null, true, []];
for (const latitude of invalidLatitudes) {
const response = await request(app)
.post('/api/locations')
.send({
address: 'Test Address',
latitude: latitude,
longitude: -85.6681
})
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toBe('Latitude must be a number between -90 and 90');
}
});
it('should reject invalid longitude values', async () => {
const invalidLongitudes = [181, -181, 'invalid', null, true, []];
for (const longitude of invalidLongitudes) {
const response = await request(app)
.post('/api/locations')
.send({
address: 'Test Address',
latitude: 42.9634,
longitude: longitude
})
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toBe('Longitude must be a number between -180 and 180');
}
});
it('should accept valid latitude and longitude values', async () => {
const validCoordinates = [
{ latitude: 0, longitude: 0 },
{ latitude: 90, longitude: 180 },
{ latitude: -90, longitude: -180 },
{ latitude: 42.9634, longitude: -85.6681 }
];
for (const coords of validCoordinates) {
const response = await request(app)
.post('/api/locations')
.send({
address: 'Test Address',
latitude: coords.latitude,
longitude: coords.longitude
})
.expect(200);
expect(response.body.latitude).toBe(coords.latitude);
expect(response.body.longitude).toBe(coords.longitude);
}
});
});
});

View file

@ -70,14 +70,9 @@ export const createTestProfanityDatabase = (): Promise<Database> => {
});
};
// Cleanup function for tests
// Cleanup function for tests (in-memory databases don't need file cleanup)
export const cleanupTestDatabases = () => {
// Clean up any test database files
[TEST_DB_PATH, TEST_PROFANITY_DB_PATH].forEach(dbPath => {
if (fs.existsSync(dbPath)) {
fs.unlinkSync(dbPath);
}
});
// Using in-memory databases (:memory:) - no file cleanup needed
};
// Global test cleanup