Skip to content

Latest commit

 

History

History
384 lines (306 loc) · 13.2 KB

File metadata and controls

384 lines (306 loc) · 13.2 KB

Task Manager

A multi-user task and kanban application in plain PHP 8.2 that demonstrates ownership-scoped authorization, secure CRUD, and defence-in-depth against the OWASP Top 10.


Overview

Task Manager lets registered users create boards, add tasks, move them across todo → doing → done columns, and edit or delete only the records they own. It is a deliberately small, dependency-light reference build: no framework, just Composer/PSR-4 autoloading, a thin router, PDO, and hardened sessions.

The threat model assumes an authenticated but adversarial user population plus anonymous attackers. Concretely we defend against: broken object-level authorization (user A reading/editing user B's task — OWASP A01), SQL injection via task titles and IDs (A03), stored XSS in task descriptions (A03), CSRF on every state-changing action (part of A01), session fixation/hijacking (A07), and credential stuffing against the login form (A07). Out of scope: DDoS, TLS termination (assumed at the reverse proxy), and multi-tenant billing.


Architecture

Server-rendered MVC with a single front controller. Every request flows through session bootstrap, routing, an auth guard, then a controller that owns validation, an ownership check, and a PDO data-layer call before rendering an escaped template.

flowchart LR
    B[Browser] -->|HTTPS| N[nginx]
    N -->|FastCGI| F[php-fpm: public/index.php]
    F --> R[Router]
    R --> G[Auth Guard]
    G --> C[Controller]
    C --> V[Validator + CSRF check]
    V --> M[TaskRepository - PDO]
    M --> D[(MySQL 8)]
    C --> T[View: htmlspecialchars]
    T --> B
Loading

Ownership authorization is enforced in the data layer: every read/write is parameterised on user_id, so an attacker who forges a task_id still queries against their own rows and gets nothing.


Folder Structure

task-manager/
├── composer.json
├── public/
│   └── index.php            # front controller (only web-exposed file)
├── src/
│   ├── Core/
│   │   ├── Router.php
│   │   ├── Database.php      # PDO factory (singleton)
│   │   ├── Csrf.php
│   │   ├── Session.php
│   │   └── View.php
│   ├── Controller/
│   │   ├── AuthController.php
│   │   └── TaskController.php
│   ├── Repository/
│   │   ├── UserRepository.php
│   │   └── TaskRepository.php
│   └── Middleware/
│       └── AuthGuard.php
├── views/
│   ├── layout.php
│   ├── board.php
│   └── login.php
├── migrations/
│   └── 001_init.sql
├── tests/
│   └── TaskRepositoryTest.php
├── .env.example
└── docker-compose.yml

Database Schema

CREATE TABLE users (
    id            BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    email         VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE tasks (
    id          BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    user_id     BIGINT UNSIGNED NOT NULL,
    title       VARCHAR(200) NOT NULL,
    description TEXT NULL,
    status      ENUM('todo','doing','done') NOT NULL DEFAULT 'todo',
    position    INT UNSIGNED NOT NULL DEFAULT 0,
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
                    ON UPDATE CURRENT_TIMESTAMP,
    CONSTRAINT fk_task_user FOREIGN KEY (user_id)
        REFERENCES users(id) ON DELETE CASCADE,
    INDEX idx_owner_status (user_id, status, position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

The ON DELETE CASCADE and the composite idx_owner_status index both reflect the design rule that tasks are only ever accessed through their owner.


Implementation

PDO data layer — a singleton factory forces prepared statements and disables emulation so placeholders are truly server-side.

<?php
declare(strict_types=1);

namespace App\Core;

use PDO;

final class Database
{
    private static ?PDO $pdo = null;

    public static function conn(): PDO
    {
        if (self::$pdo === null) {
            $dsn = sprintf(
                'mysql:host=%s;dbname=%s;charset=utf8mb4',
                $_ENV['DB_HOST'],
                $_ENV['DB_NAME']
            );
            self::$pdo = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS'], [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            ]);
        }
        return self::$pdo;
    }
}

Ownership-scoped repository — note that every clause binds user_id. This single pattern closes IDOR/BOLA.

<?php
declare(strict_types=1);

namespace App\Repository;

use App\Core\Database;

final class TaskRepository
{
    public function forOwner(int $userId): array
    {
        $stmt = Database::conn()->prepare(
            'SELECT id, title, description, status, position
               FROM tasks
              WHERE user_id = :uid
           ORDER BY status, position'
        );
        $stmt->execute([':uid' => $userId]);
        return $stmt->fetchAll();
    }

    public function create(int $userId, string $title, ?string $desc): int
    {
        $stmt = Database::conn()->prepare(
            'INSERT INTO tasks (user_id, title, description)
             VALUES (:uid, :title, :desc)'
        );
        $stmt->execute([':uid' => $userId, ':title' => $title, ':desc' => $desc]);
        return (int) Database::conn()->lastInsertId();
    }

    /** Returns rows affected; 0 means the caller did not own it. */
    public function updateStatus(int $userId, int $taskId, string $status): int
    {
        $stmt = Database::conn()->prepare(
            'UPDATE tasks SET status = :st
              WHERE id = :id AND user_id = :uid'   // ownership in the WHERE
        );
        $stmt->execute([':st' => $status, ':id' => $taskId, ':uid' => $userId]);
        return $stmt->rowCount();
    }
}
// VULNERABLE — trusts the client-supplied id, ignores ownership:
// $sql = "UPDATE tasks SET status='$status' WHERE id=$taskId";
// Attacker sends any id and rewrites another user's task.
// FIX: bind :status/:id AND add "AND user_id = :uid" as above.

CSRF tokens — per-session secret, constant-time comparison.

<?php
declare(strict_types=1);

namespace App\Core;

final class Csrf
{
    public static function token(): string
    {
        return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
    }

    public static function check(?string $sent): void
    {
        if ($sent === null || !hash_equals($_SESSION['csrf'] ?? '', $sent)) {
            http_response_code(419);
            exit('CSRF token mismatch');
        }
    }
}

Auth + session hardeningpassword_hash/password_verify, plus fixation defence on login.

<?php
declare(strict_types=1);

namespace App\Controller;

use App\Core\{Csrf, Session};
use App\Repository\UserRepository;

final class AuthController
{
    public function __construct(private UserRepository $users) {}

    public function login(): void
    {
        Csrf::check($_POST['_token'] ?? null);
        $email = trim((string)($_POST['email'] ?? ''));
        $pass  = (string)($_POST['password'] ?? '');

        $user = $this->users->findByEmail($email);
        if ($user !== null && password_verify($pass, $user['password_hash'])) {
            session_regenerate_id(true);          // defeat session fixation
            $_SESSION['uid'] = (int) $user['id'];
            header('Location: /board');
            return;
        }
        // Uniform failure — no user-enumeration oracle.
        Session::flash('error', 'Invalid credentials.');
        header('Location: /login');
    }
}

Session cookie flags are set once at bootstrap in public/index.php:

session_set_cookie_params([
    'httponly' => true,
    'secure'   => true,
    'samesite' => 'Strict',
    'path'     => '/',
]);
session_start();

Output escaping — the view helper escapes everything by default; there is no raw-echo path.

<?php
declare(strict_types=1);

namespace App\Core;

final class View
{
    public static function e(?string $v): string
    {
        return htmlspecialchars($v ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
    }
}
// In board.php:  <h3><?= App\Core\View::e($task['title']) ?></h3>

Testing

Test the security-critical invariants, not the framework: (1) ownership isolation — a user cannot mutate another user's task; (2) updateStatus rejects an out-of-enum status at the validator; (3) CSRF rejection returns 419; (4) password_verify round-trips. Run repository tests against a disposable SQLite/MySQL test database.

<?php
declare(strict_types=1);

use App\Repository\TaskRepository;
use PHPUnit\Framework\TestCase;

final class TaskRepositoryTest extends TestCase
{
    public function testUserCannotUpdateAnotherUsersTask(): void
    {
        $repo = new TaskRepository();
        $ownerTask = $repo->create(userId: 1, title: 'Owned', desc: null);

        // Attacker is user 2 targeting user 1's task id.
        $rows = $repo->updateStatus(userId: 2, taskId: $ownerTask, status: 'done');

        self::assertSame(0, $rows, 'Cross-owner update must affect 0 rows');
    }
}

Security Review

Risk OWASP 2021 Mitigation in this build
IDOR / cross-user task access A01 AND user_id = :uid on every read/write; assert rowCount() > 0
SQL injection A03 PDO prepared statements, EMULATE_PREPARES=false, no concatenation
Stored XSS in title/description A03 htmlspecialchars(ENT_QUOTES) default in View::e; CSP header
CSRF on create/move/delete A01 Per-session token, hash_equals, SameSite=Strict cookie
Session fixation / hijacking A07 session_regenerate_id(true), HttpOnly+Secure+SameSite
Weak / stolen credentials A07 password_hash (bcrypt), uniform login error, rate-limit at proxy
Sensitive config in repo A05 .env (gitignored), never committed; .env.example only
Verbose errors leaking schema A05 display_errors=Off in prod; log to file, generic 500 page

Deployment

Run locally with Docker Compose: an nginx container fronts php-fpm, with mysql:8 as the data store. nginx passes only public/ and routes everything to index.php; src/, .env, and migrations/ are never web-reachable.

server {
    root /app/public;
    index index.php;
    location / { try_files $uri /index.php?$query_string; }
    location ~ \.php$ {
        fastcgi_pass php:9000;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
    add_header Content-Security-Policy "default-src 'self'" always;
    add_header X-Content-Type-Options "nosniff" always;
}
cp .env.example .env      # set DB creds, never commit .env
docker compose up -d --build
docker compose exec php php migrations/run.php   # applies 001_init.sql
# App at http://localhost:8080

Set PDO with a least-privilege DB user (SELECT/INSERT/UPDATE/DELETE on the app schema only — no DDL), and run php-fpm as a non-root user with a read-only code mount.


Exercises

  1. Add board sharing: a board_members join table and a policy that grants collaborators write access — re-derive the ownership checks so they honour membership, not just user_id.
  2. Introduce optimistic locking with a version column so two tabs cannot silently overwrite each other's status change.
  3. Add login rate limiting in PHP (per-IP + per-account leaky bucket) instead of relying solely on the proxy.
  4. Swap server-rendered moves for a JSON drag-and-drop API; keep CSRF via a custom header + double-submit cookie.
  5. Add an audit log table recording who changed which task, immutably (append-only, no UPDATE/DELETE grant).

References

  • OWASP Top 10 (2021) — A01 Broken Access Control, A03 Injection, A07 Identification & Auth Failures
  • OWASP Cheat Sheets — Cross-Site Request Forgery Prevention, Session Management, Password Storage
  • PHP Manual — PDO prepared statements, password_hash, session_set_cookie_params
  • PHP-FIG — PSR-4 Autoloading, PSR-12 Coding Style

Related