singularity

basic http2 server

Nine HTTP Edge Cases

1. The Range Header Problem

Range headers exist for specific reasons: resuming downloads, seeking in videos, downloading file chunks, etc. They’re part of HTTP/1.1, defined in RFC 7233, and most of us never think about them until something breaks.

@GetMapping("/api/files/{filename}")
public ResponseEntity<?> downloadFile(
    @PathVariable String filename,
    @RequestHeader(value = "Range", required = false) String rangeHeader) throws IOException {

    Resource file = fileService.loadAsResource(filename);
    long size = file.contentLength();

    if (rangeHeader == null || rangeHeader.isBlank()) {
        return ResponseEntity.ok()
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .body(file);
    }

    List<HttpRange> ranges;
    try {
        ranges = HttpRange.parseRanges(rangeHeader); // this will throw an exception on bad syntax or >100 ranges
    } catch (IllegalArgumentException ex) {
        // RFC 7233: 416 for unsatisfiable/malformed; include Content-Range with '*'
        return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
            .header(HttpHeaders.CONTENT_RANGE, "bytes */" + size)
            .build();
    }

    // Cap ranges and total bytes to defend resources
    final int MAX_RANGES = 5;
    final long MAX_BYTES = Math.min(size, 8L * 1024 * 1024);

    if (ranges.size() > MAX_RANGES) {
        // Option A: ignore ranges -> return full content (200)
        // Option B: 416 to force client to retry sanely, pick a consistent policy
        return ResponseEntity.ok().body(file);
    }

    long totalBytes = 0L;
    for (HttpRange r : ranges) {
        ResourceRegion rr = r.toResourceRegion(file);
        totalBytes += rr.getCount();
        if (totalBytes > MAX_BYTES) {
            return ResponseEntity.ok().body(file); // ignore ranges if too large
        }
    }

    // Let Spring write 206 / multipart or single-part automatically
    return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(HttpRange.toResourceRegions(ranges, file));
}
import rangeParser from 'range-parser';
import fs from 'fs';
import path from 'path';

app.get('/files/:filename', (req, res) => {
  const filename = req.params.filename;
  const filePath = path.join(__dirname, 'files', filename);
  const stat = fs.statSync(filePath);
  const fileSize = stat.size;
  const rangeHeader = req.headers.range;

  if (rangeHeader) {
    // Parse with range-parser (same as express.static)
    const ranges = rangeParser(fileSize, rangeHeader, { combine: true });

    if (ranges === -1) {
      // Unsatisfiable -> 416 per RFC
      return res.status(416)
        .set('Content-Range', `bytes */${fileSize}`)
        .end();
    }
    if (ranges === -2) {
      // Malformed -> ignore Range, serve full
      return res.sendFile(filePath);
    }

    // Limit ranges & aggregate size
    const MAX_RANGES = 5;
    const MAX_BYTES = 8 * 1024 * 1024;

    if (ranges.length > MAX_RANGES) {
      // Too many -> ignore Range, serve full
      return res.sendFile(filePath);
    }

    let totalBytes = 0;
    for (const r of ranges) {
      totalBytes += (r.end - r.start + 1);
      if (totalBytes > MAX_BYTES) {
        // Too heavy -> ignore Range
        return res.sendFile(filePath);
      }
    }

    // If one range, serve 206 single part
    if (ranges.length === 1) {
      const { start, end } = ranges[0];
      res.status(206)
        .set('Content-Range', `bytes ${start}-${end}/${fileSize}`)
        .set('Accept-Ranges', 'bytes')
        .set('Content-Length', end - start + 1);
      return fs.createReadStream(filePath, { start, end }).pipe(res);
    }

    // If multiple ranges, you'd need to construct multipart/byteranges.
    // Node/Express doesn't do this automatically; consider rejecting or serving full content
    return res.sendFile(filePath);
  }

  // No Range -> serve full
  res.sendFile(filePath);
});

2. Content-Type Enforcement Prevents Weird Parser Behavior

POST /api/users HTTP/1.1
Content-Type: text/plain
Content-Length: 87

{"name": "Robert'; DROP TABLE users;--", "email": "bobby@tables.com"}

3. Accept Header Negotiation Gets Weird Fast

Accept: application/json;q=1.0, application/xml;q=0.5, */*;q=0.1

app.get('/api/data', (req, res) => {
  // req.accepts() properly parses quality values
  const accepts = req.accepts(['json', 'xml']);
  
  if (accepts === 'json') {
    res.json(data);
  } else if (accepts === 'xml') {
    res.type('application/xml').send(toXml(data));
  } else {
    res.status(406).send('Not Acceptable');
  }
});

4. Method Not Allowed Should Tell You What Works

5. Compression Configuration Is Never Where You Think

6. Character Encoding Silently Corrupts Your Database

7. Path Traversal Lets Attackers Read Arbitrary Files


    const path = require('path');

    app.get('/files/*', (req, res) => {
    const baseDir = path.resolve('/var/app/uploads');
    const requestedPath = path.join(baseDir, req.params[0]);
    const resolved = path.resolve(requestedPath);
    
    if (!resolved.startsWith(baseDir)) {
        return res.status(403).send('Forbidden');
    }
    
    res.sendFile(resolved);
    });
    from pathlib import Path

    def serve_file(filename):
        base_dir = Path('/var/app/uploads').resolve()
        requested = (base_dir / filename).resolve()
        
        # Verify resolved path is inside base directory
        if not requested.is_relative_to(base_dir):
            return 403, "Forbidden"
        
        return send_file(requested)

8. Request Size Limits Prevent Memory Exhaustion

9. Transfer-Encoding Enables Request Smuggling

HTTP/2 and HTTP/3 Change the Attack Surface

Where Frameworks End and Your Responsibility Begins