From 94f3a62539f25284056a26e86f2d0eabf75ed61e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 14 Jul 2025 10:20:10 -0500 Subject: [PATCH] Add comprehensive input sanitization for coordinate parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .kiro/steering/product.md | 18 +++ .kiro/steering/structure.md | 115 +++++++++++++++++ .kiro/steering/tech.md | 100 +++++++++++++++ lib/aprsme/encoding_utils.ex | 44 ++++++- lib/aprsme_web/live/map_live/index.ex | 176 +++++++++++++++++++++----- 5 files changed, 411 insertions(+), 42 deletions(-) create mode 100644 .kiro/steering/product.md create mode 100644 .kiro/steering/structure.md create mode 100644 .kiro/steering/tech.md diff --git a/.kiro/steering/product.md b/.kiro/steering/product.md new file mode 100644 index 0000000..81053c9 --- /dev/null +++ b/.kiro/steering/product.md @@ -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. diff --git a/.kiro/steering/structure.md b/.kiro/steering/structure.md new file mode 100644 index 0000000..48d98c5 --- /dev/null +++ b/.kiro/steering/structure.md @@ -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 diff --git a/.kiro/steering/tech.md b/.kiro/steering/tech.md new file mode 100644 index 0000000..a8bb766 --- /dev/null +++ b/.kiro/steering/tech.md @@ -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` diff --git a/lib/aprsme/encoding_utils.ex b/lib/aprsme/encoding_utils.ex index 54f30ff..63a2fb8 100644 --- a/lib/aprsme/encoding_utils.ex +++ b/lib/aprsme/encoding_utils.ex @@ -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. diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index d08db0b..5b95abe 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -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