Web Service#

REST API service for anonymizing iCalendar files. Multiple endpoints for different input methods, plus shareable links for easy collaboration.

Hosted Service#

A public instance is available at https://icalendar-anonymizer.com - no installation required.

Features of the hosted service:

  • Shareable links: Generate URLs to share anonymized calendars (30-day expiry)

  • R2 storage: Calendars stored on Cloudflare R2 for reliable access

  • Global edge network: Fast response times worldwide via Cloudflare Workers

  • No account required: Use immediately without signup

For self-hosting options, see the Self-Hosting guide.

Installation#

Install the web service dependencies:

pip install icalendar-anonymizer[web]

This installs:

  • fastapi>=0.128.0 - Web framework

  • uvicorn>=0.38.0 - ASGI server

  • python-multipart>=0.0.18 - File upload support

  • httpx>=0.28.1 - Async HTTP client for URL fetching

Running the Server#

Start the server with uvicorn:

uvicorn icalendar_anonymizer.webapp.main:app --reload

The server starts on http://127.0.0.1:8000 by default.

For production deployment:

uvicorn icalendar_anonymizer.webapp.main:app --host 0.0.0.0 --port 8000

API Documentation#

Interactive API documentation is available at:

Frontend Interface#

The web service includes a user-friendly web interface at the root URL (/).

Features#

The frontend provides three input methods:

Upload File

Drag and drop or click to select an .ics file from your device. Maximum file size: 10 MB.

Paste Content

Copy and paste iCalendar content directly into a text area. Useful for small calendars or quick tests.

Fetch from URL

Enter a URL to fetch and anonymize a remote calendar. Subject to SSRF protection (see security considerations). An Authentication dropdown offers None, Basic, and Bearer for password-protected feeds, calling POST /fetch instead of GET /fetch when credentials are set.

Advanced Options#

All three input methods include an “Advanced Options” collapsible section for granular field control.

Configure how each field is anonymized:

  • keep - Preserve original value

  • remove - Strip property entirely

  • randomize (default) - Hash to deterministic random value

  • replace - Replace with fixed placeholder

Configurable fields (10 total):

  • Summary, Description, Location

  • Comment, Contact, Resources, Categories

  • Attendee, Organizer, UID

The UID field cannot use remove mode (would break recurring events).

Changing a field’s mode on one tab mirrors it to the same field on the other two tabs, and the selections are saved in the browser’s localStorage so they survive a page reload. The Fetch tab’s authentication fields (username, password, bearer token) are never included in this saved data.

Accessibility#

The interface is fully accessible:

  • Keyboard navigation: All controls keyboard accessible with proper tab order

  • Screen reader support: ARIA labels and live regions for status updates

  • High contrast: WCAG AA compliant color contrast ratios

  • Mobile responsive: Works on all device sizes

  • Progressive enhancement: Basic functionality works without JavaScript

Usage Example#

  1. Navigate to http://localhost:8000/

  2. Choose an input method (upload, paste, or fetch)

  3. Submit your calendar

  4. Click “Download” to save the anonymized result

The interface handles errors gracefully with informative messages.

No-JavaScript Fallback#

If JavaScript is disabled, file upload still works via direct form submission to the /upload endpoint. The file downloads automatically on success.

API Endpoints#

GET/POST /anonymized#

curl-friendly endpoint for scripting and testing. Returns raw ICS without JSON wrapper.

GET with query parameter

GET /anonymized?ics=BEGIN:VCALENDAR... HTTP/1.1

POST with raw body

POST /anonymized HTTP/1.1
Content-Type: text/plain

BEGIN:VCALENDAR
VERSION:2.0
...

Response (200 OK)

HTTP/1.1 200 OK
Content-Type: text/calendar; charset=utf-8

BEGIN:VCALENDAR
VERSION:2.0
...

No Content-Disposition header, allowing direct piping to files.

For a POST body, a charset declared in the request’s own Content-Type header (for example Content-Type: text/calendar; charset=iso-8859-7) is used to decode it. Otherwise it falls back to the same UTF-8/Windows-1252/Latin-1 detection as the CLI; see Encoding support.

Error Responses

  • 400 Bad Request - Missing ics parameter (GET), empty body (POST), or invalid ICS format

Examples with curl

# POST with file (primary use case)
curl -X POST --data-binary @calendar.ics https://icalendar-anonymizer.com/anonymized

# Pipe to output file
curl -X POST --data-binary @calendar.ics https://icalendar-anonymizer.com/anonymized > anonymized.ics

# Pipe from stdin
cat calendar.ics | curl -X POST --data-binary @- https://icalendar-anonymizer.com/anonymized

# GET with small test calendar (URL-encoded)
curl "https://icalendar-anonymizer.com/anonymized?ics=BEGIN:VCALENDAR%0AVERSION:2.0%0AEND:VCALENDAR"

Note

GET requests have URL length limits (~2KB). Use POST for anything beyond tiny test calendars.

POST /anonymize#

Anonymize iCalendar content provided as JSON. Optionally configure per-field anonymization.

Request

POST /anonymize HTTP/1.1
Content-Type: application/json

{
  "ics": "BEGIN:VCALENDAR\nVERSION:2.0\n...",
  "config": {
    "summary": "keep",
    "location": "remove",
    "description": "replace"
  }
}

The config field is optional. If omitted, all fields use default randomize behavior.

Response (200 OK)

HTTP/1.1 200 OK
Content-Type: text/calendar
Content-Disposition: attachment; filename="anonymized.ics"

BEGIN:VCALENDAR
VERSION:2.0
...

Error Responses

  • 400 Bad Request - Invalid ICS format or empty input

  • 422 Unprocessable Entity - Invalid field config (invalid field name, invalid mode, or UID set to remove)

  • 500 Internal Server Error - Anonymization failed

Example with curl

# Basic anonymization
curl -X POST http://localhost:8000/anonymize \
  -H "Content-Type: application/json" \
  -d '{"ics": "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR"}' \
  -o anonymized.ics

# With field configuration
curl -X POST http://localhost:8000/anonymize \
  -H "Content-Type: application/json" \
  -d '{"ics": "BEGIN:VCALENDAR\n...", "config": {"summary": "keep", "location": "remove"}}' \
  -o anonymized.ics

POST /upload#

Anonymize an uploaded iCalendar file. Optionally configure per-field anonymization.

Request

POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="calendar.ics"
Content-Type: text/calendar

BEGIN:VCALENDAR
VERSION:2.0
...
------WebKitFormBoundary
Content-Disposition: form-data; name="config"

{"summary": "keep", "location": "remove"}

The config field is optional JSON string. If omitted, all fields use default randomize behavior.

If the uploaded file’s part declares a charset in its own Content-Type, that charset is used to decode it, and any Python codec name is accepted, not just Windows-1252 or Latin-1 (for example Content-Type: text/calendar; charset=utf-16). A declared charset that fails to decode the actual bytes is not trusted blindly: decoding falls back to the same detection used when no charset is declared, rather than crashing or returning corrupted text. See Command-line interface for how that fallback chain works, and Encoding support for its known limitation with other legacy encodings.

Response (200 OK)

HTTP/1.1 200 OK
Content-Type: text/calendar
Content-Disposition: attachment; filename="anonymized.ics"

BEGIN:VCALENDAR
VERSION:2.0
...

Error Responses

  • 400 Bad Request - Invalid ICS format, empty file, or invalid config JSON

  • 413 Payload Too Large - File exceeds size limit

  • 422 Unprocessable Entity - Invalid field config

  • 500 Internal Server Error - Anonymization failed

Example with curl

# Basic upload
curl -X POST http://localhost:8000/upload \
  -F "file=@calendar.ics" \
  -o anonymized.ics

# With field configuration
curl -X POST http://localhost:8000/upload \
  -F "file=@calendar.ics" \
  -F 'config={"summary": "keep", "location": "remove"}' \
  -o anonymized.ics

GET /fetch#

Fetch an iCalendar file from a URL and anonymize it. Optionally configure per-field anonymization via query parameters.

Security Features

This endpoint includes SSRF (Server-Side Request Forgery) protection:

  • Blocks any address that isn’t globally routable: private, loopback, link-local, CGNAT, reserved, and documentation/benchmarking ranges, for both IPv4 and IPv6

  • Blocks multicast addresses

  • Blocks localhost and 0.0.0.0 by name, before DNS resolution

  • Only allows http:// and https:// schemes

  • 10-second timeout

  • 10 MB size limit

  • Validates redirect destinations

  • Follows at most 10 redirects, then fails with 400 Too many redirects

Request

GET /fetch?url=https://example.com/calendar.ics&summary=keep&location=remove HTTP/1.1

Field configuration parameters (all optional):

  • summary, description, location, comment

  • contact, resources, categories

  • attendee, organizer, uid

Each accepts: keep, remove, randomize, replace

If the source server declares a charset in its response’s Content-Type, that charset is used to decode the calendar. Otherwise it falls back to the same UTF-8/Windows-1252/Latin-1 detection as the CLI; see Encoding support.

Response (200 OK)

HTTP/1.1 200 OK
Content-Type: text/calendar
Content-Disposition: attachment; filename="anonymized.ics"

BEGIN:VCALENDAR
VERSION:2.0
...

Error Responses

  • 400 Bad Request - Invalid URL, private IP, invalid ICS format, or connection failed

  • 408 Request Timeout - Request exceeded 10-second timeout

  • 413 Payload Too Large - Response exceeds 10 MB size limit

  • 422 Unprocessable Entity - Invalid field config

  • Various HTTP status codes - Returns the actual HTTP status code from the upstream server (e.g., 404 Not Found, 500 Internal Server Error, 503 Service Unavailable)

  • 500 Internal Server Error - Anonymization failed

Example with curl

# Basic fetch
curl "http://localhost:8000/fetch?url=https://example.com/calendar.ics" \
  -o anonymized.ics

# With field configuration
curl "http://localhost:8000/fetch?url=https://example.com/calendar.ics&summary=keep&location=remove" \
  -o anonymized.ics

DNS Rebinding Protection

  • Resolves the hostname once per request or redirect hop

  • Validates every resolved address, not just the hostname

  • Connects to the validated address directly, without re-resolving it

  • Repeats resolve, validate, connect on each redirect hop

See #70.

POST /fetch#

Fetch an iCalendar file from a URL and anonymize it. Same as GET /fetch, plus optional credentials for password-protected feeds.

Security Features

  • Credentials go in the request body, not the query string

  • Rejects URLs with embedded credentials (https://alice:secret@example.com/...)

  • Drops credentials before following any redirect that changes scheme, host, or port

Request

POST /fetch HTTP/1.1
Content-Type: application/json

{
  "url": "https://example.com/calendar.ics",
  "auth": {"type": "basic", "username": "alice", "password": "secret"}
}

For a bearer token, use "auth": {"type": "bearer", "token": "abc123"} instead.

config accepts the same per-field options as GET /fetch’s query parameters, as a JSON object instead:

POST /fetch HTTP/1.1
Content-Type: application/json

{
  "url": "https://example.com/calendar.ics",
  "config": {"summary": "keep", "location": "remove"}
}

Response (200 OK)

Same shape as GET /fetch: the anonymized calendar, served as text/calendar.

Error Responses

  • 400 Bad Request - Invalid URL, embedded credentials, private IP, malformed auth, or invalid ICS format

  • 408 Request Timeout - Request exceeded 10-second timeout

  • 413 Payload Too Large - Response exceeds 10 MB size limit

  • 422 Unprocessable Entity - Invalid auth shape, such as a bearer token with a username, or invalid field config

Example with curl

curl -X POST http://localhost:8000/fetch \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/calendar.ics", "auth": {"type": "basic", "username": "alice", "password": "secret"}}' \
  -o anonymized.ics

See #79.

POST /share#

Anonymize a calendar and generate a shareable link. Only available on the hosted service.

Request

POST /share HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="calendar.ics"
Content-Type: text/calendar

BEGIN:VCALENDAR
VERSION:2.0
...

Response (200 OK)

{
  "url": "https://icalendar-anonymizer.com/s/Kml529qs"
}

Error Responses

  • 400 Bad Request - Invalid ICS format or empty file

  • 413 Payload Too Large - File exceeds size limit

  • 500 Internal Server Error - Anonymization or storage failed

  • 503 Service Unavailable - R2 storage not configured (self-hosted instances)

Example with curl

curl -X POST https://icalendar-anonymizer.com/share \
  -F "file=@calendar.ics"

# Response: {"url":"https://icalendar-anonymizer.com/s/Kml529qs"}

POST /fernet-generate#

Generate an encrypted Fernet token for live calendar proxying. Only available when FERNET_KEY environment variable is set.

Request

POST /fernet-generate HTTP/1.1
Content-Type: application/json

{
  "url": "https://example.com/calendar.ics"
}

This endpoint accepts the same optional config and auth fields as POST /fetch. If the source calendar needs credentials, encrypt them into the token instead of putting them in the plain URL:

POST /fernet-generate HTTP/1.1
Content-Type: application/json

{
  "url": "https://example.com/calendar.ics",
  "auth": {"type": "basic", "username": "alice", "password": "secret"}
}

Response (200 OK)

{
  "url": "https://icalendar-anonymizer.com/fernet/gAAAAABl..."
}

Error Responses

  • 400 Bad Request - Invalid URL scheme, localhost, or private IP

  • 422 Unprocessable Entity - Invalid auth shape, such as a bearer token with a username

  • 503 Service Unavailable - Fernet not configured (FERNET_KEY not set)

Example with curl

curl -X POST http://localhost:8000/fernet-generate \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/calendar.ics"}'

# Response: {"url":"http://localhost:8000/fernet/gAAAAABl..."}

Security Features

  • Source URL validated for SSRF protection (same rules as /fetch)

  • Token encrypted with Fernet symmetric encryption

  • Unique random salt per token

  • Token contains authenticated data, preventing tampering

  • Any auth credentials are encrypted into the token, not exposed in the plain URL

  • Treat the token URL like a password: anyone with it can read the source calendar

GET /fernet/{token}#

Fetch and anonymize a calendar using an encrypted Fernet token. Fetches the source the same way GET /fetch does, including its encoding detection.

Request

GET /fernet/gAAAAABl... HTTP/1.1

Response (200 OK)

HTTP/1.1 200 OK
Content-Type: text/calendar
Content-Disposition: attachment; filename="anonymized.ics"
Cache-Control: no-cache

BEGIN:VCALENDAR
VERSION:2.0
...

Error Responses

  • 400 Bad Request - Invalid token, malformed payload, missing URL/salt, or invalid ICS format

  • 408 Request Timeout - Source calendar fetch exceeded 10-second timeout

  • 413 Payload Too Large - Source calendar exceeds 10 MB size limit

  • 503 Service Unavailable - Fernet not configured (FERNET_KEY not set)

  • Various HTTP status codes - Returns the actual HTTP status code from the upstream server

Example with curl

curl "http://localhost:8000/fernet/gAAAAABl..." -o anonymized.ics

How It Works

  1. Token is decrypted to retrieve the source URL, salt, and any stored auth credentials

  2. Source URL is validated and its DNS resolved and pinned, same as POST /fetch

  3. Calendar is fetched from the pinned address, with any stored credentials attached, re-validating and re-pinning DNS on every redirect

  4. Calendar is anonymized using the salt from the token

  5. Anonymized calendar is returned

This provides live proxying - the source is fetched each time, so the anonymized calendar stays up-to-date.

GET /s/{share_id}#

Retrieve a shared calendar by its ID (R2 static snapshot mode).

Request

GET /s/Kml529qs HTTP/1.1

Response (200 OK)

HTTP/1.1 200 OK
Content-Type: text/calendar
Content-Disposition: attachment; filename="calendar-Kml529qs.ics"
Cache-Control: public, max-age=86400

BEGIN:VCALENDAR
VERSION:2.0
...

Error Responses

  • 400 Bad Request - Invalid share ID format (must be 8 characters, alphanumeric with hyphen/underscore)

  • 404 Not Found - Share ID not found or expired

  • 503 Service Unavailable - R2 storage not configured

Example with curl

curl https://icalendar-anonymizer.com/s/Kml529qs -o calendar.ics

GET /health#

Health check endpoint for monitoring.

Response (200 OK)

{
  "status": "healthy",
  "version": "0.2.0",
  "r2_enabled": true,
  "fernet_enabled": false
}

Fields

status

Always "healthy" (endpoint returns 200 only when service is operational)

version

Package version string

r2_enabled

Whether R2 static snapshot shareable links are available (requires Cloudflare Workers environment)

fernet_enabled

Whether Fernet live proxy shareable links are available (requires FERNET_KEY environment variable)

Error Responses#

All error responses return JSON with the following format:

{
  "detail": "Error message describing what went wrong"
}

Common error scenarios:

Invalid ICS Format

{
  "detail": "Invalid ICS format: Expected BEGIN:VCALENDAR"
}

Empty Input

{
  "detail": "Input is empty"
}

Private IP Blocked

{
  "detail": "Access to private IP 192.168.1.1 is not allowed"
}

URL Fetch Failed

{
  "detail": "Failed to fetch URL: Connection timeout"
}

CORS Configuration#

The server enables CORS with wildcard origins for development:

allow_origins=["*"]
allow_credentials=False
allow_methods=["*"]
allow_headers=["*"]

Production Hardening

For production deployments, configure CORS to only allow your frontend domain:

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://yourdomain.com"],
    allow_credentials=True,
    allow_methods=["POST", "GET"],
    allow_headers=["Content-Type"],
)

Self-Hosting#

Docker Deployment#

Pull and run the Docker image:

docker pull ghcr.io/pycalendar/icalendar-anonymizer
docker run -p 8000:8000 ghcr.io/pycalendar/icalendar-anonymizer

The web service will be available at http://localhost:8000.

Build from source:

docker build -t icalendar-anonymizer .
docker run -p 8000:8000 icalendar-anonymizer

Manual Deployment#

For production deployment on a VPS or cloud server:

  1. Install Python 3.11 or later

  2. Create a virtual environment:

    python -m venv venv
    source venv/bin/activate  # On Windows: venv\Scripts\activate
    
  3. Install the package:

    pip install icalendar-anonymizer[web]
    
  4. Run with production settings:

    uvicorn icalendar_anonymizer.webapp.main:app \
      --host 0.0.0.0 \
      --port 8000 \
      --workers 4 \
      --log-level info
    
  5. Use a reverse proxy (nginx/Apache) for HTTPS and load balancing

Systemd Service#

Create /etc/systemd/system/icalendar-anonymizer.service:

[Unit]
Description=iCalendar Anonymizer Web Service
After=network.target

[Service]
Type=notify
User=www-data
Group=www-data
WorkingDirectory=/opt/icalendar-anonymizer
Environment="PATH=/opt/icalendar-anonymizer/venv/bin"
ExecStart=/opt/icalendar-anonymizer/venv/bin/uvicorn \
  icalendar_anonymizer.webapp.main:app \
  --host 0.0.0.0 \
  --port 8000 \
  --workers 4

[Install]
WantedBy=multi-user.target

Enable and start:

sudo systemctl enable icalendar-anonymizer
sudo systemctl start icalendar-anonymizer

Nginx Reverse Proxy#

Example nginx configuration:

server {
    listen 80;
    server_name anonymizer.example.com;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Add SSL with Let’s Encrypt:

sudo certbot --nginx -d anonymizer.example.com

Security Considerations#

SSRF Protection

/fetch, /fernet-generate, and /fernet/{token} resolve DNS once per hop, validate every resolved address, and pin the connection to a validated address, closing the DNS rebinding gap described under GET /fetch above. For high-security deployments, add further layers on top of this:

  • Use network-level firewall rules

  • Deploy in an isolated network segment

  • Implement additional rate limiting

  • Monitor for suspicious URL patterns

Input Validation

All endpoints validate:

  • Text encoding: UTF-8 first, then Windows-1252 and Latin-1 as a fallback for older calendar exports, see Command-line interface for details

  • iCalendar format (BEGIN:VCALENDAR required)

  • File size limits (10 MB for URL fetching)

Error Disclosure

Error messages include technical details to aid debugging. For production, consider customizing error handlers to limit information disclosure.

Testing#

Run the test suite:

pip install -e ".[test,web]"
pytest src/icalendar_anonymizer/tests/web/

Test coverage includes:

  • All three endpoints with valid and invalid inputs

  • SSRF protection (private IPs, localhost, redirects)

  • Encoding detection (UTF-8, Windows-1252, Latin-1, declared charsets)

  • Error handling scenarios

  • Large file handling

Performance#

Benchmarks

Approximate performance on a modern server:

  • JSON input (/anonymize): ~50ms for typical calendar

  • File upload (/upload): ~60ms including multipart parsing

  • URL fetch (/fetch): ~200ms including network latency

Scaling

For high-traffic deployments:

  • Increase uvicorn workers: --workers 8

  • Use multiple server instances behind a load balancer

  • Consider async worker pools for URL fetching

  • Implement caching for frequently accessed URLs (see Issue #30)

Troubleshooting#

ImportError: No module named ‘fastapi’

Install the web extras:

pip install icalendar-anonymizer[web]

Connection Refused

Check if the server is running:

curl http://localhost:8000/docs

If not, start it:

uvicorn icalendar_anonymizer.webapp.main:app --reload

CORS Errors in Browser

The server allows all origins by default. If you’re seeing CORS errors, check that your frontend is making requests to the correct URL.

Timeout on /fetch

The endpoint has a 10-second timeout. For slow servers, the request will fail with a timeout error. This is intentional to prevent resource exhaustion.

See Also#