Skip to content

type(security): CSRF Protection Missing for Resource-Intensive Endpoints #98

@jonasyr

Description

@jonasyr

Severity: Medium-High
Component: Express CORS Configuration / Request Handling
Status: Unresolved

Description

While the API is largely unauthenticated (reducing classic CSRF impact), the application accepts preflightless content-types (application/x-www-form-urlencoded, text/plain) on state-changing POST endpoints. This enables cross-site request abuse for resource-intensive operations like repository cloning, potentially leading to denial-of-service or resource exhaustion.

Evidence from Report

From csrf_lowrate_20251114T102308Z.json:

  • POST requests with Content-Type: application/x-www-form-urlencoded accepted (status 400 due to validation, not CSRF protection)
  • POST requests with Content-Type: text/plain accepted
  • CORS headers: Access-Control-Allow-Origin: http://localhost:5173, Access-Control-Allow-Credentials: true

Proof of Concept:

<!-- Attacker's malicious page -->
<form action="http://target-gitray.com/api/repositories" method="POST">
  <input type="hidden" name="repoUrl" value="https://github.com/torvalds/linux.git">
  <input type="hidden" name="filterOptions" value='{"limit": 10000}'>
</form>
<script>document.forms[0].submit()</script>

Impact

  • Attackers can force victims to trigger expensive Git clone operations
  • Resource exhaustion attacks against the backend infrastructure
  • Potential abuse of compute resources and bandwidth
  • Could impact service availability for legitimate users

Affected Endpoints

All POST endpoints that trigger resource-intensive operations:

  • /api/repositories
  • /api/repositories/heatmap
  • /api/repositories/contributors
  • /api/repositories/churn
  • /api/repositories/full-data
  • /api/commits/stream

Recommended Fix

Option 1: Strict Content-Type Enforcement (Recommended)

// apps/backend/src/middleware/csrfProtection.ts
export const strictContentType = (req: Request, res: Response, next: NextFunction) => {
  if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
    const contentType = req.get('Content-Type') || '';
    
    // Only allow application/json
    if (!contentType.startsWith('application/json')) {
      return res.status(415).json({
        error: 'Unsupported Media Type',
        code: 'INVALID_CONTENT_TYPE',
        message: 'Only application/json is accepted for state-changing operations'
      });
    }
    
    // Require custom header to prevent simple CSRF
    const customHeader = req.get('X-Requested-With');
    if (!customHeader) {
      return res.status(403).json({
        error: 'Forbidden',
        code: 'MISSING_CUSTOM_HEADER',
        message: 'X-Requested-With header required'
      });
    }
  }
  
  next();
};

// Apply to state-changing routes
app.use('/api/repositories', strictContentType);
app.use('/api/commits/stream', strictContentType);

Option 2: Origin/Referer Validation

// apps/backend/src/middleware/csrfProtection.ts
const ALLOWED_ORIGINS = [
  'http://localhost:5173',
  process.env.FRONTEND_URL
].filter(Boolean);

export const validateOrigin = (req: Request, res: Response, next: NextFunction) => {
  if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
    const origin = req.get('Origin');
    const referer = req.get('Referer');
    
    const isValidOrigin = origin && ALLOWED_ORIGINS.includes(origin);
    const isValidReferer = referer && ALLOWED_ORIGINS.some(o => referer.startsWith(o));
    
    if (!isValidOrigin && !isValidReferer) {
      return res.status(403).json({
        error: 'Forbidden',
        code: 'INVALID_ORIGIN',
        message: 'Request origin not allowed'
      });
    }
  }
  
  next();
};

Option 3: CSRF Tokens (Most Secure, More Complex)

import csrf from 'csurf';

const csrfProtection = csrf({
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict'
  }
});

app.use(csrfProtection);

// Provide token endpoint
app.get('/api/csrf-token', (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

Testing

# Test 1: Verify form-encoded requests are blocked
curl -X POST http://localhost:3001/api/repositories \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "Origin: http://evil.com" \
  -d "repoUrl=https://github.com/octocat/Hello-World.git"
# Expected: 415 or 403

# Test 2: Verify text/plain requests are blocked  
curl -X POST http://localhost:3001/api/repositories \
  -H "Content-Type: text/plain" \
  -H "Origin: http://evil.com" \
  -d '{"repoUrl":"https://github.com/octocat/Hello-World.git"}'
# Expected: 415 or 403

# Test 3: Verify valid JSON requests still work
curl -X POST http://localhost:3001/api/repositories \
  -H "Content-Type: application/json" \
  -H "X-Requested-With: XMLHttpRequest" \
  -H "Origin: http://localhost:5173" \
  -d '{"repoUrl":"https://github.com/octocat/Hello-World.git"}'
# Expected: 200 or validation error (not CSRF error)

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions