Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,73 @@ $request->psr; // the PSR-7 request, for everything else
is an ordinary thing to receive from the open internet, and a handler replying
`400` reads better than one wrapped in a `try`.

## Middleware

```php
#[Route(Method::POST, '/api/links', middleware: [VerifySignature::class])]
final readonly class CreateLink { /* ... */ }
```

```php
final readonly class VerifySignature implements Middleware
{
public function __invoke(Request $request, callable $next): Response
{
return $this->valid($request) ? $next($request) : Response::unauthorized();
}
}
```

The first one listed is outermost. Returning an answer instead of calling
`$next` stops the request there — the handler never runs, and neither does any
middleware behind it. That is the whole reason this exists: a check on whether
the caller may be asking has to be able to stop the work, not merely disapprove
of it afterwards.

Middleware are built by the container, so they may take whatever they need.

## Deciding who is asking

There are no sessions here, and that is deliberate — see below. What there is
covers what a bot's HTTP layer actually needs:

```php
$request->cookie('session'); // off this request's Cookie header
$request->bearerToken(); // from "Authorization: Bearer ..."

Secret::matches($configured, $request->bearerToken() ?? '');
Secret::signed($request->body(), $request->header('X-Signature') ?? '', $secret);

return Response::noContent()->withCookie(new Cookie('session', $id, secure: true));
```

`Secret` compares in constant time. `===` on a secret stops at the first byte
that differs, and that difference is measurable over enough requests — which is
how a signature gets guessed one byte at a time by somebody with a loop and
patience. `signed()` takes the **raw** body, because re-encoding changes bytes
and the signature is over what was actually sent.

Cookies go out on the response, with `HttpOnly`, `SameSite=Lax` and `Path=/` by
default. `Secure` is left to you: a bot behind a plain-HTTP proxy would
otherwise set a cookie the browser never returns, which is a bug that looks like
a login loop.

## Never use PHP sessions here

`session_start()` and `$_SESSION` are unsafe in this server, and not in a subtle
way. PHP keeps **one** session per process. Under PHP-FPM that is fine because
the process *is* the request; here the process serves thousands of requests over
weeks, so the first caller's session becomes the process's session and every
caller after them reads it. With fibers, two requests interleave inside it.

The same goes for `$_GET`, `$_POST`, `$_COOKIE` and `$_SERVER`: they hold
whatever the CLI process started with, not the request being served. And
`setcookie()` and `header()` write nowhere — the answer is the `Response`.

For the same reason a **handler must not keep state on itself**. It is built by
the container, which may hand back the same instance next time; the request is
given as an argument precisely so that nothing has to be stashed.

## Configuration

`app/config/http.config.php`:
Expand Down
6 changes: 6 additions & 0 deletions src/Attributes/Route.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,14 @@
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final readonly class Route
{
/**
* @param list<class-string<\Tempcord\Plugins\Http\Http\Middleware>> $middleware
* run in the order given, outermost first; any of them may answer
* instead of letting the handler run
*/
public function __construct(
public Method $method,
public string $path,
public array $middleware = [],
) {}
}
1 change: 1 addition & 0 deletions src/Compiler/RouteCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public function compile(ClassReflector $class, Route $route): RouteDefinition
parameters: $parameters,
handler: $class->getName(),
invoke: $class->getMethod('__invoke'),
middleware: $route->middleware,
);
}

Expand Down
2 changes: 2 additions & 0 deletions src/Definitions/RouteDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* segment captured under its own name
* @param list<string> $parameters the segment names, in the order the path
* declares them
* @param list<class-string<\Tempcord\Plugins\Http\Http\Middleware>> $middleware
*/
public function __construct(
public Method $method,
Expand All @@ -25,6 +26,7 @@ public function __construct(
public array $parameters,
public string $handler,
public MethodReflector $invoke,
public array $middleware = [],
) {}

/**
Expand Down
67 changes: 67 additions & 0 deletions src/Http/Cookie.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace Tempcord\Plugins\Http\Http;

/**
* A cookie on the way out.
*
* The defaults are the safe ones rather than PHP's: HttpOnly so script cannot
* read it, SameSite=Lax so it is not sent along with a cross-site request, and
* a path of "/". Secure is left to the caller, since a bot served over plain
* HTTP behind a proxy would otherwise set a cookie the browser never returns.
*/
final readonly class Cookie
{
public function __construct(
public string $name,
public string $value,
public ?int $expiresAt = null,
public string $path = '/',
public ?string $domain = null,
public bool $secure = false,
public bool $httpOnly = true,
public SameSite $sameSite = SameSite::Lax,
) {}

/**
* A cookie that tells the browser to forget the one of the same name.
*/
public static function forget(string $name, string $path = '/'): self
{
return new self($name, '', expiresAt: 0, path: $path);
}

public function header(): string
{
/*
* rawurlencode, not urlencode: the latter writes a space as "+", which
* a browser hands back verbatim rather than as a space.
*/
$parts = [$this->name . '=' . rawurlencode($this->value)];

if ($this->expiresAt !== null) {
$parts[] = 'Expires=' . gmdate('D, d M Y H:i:s \G\M\T', $this->expiresAt);
$parts[] = 'Max-Age=' . max(0, $this->expiresAt - time());
}

$parts[] = 'Path=' . $this->path;

if ($this->domain !== null) {
$parts[] = 'Domain=' . $this->domain;
}

if ($this->secure) {
$parts[] = 'Secure';
}

if ($this->httpOnly) {
$parts[] = 'HttpOnly';
}

$parts[] = 'SameSite=' . $this->sameSite->value;

return implode('; ', $parts);
}
}
23 changes: 23 additions & 0 deletions src/Http/Middleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Tempcord\Plugins\Http\Http;

/**
* Something that runs before a handler, and may decide it never runs.
*
* Built by the container, so a middleware may take whatever it needs — the
* configuration holding a shared secret, a clock, a logger.
*
* Returning an answer instead of calling $next stops the request there, which
* is the whole point for anything that checks a caller's right to be asking:
* the handler is never reached, so it cannot half-do the work it was asked for.
*/
interface Middleware
{
/**
* @param callable(Request): Response $next
*/
public function __invoke(Request $request, callable $next): Response;
}
33 changes: 33 additions & 0 deletions src/Http/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,39 @@ public function header(string $name): ?string
return $this->psr->hasHeader($name) ? $this->psr->getHeaderLine($name) : null;
}

/**
* A cookie the browser sent, read off this request's own header.
*
* Never $_COOKIE: in a long-running server the superglobals hold whatever
* the process was started with, not what the person on the other end of
* this socket sent.
*/
public function cookie(string $name): ?string
{
$cookies = $this->psr->getCookieParams();
$value = $cookies[$name] ?? null;

return is_scalar($value) ? (string) $value : null;
}

/**
* The token from an "Authorization: Bearer ..." header.
*
* Only Bearer: anything else is a different scheme with different rules,
* and quietly treating it as a token would accept credentials meant for
* something else entirely.
*/
public function bearerToken(): ?string
{
$header = $this->header('Authorization');

if ($header === null || !preg_match('/\ABearer\s+(\S+)\z/i', $header, $found)) {
return null;
}

return $found[1];
}

public function body(): string
{
return (string) $this->psr->getBody();
Expand Down
38 changes: 36 additions & 2 deletions src/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,34 @@
*/
final readonly class Response
{
/**
* @param array<string, string> $headers
* @param list<Cookie> $cookies
*/
private function __construct(
public int $status,
public string $body,
/** @var array<string, string> */
public array $headers,
public array $cookies = [],
) {}

/**
* The same answer, with a cookie set on it.
*
* On the response rather than through setcookie(): there are no headers to
* send in a long-running server, and a cookie belongs to one answer rather
* than to the process that happened to build it.
*/
public function withCookie(Cookie $cookie): self
{
return new self($this->status, $this->body, $this->headers, [...$this->cookies, $cookie]);
}

public function withHeader(string $name, string $value): self
{
return new self($this->status, $this->body, [...$this->headers, $name => $value], $this->cookies);
}

/**
* @param array<string, string> $headers
*/
Expand Down Expand Up @@ -65,6 +86,19 @@ public static function unauthorized(string $message = 'Unauthorized'): self

public function toReact(): ReactResponse
{
return new ReactResponse($this->status, $this->headers, $this->body);
$headers = $this->headers;

/*
* Set-Cookie is the one header that may legitimately appear more than
* once, so the values go out as a list rather than being joined.
*/
if ($this->cookies !== []) {
$headers['Set-Cookie'] = array_map(
static fn(Cookie $cookie) => $cookie->header(),
$this->cookies,
);
}

return new ReactResponse($this->status, $headers, $this->body);
}
}
17 changes: 17 additions & 0 deletions src/Http/SameSite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Tempcord\Plugins\Http\Http;

enum SameSite: string
{
case Strict = 'Strict';
case Lax = 'Lax';

/**
* Sent with every cross-site request, and refused by browsers unless the
* cookie is also Secure.
*/
case None = 'None';
}
45 changes: 45 additions & 0 deletions src/Http/Secret.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Tempcord\Plugins\Http\Http;

/**
* Comparing things an attacker gets to guess at.
*
* Every comparison here runs in the same time whatever the inputs. `===` on a
* secret stops at the first byte that differs, and the difference is measurable
* over enough requests — which is how a signature gets guessed one byte at a
* time by somebody with patience and a loop.
*/
final class Secret
{
/**
* Whether two secrets are the same, without saying how far along they
* stopped matching.
*/
public static function matches(string $expected, string $given): bool
{
return $expected !== '' && hash_equals($expected, $given);
}

/**
* Whether a body carries the signature it should, as a webhook sender
* computes one: an HMAC of the raw body under a shared secret.
*
* The raw body, not the decoded one — re-encoding changes bytes, and the
* signature is over what was actually sent.
*/
public static function signed(
string $body,
string $signature,
string $secret,
string $algorithm = 'sha256',
): bool {
if ($secret === '' || $signature === '') {
return false;
}

return hash_equals(hash_hmac($algorithm, $body, $secret), $signature);
}
}
Loading
Loading