Add comprehensive input sanitization for coordinate parsing
Security improvements: - Added sanitize_numeric_string to remove dangerous characters - Limited input string length to prevent DoS attacks (20 chars for numbers) - Added is_finite? checks to prevent infinity/NaN values - Validate coordinate ranges (lat: -90 to 90, lng: -180 to 180) - Added safe_parse_coordinate with proper validation - Updated to_float in EncodingUtils with security validations - Protected against integer overflow in coordinate conversions - Added sanitize_path_string for APRS path validation All coordinate inputs from users are now: 1. Sanitized to remove injection characters 2. Length-limited to prevent resource exhaustion 3. Validated for finite values (no infinity/NaN) 4. Checked against valid geographic ranges 5. Given safe fallback defaults 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1934b8d014
commit
94f3a62539
5 changed files with 411 additions and 42 deletions
18
.kiro/steering/product.md
Normal file
18
.kiro/steering/product.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Product Overview
|
||||
|
||||
Aprsme is an APRS (Automatic Packet Reporting System) web application that provides real-time tracking and visualization of amateur radio packets. The application connects to APRS-IS servers to receive and process amateur radio position reports, weather data, and other telemetry.
|
||||
|
||||
## Core Features
|
||||
|
||||
- **Real-time Map Visualization**: Interactive map showing APRS stations and their positions
|
||||
- **Packet Processing**: Ingests and processes APRS packets from APRS-IS network
|
||||
- **Station Information**: Detailed views of individual callsigns and their activity
|
||||
- **Weather Data**: Weather station reporting and visualization
|
||||
- **Bad Packet Analysis**: Monitoring and analysis of malformed packets
|
||||
- **Multi-language Support**: Internationalization with English, Spanish, German, and French
|
||||
- **User Authentication**: User accounts and session management
|
||||
- **API Access**: RESTful API for programmatic access to data
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
The application uses a GenStage-based packet processing pipeline to handle high-volume APRS data streams, with PostgreSQL for persistence and Phoenix LiveView for real-time web interfaces. Background job processing is handled by Oban for maintenance tasks like packet cleanup.
|
||||
115
.kiro/steering/structure.md
Normal file
115
.kiro/steering/structure.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Project Structure
|
||||
|
||||
## Root Directory Layout
|
||||
|
||||
```
|
||||
├── lib/ # Application source code
|
||||
├── test/ # Test files
|
||||
├── config/ # Configuration files
|
||||
├── priv/ # Private application files (migrations, static assets)
|
||||
├── assets/ # Frontend assets (CSS, JS, images)
|
||||
├── deps/ # Dependencies (managed by Mix)
|
||||
├── _build/ # Compiled artifacts
|
||||
├── k8s/ # Kubernetes deployment manifests
|
||||
├── rel/ # Release configuration
|
||||
└── vendor/ # Vendored dependencies (custom APRS library)
|
||||
```
|
||||
|
||||
## Application Code Structure (`lib/`)
|
||||
|
||||
### Core Application (`lib/aprsme/`)
|
||||
|
||||
- **Business Logic**: Core domain modules for APRS packet processing
|
||||
- **Data Layer**: Ecto schemas, repos, and database interactions
|
||||
- **Background Jobs**: Oban workers for maintenance tasks
|
||||
- **External Integrations**: APRS-IS connection and packet processing pipeline
|
||||
|
||||
#### Key Subdirectories:
|
||||
|
||||
- `accounts/` - User authentication and management
|
||||
- `is/` - APRS-IS server connection and supervision
|
||||
- `packets/` - Packet processing, clustering, and queries
|
||||
- `workers/` - Background job workers
|
||||
|
||||
### Web Layer (`lib/aprsme_web/`)
|
||||
|
||||
- **Controllers**: HTTP request handlers and API endpoints
|
||||
- **LiveViews**: Real-time interactive pages
|
||||
- **Components**: Reusable UI components
|
||||
- **Plugs**: Custom middleware for authentication, rate limiting, etc.
|
||||
|
||||
#### Key Subdirectories:
|
||||
|
||||
- `controllers/` - Traditional Phoenix controllers and API endpoints
|
||||
- `live/` - Phoenix LiveView modules organized by feature
|
||||
- `components/` - Shared UI components and layouts
|
||||
- `plugs/` - Custom Plug modules
|
||||
|
||||
## Configuration Structure (`config/`)
|
||||
|
||||
- `config.exs` - Base configuration
|
||||
- `dev.exs` - Development environment
|
||||
- `prod.exs` - Production environment
|
||||
- `runtime.exs` - Runtime configuration (environment variables)
|
||||
- `test.exs` - Test environment
|
||||
|
||||
## Test Structure (`test/`)
|
||||
|
||||
- Mirrors `lib/` structure with `_test.exs` suffix
|
||||
- `test/support/` - Test helpers and fixtures
|
||||
- `test/fixtures/` - Test data fixtures
|
||||
- `test/integration/` - Integration tests
|
||||
|
||||
## Asset Structure (`assets/`)
|
||||
|
||||
- `css/` - Tailwind CSS files
|
||||
- `js/` - JavaScript/TypeScript files
|
||||
- `features/` - Feature-specific JS modules
|
||||
- `hooks/` - Phoenix LiveView hooks
|
||||
- `types/` - TypeScript type definitions
|
||||
|
||||
## Database Structure (`priv/repo/`)
|
||||
|
||||
- `migrations/` - Ecto database migrations
|
||||
- `seeds.exs` - Database seeding script
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Modules
|
||||
|
||||
- **Contexts**: `Aprsme.ContextName` (e.g., `Aprsme.Accounts`, `Aprsme.Packets`)
|
||||
- **Schemas**: `Aprsme.Context.SchemaName` (e.g., `Aprsme.Accounts.User`)
|
||||
- **Web Modules**: `AprsmeWeb.ModuleName` (e.g., `AprsmeWeb.UserController`)
|
||||
- **LiveViews**: `AprsmeWeb.FeatureLive.Action` (e.g., `AprsmeWeb.MapLive.Index`)
|
||||
|
||||
### Files
|
||||
|
||||
- **Contexts**: `snake_case.ex` (e.g., `packet_consumer.ex`)
|
||||
- **Tests**: `module_name_test.exs`
|
||||
- **Templates**: `action_name.html.heex` for LiveView templates
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Phoenix Contexts
|
||||
|
||||
- Business logic organized into bounded contexts
|
||||
- Each context has a main module that serves as the public API
|
||||
- Internal modules handle specific responsibilities
|
||||
|
||||
### GenStage Pipeline
|
||||
|
||||
- `PacketProducer` → `PacketConsumer` pipeline for APRS data processing
|
||||
- Supervised by `PacketPipelineSupervisor`
|
||||
- Configurable batch processing parameters
|
||||
|
||||
### LiveView Organization
|
||||
|
||||
- Feature-based organization (e.g., `map_live/`, `packets_live/`)
|
||||
- Shared components in `components/`
|
||||
- Shared utilities in `shared/`
|
||||
|
||||
### Error Handling
|
||||
|
||||
- `ErrorTracker` for application error monitoring
|
||||
- Custom error handlers and circuit breakers for external services
|
||||
- Structured logging with sanitization for sensitive data
|
||||
100
.kiro/steering/tech.md
Normal file
100
.kiro/steering/tech.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# Technology Stack
|
||||
|
||||
## Core Technologies
|
||||
|
||||
- **Elixir**: ~> 1.17 - Primary programming language
|
||||
- **Phoenix Framework**: ~> 1.8 - Web framework
|
||||
- **Phoenix LiveView**: ~> 1.0.17 - Real-time web interfaces
|
||||
- **PostgreSQL**: Database with PostGIS extension for geospatial data
|
||||
- **Ecto**: Database wrapper and query generator
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
### Web & UI
|
||||
|
||||
- **Bandit**: HTTP server (replaces Cowboy)
|
||||
- **Phoenix LiveDashboard**: Development and monitoring dashboard
|
||||
- **Tailwind CSS**: Utility-first CSS framework
|
||||
- **Heroicons**: Icon library
|
||||
- **ESBuild**: JavaScript bundler
|
||||
|
||||
### Data Processing
|
||||
|
||||
- **GenStage**: Stream processing for APRS packet pipeline
|
||||
- **Oban**: Background job processing
|
||||
- **Cachex**: In-memory caching
|
||||
- **Geo/PostGIS**: Geospatial data handling
|
||||
|
||||
### External Integrations
|
||||
|
||||
- **HTTPoison/Req**: HTTP clients
|
||||
- **Jason**: JSON encoding/decoding
|
||||
- **Swoosh**: Email delivery
|
||||
|
||||
### Development Tools
|
||||
|
||||
- **Credo**: Static code analysis
|
||||
- **Dialyxir**: Static type analysis
|
||||
- **Styler**: Code formatting
|
||||
- **ExVCR**: HTTP interaction recording for tests
|
||||
- **Sobelow**: Security-focused static analysis
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
mix deps.get # Install dependencies
|
||||
mix ecto.setup # Create and migrate database
|
||||
mix phx.server # Start development server
|
||||
iex -S mix phx.server # Start with interactive shell
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
|
||||
```bash
|
||||
mix ecto.create # Create database
|
||||
mix ecto.migrate # Run migrations
|
||||
mix ecto.reset # Drop, create, and migrate database
|
||||
mix ecto.gen.migration # Generate new migration
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
mix credo # Run static analysis
|
||||
mix dialyzer # Run type analysis
|
||||
mix format # Format code
|
||||
mix test # Run test suite
|
||||
mix test.watch # Run tests in watch mode
|
||||
```
|
||||
|
||||
### Asset Management
|
||||
|
||||
```bash
|
||||
mix assets.deploy # Build and optimize assets for production
|
||||
mix tailwind default # Compile Tailwind CSS
|
||||
mix esbuild default # Compile JavaScript
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
mix release # Build production release
|
||||
mix phx.digest # Generate asset digests
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Development**: `config/dev.exs`
|
||||
- **Production**: `config/prod.exs`
|
||||
- **Runtime**: `config/runtime.exs`
|
||||
- **Test**: `config/test.exs`
|
||||
- **Base**: `config/config.exs`
|
||||
|
||||
## Code Style
|
||||
|
||||
- Uses **Styler** plugin for consistent formatting
|
||||
- **Credo** enforces code quality with 120 character line limit
|
||||
- **Dialyzer** for static type analysis
|
||||
- Import dependencies: `:ecto`, `:ecto_sql`, `:phoenix`, `:stream_data`
|
||||
|
|
@ -84,7 +84,7 @@ defmodule Aprsme.EncodingUtils do
|
|||
def sanitize_string(other), do: other
|
||||
|
||||
@doc """
|
||||
Converts various types to float for consistent display.
|
||||
Converts various types to float with validation for safety.
|
||||
|
||||
## Examples
|
||||
|
||||
|
|
@ -100,18 +100,48 @@ defmodule Aprsme.EncodingUtils do
|
|||
nil
|
||||
"""
|
||||
@spec to_float(any()) :: float() | nil
|
||||
def to_float(value) when is_float(value), do: value
|
||||
def to_float(value) when is_integer(value), do: value * 1.0
|
||||
def to_float(%Decimal{} = value), do: Decimal.to_float(value)
|
||||
def to_float(value) when is_float(value) do
|
||||
if is_finite_float?(value), do: value, else: nil
|
||||
end
|
||||
|
||||
def to_float(value) when is_integer(value) do
|
||||
# Protect against integer overflow when converting to float
|
||||
if value >= -9.0e15 and value <= 9.0e15 do
|
||||
value * 1.0
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def to_float(%Decimal{} = value) do
|
||||
float = Decimal.to_float(value)
|
||||
if is_finite_float?(float), do: float, else: nil
|
||||
end
|
||||
|
||||
def to_float(value) when is_binary(value) do
|
||||
case Float.parse(value) do
|
||||
{float, _} -> float
|
||||
:error -> nil
|
||||
# Sanitize input first to prevent injection attacks
|
||||
sanitized =
|
||||
value
|
||||
|> sanitize_string()
|
||||
|> to_string()
|
||||
|> String.slice(0, 30) # Reasonable max length for a number
|
||||
|
||||
case Float.parse(sanitized) do
|
||||
{float, _} when is_float(float) ->
|
||||
if is_finite_float?(float), do: float, else: nil
|
||||
:error ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def to_float(_), do: nil
|
||||
|
||||
# Helper to check if a float is finite (not infinity or NaN)
|
||||
defp is_finite_float?(float) when is_float(float) do
|
||||
float != :infinity and float != :neg_infinity and float == float # NaN != NaN
|
||||
end
|
||||
|
||||
defp is_finite_float?(_), do: false
|
||||
|
||||
@doc """
|
||||
Converts various types to Decimal for database storage.
|
||||
|
|
|
|||
|
|
@ -56,25 +56,65 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
parse_int_in_range(zoom_str, @default_zoom, 1, 20)
|
||||
end
|
||||
|
||||
defp parse_float_in_range(str, default, min, max) do
|
||||
case Float.parse(str) do
|
||||
{val, _} when val >= min and val <= max -> val
|
||||
_ -> default
|
||||
defp parse_float_in_range(str, default, min, max) when is_binary(str) do
|
||||
# Sanitize input first
|
||||
sanitized = sanitize_numeric_string(str)
|
||||
|
||||
case Float.parse(sanitized) do
|
||||
{val, ""} when val >= min and val <= max ->
|
||||
if is_finite?(val), do: val, else: default
|
||||
{val, _remainder} when val >= min and val <= max ->
|
||||
# Accept even with trailing characters, but validate the number
|
||||
if is_finite?(val), do: val, else: default
|
||||
_ ->
|
||||
default
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_float_in_range(_, default, _, _), do: default
|
||||
|
||||
defp parse_int_in_range(str, default, min, max) do
|
||||
case Integer.parse(str) do
|
||||
{val, _} when val >= min and val <= max -> val
|
||||
_ -> default
|
||||
defp parse_int_in_range(str, default, min, max) when is_binary(str) do
|
||||
# Sanitize input first
|
||||
sanitized = sanitize_numeric_string(str)
|
||||
|
||||
case Integer.parse(sanitized) do
|
||||
{val, ""} when val >= min and val <= max ->
|
||||
val
|
||||
{val, _remainder} when val >= min and val <= max ->
|
||||
# Accept even with trailing characters
|
||||
val
|
||||
_ ->
|
||||
default
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_int_in_range(_, default, _, _), do: default
|
||||
|
||||
# Sanitize numeric strings to prevent injection attacks
|
||||
defp sanitize_numeric_string(str) when is_binary(str) do
|
||||
# Remove any potentially dangerous characters, keeping only numbers, dots, minus, and e/E for scientific notation
|
||||
str
|
||||
|> String.trim()
|
||||
|> String.replace(~r/[^\d\.\-eE]/, "")
|
||||
|> limit_string_length(20) # Prevent extremely long inputs
|
||||
end
|
||||
|
||||
defp sanitize_numeric_string(_), do: ""
|
||||
|
||||
# Limit string length to prevent DoS
|
||||
defp limit_string_length(str, max_length) when byte_size(str) > max_length do
|
||||
:binary.part(str, 0, max_length)
|
||||
end
|
||||
|
||||
defp limit_string_length(str, _), do: str
|
||||
|
||||
# Check if a float is finite (not infinity or NaN)
|
||||
defp is_finite?(float) when is_float(float) do
|
||||
float != :infinity and float != :neg_infinity and float == float # NaN != NaN
|
||||
end
|
||||
|
||||
defp is_finite?(_), do: false
|
||||
|
||||
# Convert various types to float
|
||||
defp to_float(value) when is_binary(value), do: String.to_float(value)
|
||||
defp to_float(value) when is_integer(value), do: value / 1.0
|
||||
defp to_float(value) when is_float(value), do: value
|
||||
defp to_float(_), do: 0.0
|
||||
|
||||
@impl true
|
||||
def mount(params, session, socket) do
|
||||
|
|
@ -291,13 +331,18 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
@impl true
|
||||
def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do
|
||||
# Update map center and zoom when location is received
|
||||
# Ensure coordinates are floats
|
||||
lat_float = to_float(lat)
|
||||
lng_float = to_float(lng)
|
||||
# Parse and validate coordinates
|
||||
lat_float = parse_latitude(lat)
|
||||
lng_float = parse_longitude(lng)
|
||||
|
||||
socket = update_and_zoom_to_location(socket, lat_float, lng_float, 12)
|
||||
|
||||
{:noreply, socket}
|
||||
# Additional validation - ensure we got valid coordinates
|
||||
if valid_coordinates?(lat_float, lng_float) do
|
||||
socket = update_and_zoom_to_location(socket, lat_float, lng_float, 12)
|
||||
{:noreply, socket}
|
||||
else
|
||||
# Invalid coordinates - ignore the event
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -361,10 +406,17 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
def handle_event("marker_hover_start", %{"id" => _id, "path" => path, "lat" => lat, "lng" => lng}, socket) do
|
||||
require Logger
|
||||
|
||||
Logger.info("RF path hover start - path: #{inspect(path)}")
|
||||
# Validate coordinates first
|
||||
lat_float = safe_parse_coordinate(lat, 0.0, -90.0, 90.0)
|
||||
lng_float = safe_parse_coordinate(lng, 0.0, -180.0, 180.0)
|
||||
|
||||
# Validate path string
|
||||
safe_path = sanitize_path_string(path)
|
||||
|
||||
Logger.info("RF path hover start - path: #{inspect(safe_path)}")
|
||||
|
||||
# Parse the path to find digipeater/igate stations
|
||||
path_stations = parse_rf_path(path)
|
||||
path_stations = parse_rf_path(safe_path)
|
||||
Logger.info("Parsed path stations: #{inspect(path_stations)}")
|
||||
|
||||
# Query for positions of path stations
|
||||
|
|
@ -375,8 +427,8 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
socket =
|
||||
if length(path_station_positions) > 0 do
|
||||
push_event(socket, "draw_rf_path", %{
|
||||
station_lat: lat,
|
||||
station_lng: lng,
|
||||
station_lat: lat_float,
|
||||
station_lng: lng_float,
|
||||
path_stations: path_station_positions
|
||||
})
|
||||
else
|
||||
|
|
@ -539,28 +591,71 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp parse_center_coordinates(center, socket) do
|
||||
lat = extract_coordinate(center, "lat", socket.assigns.map_center.lat)
|
||||
lng = extract_coordinate(center, "lng", socket.assigns.map_center.lng)
|
||||
|
||||
# Validate and clamp values
|
||||
lat = clamp_coordinate(lat, -90.0, 90.0)
|
||||
lng = clamp_coordinate(lng, -180.0, 180.0)
|
||||
defp parse_center_coordinates(center, socket) when is_map(center) do
|
||||
default_lat = socket.assigns.map_center.lat
|
||||
default_lng = socket.assigns.map_center.lng
|
||||
|
||||
lat = safe_parse_coordinate(Map.get(center, "lat"), default_lat, -90.0, 90.0)
|
||||
lng = safe_parse_coordinate(Map.get(center, "lng"), default_lng, -180.0, 180.0)
|
||||
|
||||
{lat, lng}
|
||||
end
|
||||
|
||||
defp extract_coordinate(map, key, default) do
|
||||
Map.get(map, key, default)
|
||||
|
||||
defp parse_center_coordinates(_, socket) do
|
||||
# Invalid center format, return current center
|
||||
{socket.assigns.map_center.lat, socket.assigns.map_center.lng}
|
||||
end
|
||||
|
||||
defp clamp_coordinate(value, min, max) do
|
||||
defp safe_parse_coordinate(value, default, min, max) when is_binary(value) do
|
||||
case parse_float_in_range(value, default, min, max) do
|
||||
^default -> default
|
||||
parsed -> clamp_coordinate(parsed, min, max)
|
||||
end
|
||||
end
|
||||
|
||||
defp safe_parse_coordinate(value, default, min, max) when is_number(value) do
|
||||
if is_finite_number?(value) do
|
||||
clamp_coordinate(value, min, max)
|
||||
else
|
||||
default
|
||||
end
|
||||
end
|
||||
|
||||
defp safe_parse_coordinate(_, default, _, _), do: default
|
||||
|
||||
defp clamp_coordinate(value, min, max) when is_number(value) do
|
||||
value |> max(min) |> min(max)
|
||||
end
|
||||
|
||||
defp clamp_coordinate(_, _, _), do: 0.0
|
||||
|
||||
defp clamp_zoom(zoom) do
|
||||
defp clamp_zoom(zoom) when is_binary(zoom) do
|
||||
parse_int_in_range(zoom, @default_zoom, 1, 20)
|
||||
end
|
||||
|
||||
defp clamp_zoom(zoom) when is_integer(zoom) do
|
||||
max(1, min(20, zoom))
|
||||
end
|
||||
|
||||
defp clamp_zoom(zoom) when is_float(zoom) do
|
||||
max(1, min(20, trunc(zoom)))
|
||||
end
|
||||
|
||||
defp clamp_zoom(_), do: @default_zoom
|
||||
|
||||
# Helper to check if a number is finite
|
||||
defp is_finite_number?(num) when is_number(num) do
|
||||
is_finite?(num / 1.0) # Convert integer to float for check
|
||||
end
|
||||
|
||||
defp is_finite_number?(_), do: false
|
||||
|
||||
# Validate that coordinates are within reasonable ranges
|
||||
defp valid_coordinates?(lat, lng) when is_number(lat) and is_number(lng) do
|
||||
lat >= -90 and lat <= 90 and lng >= -180 and lng <= 180
|
||||
end
|
||||
|
||||
defp valid_coordinates?(_, _), do: false
|
||||
|
||||
# Calculate degrees per pixel based on zoom level
|
||||
defp calculate_degrees_per_pixel(zoom) when zoom >= 15, do: 0.000005
|
||||
|
|
@ -2280,6 +2375,17 @@ defmodule AprsmeWeb.MapLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
# Sanitize path string to prevent injection attacks
|
||||
defp sanitize_path_string(path) when is_binary(path) do
|
||||
# Limit length and remove any potentially dangerous characters
|
||||
# APRS paths should only contain callsigns, commas, asterisks, and dashes
|
||||
path
|
||||
|> String.slice(0, 200) # Reasonable max length for APRS path
|
||||
|> String.replace(~r/[^A-Za-z0-9,\-\*\s]/, "")
|
||||
end
|
||||
|
||||
defp sanitize_path_string(_), do: ""
|
||||
|
||||
# Parse RF path string to extract digipeater/igate callsigns
|
||||
defp parse_rf_path(path) when is_binary(path) do
|
||||
# Split by comma and filter out empty strings
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue