Skip to content

CORS preflight (OPTIONS) returns 404 because CorsMiddleware::beforeCors doesn't short-circuit and Router::any doesn't register OPTIONS #109

@justin-k-bruce

Description

@justin-k-bruce

Summary

With \Tina4\Middleware\CorsMiddleware registered globally, cross-origin requests that require a CORS preflight (e.g. POST with Authorization and Content-Type: application/json) fail in the browser because the OPTIONS preflight gets a 404 response. Simple requests (e.g. GET /api/foo) work fine.

Reproduction

index.php:

<?php
require "./vendor/autoload.php";
\Tina4\Middleware::use(\Tina4\Middleware\CorsMiddleware::class);
\$app = new \Tina4\App(__DIR__);
\$app->handle();

src/routes/login.php:

Router::post("/api/login", function (\$request, \$response) {
    return \$response(['ok' => true]);
})->secure(false);

.env:

TINA4_CORS_ORIGINS=http://localhost:3000

Then:

$ curl -i -X OPTIONS -H "Origin: http://localhost:3000" \
       -H "Access-Control-Request-Method: POST" \
       -H "Access-Control-Request-Headers: authorization,content-type" \
       http://127.0.0.1:7146/api/login

HTTP/1.1 404 Not Found
...

Browser reports: "CORS Preflight Did Not Succeed" and the real POST never fires.

Root cause

Two interacting issues in Tina4/Router.php:

  1. CorsMiddleware::beforeCors sets status 204 on OPTIONS (see CorsMiddleware.php:210-212), but Router::dispatchInner only short-circuits middleware on status >= 400:

    if (\$response->getStatusCode() >= 400) {
        return \$response;
    }

    204 is not >= 400, so dispatch continues into match(\$request->method, \$request->path).

  2. Router::any() only registers GET|POST|PUT|PATCH|DELETE — there is no way to register a catch-all OPTIONS handler:

    public static function any(string \$path, callable \$callback): self
    {
        foreach (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as \$method) {
            self::addRoute(\$method, \$path, \$callback);
        }
        ...
    }

    So match() returns null for every OPTIONS request, dispatchInner falls through to renderError(\$response, 404, 'Not Found', \$request->path), and the 204 from beforeCors is replaced with a 404 JSON body.

Because renderError's json() / html() preserve existing headers, the CORS headers are still there — but the browser rejects the preflight because the status is not 2xx.

Suggested fix

Any one of these would resolve it:

  1. Short-circuit OPTIONS after CORS middleware runs. In dispatchInner, after the CORS pre-pass, if \$request->method === 'OPTIONS' and the CORS middleware set a 2xx status, return immediately without running the other middleware or hitting match():

    if (strtoupper(\$request->method) === 'OPTIONS' && \$response->getStatusCode() < 400) {
        return \$response;
    }
  2. Include OPTIONS in Router::any()'s method list — so users can at least register a catch-all preflight handler manually.

  3. Add Router::options() as a sibling to get/post/put/patch/delete.

Option 1 is the most correct because it matches what every CORS-aware framework does and doesn't require users to touch their route files. Options 2 and 3 are useful regardless of option 1 for users who want full control.

Workaround

Until this is fixed, a manual short-circuit at the top of index.php works:

if ((\$_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
    \$origin = \$_SERVER['HTTP_ORIGIN'] ?? '';
    \$allowed = array_map('trim', explode(',', \Tina4\DotEnv::getEnv('TINA4_CORS_ORIGINS', '*') ?? '*'));
    \$resolved = in_array('*', \$allowed, true) ? '*'
        : (in_array(\$origin, \$allowed, true) ? \$origin : null);
    if (\$resolved !== null) {
        header("Access-Control-Allow-Origin: {\$resolved}");
        header('Access-Control-Allow-Methods: ' . \Tina4\DotEnv::getEnv('TINA4_CORS_METHODS', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'));
        header('Access-Control-Allow-Headers: ' . \Tina4\DotEnv::getEnv('TINA4_CORS_HEADERS', 'Content-Type,Authorization,X-Request-ID'));
        header('Access-Control-Max-Age: ' . \Tina4\DotEnv::getEnv('TINA4_CORS_MAX_AGE', '86400'));
        if (\$resolved !== '*') {
            header('Vary: Origin');
            header('Access-Control-Allow-Credentials: true');
        }
    }
    http_response_code(204);
    exit;
}

This bypasses the framework entirely for preflight, which defeats the point of CorsMiddleware.

Version observed: tina4stack/tina4php v3.10.85

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions