signedurl

package module
v0.0.0-...-fb50e35 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 14, 2025 License: MIT Imports: 13 Imported by: 0

README

Caddy Signed URL Plugin

A Caddy HTTP handler plugin that validates HMAC-SHA256 signed URLs. Perfect for creating time-limited, secure access to protected resources like file downloads, API endpoints, or any content that requires temporary authorization.

Features

  • 🔐 HMAC-SHA256 signature verification - Industry-standard cryptographic signing
  • Optional expiration - Create time-limited URLs that automatically expire
  • 📝 Audit logging - Track when URLs were issued and accessed
  • 🔄 Query parameter ordering - Immune to parameter reordering attacks
  • 🎯 Flexible configuration - Support for query parameters and HTTP headers
  • 🚀 High performance - Minimal overhead with constant-time signature comparison

Installation

Build Caddy with this plugin using xcaddy:

xcaddy build --with github.com/hookenz/caddy-signedurls

Or add it to your go.mod:

go get github.com/hookenz/caddy-signedurls

Quick Start

Basic Usage

Protect file server routes with signed URLs:

example.com {
    route /downloads/* {
        signed_url "your-secret-key-here"
        file_server {
            root /var/www/downloads
        }
    }
}
With Expiration

Create URLs that expire after 1 hour:

example.com {
    route /shared/* {
        signed_url "your-secret-key-here"
        file_server {
            root /var/www/shared
        }
    }
}

Configuration

Inline Syntax
signed_url "your-secret-key"
Block Syntax
signed_url {
    secret "your-secret-key"       # Required
    algorithm "sha265"             # Optional, default: "sha256", options: sha265, sha384, sha512
    query_param "signature"        # Optional, default: "signature"
    header "X-Signature"           # Optional, default: "X-Signature"
    expires_param "expires"        # Optional, default: "expires"
    issued_param "issued"          # Optional, default: "issued"
}
Configuration Options
Option Type Default Description
secret string (required) Secret key for HMAC signing
algorithm string sha256 The signature algorithm used by the signer
query_param string signature Query parameter name for signature
header string X-Signature HTTP header name for signature
expires_param string expires Query parameter name for expiration timestamp
issued_param string issued Query parameter name for issued timestamp

Generating Signed URLs

Python
import hmac
import hashlib
import time
from urllib.parse import urlencode

secret = "your-secret-key"
path = "/downloads/document.pdf"
issued = int(time.time())
expires = issued + 3600  # Valid for 1 hour

# Build query parameters (automatically sorted)
params = {
    'issued': issued,
    'expires': expires
}
query_string = urlencode(sorted(params.items()))

# Sign the path with sorted query parameters
to_sign = f"{path}?{query_string}"
signature = hmac.new(
    secret.encode(),
    to_sign.encode(),
    hashlib.sha256
).hexdigest()

# Final URL
url = f"{path}?{query_string}&signature={signature}"
print(url)
# /downloads/document.pdf?expires=1731632400&issued=1731628800&signature=abc123...
Go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "net/url"
    "time"
)

func generateSignedURL(secret, path string, ttl time.Duration) string {
    issued := time.Now().Unix()
    expires := issued + int64(ttl.Seconds())
    
    // Build query with sorted params
    query := url.Values{}
    query.Set("issued", fmt.Sprintf("%d", issued))
    query.Set("expires", fmt.Sprintf("%d", expires))
    
    // Sign the path with query params
    toSign := path + "?" + query.Encode()
    h := hmac.New(sha256.New, []byte(secret))
    h.Write([]byte(toSign))
    signature := hex.EncodeToString(h.Sum(nil))
    
    // Add signature to query
    query.Set("signature", signature)
    
    return path + "?" + query.Encode()
}

func main() {
    secret := "your-secret-key"
    path := "/downloads/document.pdf"
    url := generateSignedURL(secret, path, 1*time.Hour)
    fmt.Println(url)
}
Node.js
const crypto = require('crypto');

function generateSignedURL(secret, path, ttlSeconds) {
    const issued = Math.floor(Date.now() / 1000);
    const expires = issued + ttlSeconds;
    
    // Build query params (URLSearchParams sorts automatically)
    const params = new URLSearchParams();
    params.set('issued', issued);
    params.set('expires', expires);
    params.sort();
    
    // Sign the path with query params
    const toSign = `${path}?${params.toString()}`;
    const signature = crypto
        .createHmac('sha256', secret)
        .update(toSign)
        .digest('hex');
    
    // Add signature to query
    params.set('signature', signature);
    
    return `${path}?${params.toString()}`;
}

const url = generateSignedURL('your-secret-key', '/downloads/document.pdf', 3600);
console.log(url);
PHP
<?php
function generateSignedURL($secret, $path, $ttl) {
    $issued = time();
    $expires = $issued + $ttl;
    
    // Build query params
    $params = [
        'issued' => $issued,
        'expires' => $expires
    ];
    
    // Sort params for consistent signature
    ksort($params);
    $queryString = http_build_query($params);
    
    // Sign the path with query params
    $toSign = $path . '?' . $queryString;
    $signature = hash_hmac('sha256', $toSign, $secret);
    
    // Add signature to query
    $params['signature'] = $signature;
    ksort($params);
    
    return $path . '?' . http_build_query($params);
}

$url = generateSignedURL('your-secret-key', '/downloads/document.pdf', 3600);
echo $url;
?>

Usage Examples

Protecting File Downloads
files.example.com {
    route /secure/* {
        signed_url "super-secret-key"
        file_server {
            root /var/www/secure-files
        }
    }
    
    # Public files don't need signing
    route /public/* {
        file_server {
            root /var/www/public-files
        }
    }
}
Protecting API Endpoints
api.example.com {
    route /api/private/* {
        signed_url "api-secret-key"
        reverse_proxy localhost:8080
    }
    
    route /api/public/* {
        reverse_proxy localhost:8080
    }
}
Custom Parameter Names
cdn.example.com {
    route /media/* {
        signed_url {
            secret "cdn-secret"
            query_param "sig"
            expires_param "exp"
            issued_param "iat"
        }
        file_server {
            root /var/www/media
        }
    }
}
Using Header-Based Signatures

For APIs where you don't want signatures in URLs:

api.example.com {
    route /api/internal/* {
        signed_url {
            secret "internal-api-key"
            header "X-API-Signature"
        }
        reverse_proxy localhost:9000
    }
}

Client usage:

curl -H "X-Signature: abc123..." https://api.example.com/api/internal/data

How It Works

Signature Validation Process
  1. Extract signature - Check query parameter or HTTP header
  2. Parse timestamps - Validate issued and expires if present
  3. Check expiration - Reject if URL has expired
  4. Build signed string - Reconstruct path + sorted query params (minus signature)
  5. Calculate HMAC - Generate expected signature using secret key
  6. Compare - Use constant-time comparison to prevent timing attacks
  7. Allow or deny - Return 200 OK or 401 Unauthorized
What Gets Signed

The signature covers:

  • The full request path (e.g., /downloads/file.pdf)
  • All query parameters except signature itself
  • Query parameters are automatically sorted alphabetically

Example:

Original URL: /file?expires=123&issued=456&signature=abc
Signed string: /file?expires=123&issued=456
Query Parameter Ordering

The plugin automatically handles parameter reordering. These URLs all produce the same signature:

/file?expires=123&issued=456&signature=abc
/file?issued=456&expires=123&signature=abc
/file?signature=abc&expires=123&issued=456

Security Considerations

Secret Key Management
  • Use strong secrets - Minimum 32 characters, randomly generated
  • Rotate regularly - Change secrets periodically
  • Keep secrets secure - Never commit to version control
  • Use environment variables - Store secrets in environment or secret management systems
{
    # Load secret from environment
    signed_url {
        secret {$SIGNED_URL_SECRET}
    }
}
Expiration Times
  • Short TTLs recommended - 1 hour or less for sensitive content
  • Balance security and usability - Consider your use case
  • No default expiration - Must explicitly set expires parameter
HTTPS Required

Always use HTTPS in production to prevent signature interception:

example.com {
    # Caddy automatically handles HTTPS
    route /downloads/* {
        signed_url "secret"
        file_server
    }
}

Logging

The plugin uses structured logging with different levels:

INFO (Startup)
{
  "level": "info",
  "logger": "http.handlers.signed_url",
  "msg": "signed_url handler provisioned",
  "query_param": "signature",
  "header": "X-Signature",
  "expires_param": "expires",
  "issued_param": "issued"
}
WARN (Authentication Failures)
{
  "level": "warn",
  "logger": "http.handlers.signed_url",
  "msg": "invalid signature",
  "path": "/downloads/file.pdf",
  "signature_source": "query",
  "remote_addr": "192.168.1.100:54321"
}
DEBUG (Successful Validations)
{
  "level": "debug",
  "logger": "http.handlers.signed_url",
  "msg": "signature validated successfully",
  "path": "/downloads/file.pdf",
  "signature_source": "query",
  "issued_at": 1731628800,
  "age_seconds": 450,
  "expires_at": 1731632400,
  "ttl_seconds": 3150
}

Enable debug logging:

caddy run --config Caddyfile --debug

Or in Caddyfile:

{
    log {
        level DEBUG
    }
}

Testing

Run the test suite:

# All tests
go test -v

# Specific test
go test -v -run TestSignedURL_QueryParamOrdering

# With coverage
go test -cover

# Benchmarks
go test -bench=.

Error Responses

Status Code Message Cause
400 Bad Request Invalid expires parameter Expires is not a valid Unix timestamp
400 Bad Request Invalid issued parameter Issued is not a valid Unix timestamp
401 Unauthorized Missing signature No signature in query param or header
401 Unauthorized Invalid signature Signature verification failed
401 Unauthorized URL has expired Current time is past expiration

Performance

The plugin is designed for high performance:

  • Constant-time comparison - Prevents timing attacks
  • Minimal allocations - Efficient memory usage
  • Early validation - Checks expiration before signature calculation
  • No external dependencies - Uses standard library crypto

Benchmark results (example):

BenchmarkSignedURL_ValidSignature-8    500000    3421 ns/op

Troubleshooting

Common Issues

1. "Invalid signature" errors

  • Check that the secret key matches on both sides
  • Verify query parameters are sorted alphabetically when signing
  • Ensure the path includes query parameters in the signature
  • Check that signature is excluded from the signed string

2. "URL has expired"

  • Verify system clocks are synchronized (use NTP)
  • Check timezone is not affecting Unix timestamp generation
  • Ensure TTL is appropriate for your use case

3. "Missing signature"

  • Verify signature parameter name matches configuration
  • Check if using header-based signatures, the header name is correct
  • Ensure signature is being passed in the request
Debug Tips
  1. Enable debug logging to see successful validations
  2. Log the signed string on both client and server
  3. Test with curl to isolate client issues:
curl -v "https://example.com/file?expires=123&signature=abc"
  1. Use online HMAC calculators to verify signature generation

Contributing

Contributions are welcome! Please:

  1. Fork the repository
  2. Create a feature branch
  3. Add tests for new functionality
  4. Ensure all tests pass
  5. Submit a pull request

License

MIT License

  • Caddy - The web server this plugin extends
  • xcaddy - Build tool for Caddy with plugins

Support

Changelog

v0.1.0
  • Initial release
  • HMAC-SHA256 signature validation
  • Optional expiration support
  • Issued timestamp for audit logging
  • Query parameter and header support
  • Test suite

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type SignedURL

type SignedURL struct {
	// Secret key for HMAC signing
	Secret string `json:"secret,omitempty"`

	// Algorithm is the hash algorithm to use (default: "sha256")
	// Supported: sha256, sha384, sha512
	Algorithm string `json:"algorithm,omitempty"`

	// QueryParam is the query parameter name for the signature (default: "signature")
	QueryParam string `json:"query_param,omitempty"`

	// Header is the HTTP header name for the signature (default: "X-Signature")
	Header string `json:"header,omitempty"`

	// ExpiresParam is the query parameter name for expiration timestamp (default: "expires")
	ExpiresParam string `json:"expires_param,omitempty"`

	// IssuedParam is the query parameter name for issued/generated timestamp (default: "issued")
	IssuedParam string `json:"issued_param,omitempty"`
	// contains filtered or unexported fields
}

SignedURL implements an HTTP handler that validates HMAC signatures

func (SignedURL) CaddyModule

func (SignedURL) CaddyModule() caddy.ModuleInfo

CaddyModule returns the Caddy module information

func (*SignedURL) Provision

func (s *SignedURL) Provision(ctx caddy.Context) error

Provision implements caddy.Provisioner

func (SignedURL) ServeHTTP

func (s SignedURL) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error

ServeHTTP implements caddyhttp.MiddlewareHandler

func (*SignedURL) UnmarshalCaddyfile

func (s *SignedURL) UnmarshalCaddyfile(d *caddyfile.Dispenser) error

UnmarshalCaddyfile implements caddyfile.Unmarshaler

func (*SignedURL) Validate

func (s *SignedURL) Validate() error

Validate implements caddy.Validator

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL