# Radius search uses great-circle distance, not bounding-box approximation

- **Pattern:** `ab-001049` (`directory-map-location.location-search.radius-distance-calculation`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001049
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001049)

## Why it matters

Bounding-box radius filtering — using `lat BETWEEN` and `lng BETWEEN` — is a rectangle, not a circle. At a 5km search radius, the corners of the bounding box extend approximately 7km from the center, meaning listings up to 40% farther than the stated radius appear in results. CWE-682 (Incorrect Calculation) applies because the distance calculation is mathematically wrong. ISO 25010:2011 functional-suitability is violated because the feature does not perform as advertised. For time-sensitive searches (finding an open restaurant, locating a nearby service), returning listings that are materially farther than the stated radius causes users to navigate to incorrect distances.

## Severity rationale

High because bounding-box distance calculation is mathematically incorrect and silently returns listings outside the stated radius, producing demonstrably wrong search results.

## Remediation

Replace bounding-box filtering with either a PostGIS spatial query or an in-application haversine distance calculation. PostGIS is preferred for large datasets because the computation happens in the database with a spatial index.

```sql
-- PostGIS: returns listings within 5km of a point
SELECT id, name, location,
  ST_Distance(location, ST_Point(:lng, :lat)::geography) AS distance_meters
FROM listings
WHERE ST_Distance(location, ST_Point(:lng, :lat)::geography) <= 5000
ORDER BY distance_meters;
```

For application-layer calculation when PostGIS is not available:

```ts
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
  const R = 6371;
  const dLat = (lat2 - lat1) * Math.PI / 180;
  const dLon = (lon2 - lon1) * Math.PI / 180;
  const a = Math.sin(dLat / 2) ** 2 +
    Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
  return R * 2 * Math.asin(Math.sqrt(a));
}

const inRadius = listings.filter(l => haversineKm(searchLat, searchLng, l.lat, l.lng) <= radiusKm);
```

See also `directory-map-location.location-search.radius-in-url` — radius should be reflected in the URL for shareability.

## Detection

- **ID:** `radius-distance-calculation`
- **Severity:** `high`
- **What to look for:** Examine the search radius logic. Check whether distance is calculated using great-circle distance (haversine formula) or a spatial index query. Look for bounding-box approximations that might return incorrect results. Test a search with radius and verify that results are correctly ordered by distance.
- **Pass criteria:** Radius search uses great-circle distance (haversine formula) or a spatial database query (PostGIS, MongoDB geospatial). Enumerate all distance calculation functions and confirm at least 1 uses haversine or spatial index. Results are accurately filtered by distance from the search point.
- **Fail criteria:** Bounding-box approximation is used as the sole distance filter. Results include listings outside the radius, or exclude listings inside the radius. A simple lat/lng BETWEEN clause without haversine refinement does not count as pass.
- **Skip (N/A) when:** No radius-based search feature exists.
- **Cross-reference:** Compare with `directory-map-location.location-search.radius-in-url` — search radius should be both accurately calculated (this check) and reflected in the URL for shareability.
- **Detail on fail:** `"Search radius uses bounding-box (lat/lng BETWEEN), returning listings 15km away from a 5km radius search"` or `"No distance calculation found; search appears to use arbitrary result ordering"`
- **Remediation:** Use a spatial query with PostGIS or implement haversine distance:

  ```sql
  SELECT id, name, location
  FROM listings
  WHERE ST_Distance(location, ST_Point(search_lng, search_lat)::geography) <= 5000
  ORDER BY ST_Distance(location, ST_Point(search_lng, search_lat)::geography);
  ```

  Or in application code:

  ```ts
  function haversineDistance(lat1, lon1, lat2, lon2) {
    const R = 6371;
    const dLat = (lat2 - lat1) * Math.PI / 180;
    const dLon = (lon2 - lon1) * Math.PI / 180;
    const a = Math.sin(dLat / 2) ** 2 +
              Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
    const c = 2 * Math.asin(Math.sqrt(a));
    return R * c;
  }

  const resultsInRadius = listings.filter(l => {
    const distance = haversineDistance(searchLat, searchLng, l.lat, l.lng);
    return distance <= radiusKm;
  });
  ```

## External references

- cwe CWE-682
- iso-25010 functional-suitability

Taxons: data-integrity

HTML version: https://auditbuffet.com/patterns/ab-001049
