mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-09 14:23:19 +01:00
Compare commits
2 Commits
feature/ru
...
feature/os
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89c477d463 | ||
|
|
6e9cc66f47 |
56
.oss-scanner/Dockerfile
Normal file
56
.oss-scanner/Dockerfile
Normal file
@@ -0,0 +1,56 @@
|
||||
# Build environment for Anthropic's OSS Scanner (https://github.com/anthropics/oss-scanner).
|
||||
# The scanner builds this image with the repository root as the build context and then audits it without network
|
||||
# access, so everything needed to run the app and its test suite (PHP + Composer deps, Node deps + built frontend,
|
||||
# a local PostgreSQL) is installed here.
|
||||
#
|
||||
# Inside the finished image:
|
||||
# .oss-scanner/start-postgres.sh start the local PostgreSQL server (required for tests and the app)
|
||||
# php artisan test run the PHPUnit suite
|
||||
# php artisan serve run the app on http://127.0.0.1:8000 (after `php artisan migrate --seed`)
|
||||
|
||||
FROM node:20-bookworm-slim AS node
|
||||
|
||||
FROM php:8.3-cli-bookworm
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
COMPOSER_ALLOW_SUPERUSER=1 \
|
||||
COMPOSER_NO_INTERACTION=1 \
|
||||
TZ=UTC
|
||||
|
||||
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
# PostgreSQL 15 (Debian bookworm default) is the database solidtime runs and is tested against.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git unzip curl ca-certificates postgresql postgresql-client \
|
||||
&& install-php-extensions pdo_pgsql pgsql intl gd zip bcmath exif pcntl sockets soap \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& echo "memory_limit=2G" > "$PHP_INI_DIR/conf.d/99-oss-scanner.ini"
|
||||
|
||||
# Database matching .env.ci: user root / password root, database laravel.
|
||||
RUN pg_ctlcluster 15 main start \
|
||||
&& runuser -u postgres -- psql -c "CREATE ROLE root WITH LOGIN SUPERUSER PASSWORD 'root';" \
|
||||
&& runuser -u postgres -- createdb -O root laravel \
|
||||
&& pg_ctlcluster 15 main stop
|
||||
|
||||
# scanner contract: the checkout lives inside the image, at /src
|
||||
COPY . /src
|
||||
WORKDIR /src
|
||||
|
||||
RUN composer install --prefer-dist \
|
||||
&& npm ci \
|
||||
&& npm run build
|
||||
|
||||
RUN cp .env.ci .env \
|
||||
&& php artisan key:generate \
|
||||
&& php artisan passport:keys --force \
|
||||
&& chmod +x .oss-scanner/start-postgres.sh
|
||||
|
||||
# Run the test suite so the image is known to work, but do not fail the build on test failures.
|
||||
RUN .oss-scanner/start-postgres.sh \
|
||||
&& (php artisan test || echo "WARNING: PHPUnit reported failures") \
|
||||
&& pg_ctlcluster 15 main stop
|
||||
18
.oss-scanner/Dockerfile.dockerignore
Normal file
18
.oss-scanner/Dockerfile.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
# Used instead of the root .dockerignore when building .oss-scanner/Dockerfile. The root one is tailored to the
|
||||
# production image and excludes tests, phpunit.xml etc., which the scanner needs.
|
||||
node_modules
|
||||
extensions/*/node_modules
|
||||
vendor
|
||||
.env
|
||||
public/build
|
||||
public/hot
|
||||
storage/*.key
|
||||
storage/logs/*
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
.phpunit.cache
|
||||
.phpunit.result.cache
|
||||
auth.json
|
||||
.DS_Store
|
||||
.idea
|
||||
7
.oss-scanner/start-postgres.sh
Executable file
7
.oss-scanner/start-postgres.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the local PostgreSQL server used by the test suite and the app (see .oss-scanner/Dockerfile).
|
||||
set -euo pipefail
|
||||
|
||||
pg_ctlcluster 15 main start 2>/dev/null || true
|
||||
until pg_isready -h 127.0.0.1 -p 5432 -q; do sleep 0.5; done
|
||||
echo "PostgreSQL is running on 127.0.0.1:5432 (user root, password root, database laravel)"
|
||||
95
.oss-scanner/threat_model.md
Normal file
95
.oss-scanner/threat_model.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Threat model
|
||||
|
||||
## What this project does
|
||||
|
||||
solidtime is an open-source, multi-tenant time tracking web application (Laravel backend, Vue 3 + Inertia frontend,
|
||||
PostgreSQL). It runs as a hosted SaaS (solidtime.io) and is self-hosted by many organisations. Users belong to one or
|
||||
more **organizations**; inside an organization each member has a role: `owner`, `admin`, `manager`, `employee` or
|
||||
`placeholder` (an imported, non-login member). What each role may do is defined in `app/Service/PermissionStore.php`.
|
||||
|
||||
The most important security property is **isolation**: a user must never read or modify data of an organization they
|
||||
are not a member of, and within an organization a member must not exceed the permissions of their role (e.g. an
|
||||
employee must not see other members' time entries, billable rates, or manage members, unless the organization settings
|
||||
explicitly allow it).
|
||||
|
||||
## Trust boundaries
|
||||
|
||||
- **Super admins are fully trusted.** They are the instance operators, configured via the `SUPER_ADMINS` env
|
||||
variable, and have access to the Filament admin panel (`app/Filament`), which can view and change data of every
|
||||
organization and impersonate users. Anything a super admin can do through the panel (including XSS, SQL injection,
|
||||
SSRF or file access that is only reachable from the panel) is not a vulnerability.
|
||||
- What **is** in scope: a user who is not a super admin reaching the admin panel, or any of its actions, at all.
|
||||
- Operators of a self-hosted instance (shell, database, environment, filesystem access) are trusted.
|
||||
- Everyone else, including organization owners and admins when acting outside their own organization, is untrusted.
|
||||
|
||||
## Where untrusted input enters
|
||||
|
||||
All authenticated users, including employees of any organization and anyone who self-registers (registration is open
|
||||
by default), are untrusted.
|
||||
|
||||
- **JSON API** `routes/api.php` (`/api/v1/...`), authenticated via Passport (session cookie or personal access token).
|
||||
Most routes are scoped by `{organization}` and authorised in the controllers / form requests.
|
||||
- **Public, unauthenticated** endpoints: `GET /api/v1/public/reports` (shared reports, accessed by a secret), login,
|
||||
registration, password reset, email verification, organization invitation acceptance (`routes/web.php`).
|
||||
- **Web / Inertia routes** `routes/web.php` and Fortify/Jetstream actions in `app/Actions`.
|
||||
- **Imports** (`app/Service/Import/Importers`): user-uploaded CSV and ZIP files from Toggl, Clockify, Harvest,
|
||||
generic CSV and solidtime's own export format. ZIP handling is in `ZipImportHelper.php`.
|
||||
- **Exports / reports** (`app/Service/Export`, `app/Service/ReportExport`): CSV/XLSX/ODS and PDF. PDFs are rendered by
|
||||
sending HTML to a Gotenberg (headless Chromium) service, so user-controlled content in that HTML matters.
|
||||
- **OAuth** (Passport) authorization and token endpoints.
|
||||
- **Filament admin panel** (`app/Filament`): only its access control is in scope (see Trust boundaries).
|
||||
|
||||
## Components that matter most / least
|
||||
|
||||
Most important: organization scoping and role checks in the API controllers, form requests (`app/Http/Requests`),
|
||||
`PermissionStore`, public report sharing, invitations and member management (role changes, ownership transfer, member
|
||||
merge), authentication flows (Fortify, 2FA, email change, API tokens), import parsing.
|
||||
|
||||
Less important / out of scope:
|
||||
- `extensions/` is empty in this repository (proprietary modules are not part of the open-source code).
|
||||
- `docker/`, `k8s/`, `e2e/`, `playwright/`, `docs/` and developer tooling.
|
||||
- Third-party dependencies in `vendor/` and `node_modules/`, unless solidtime uses them in an unsafe way.
|
||||
|
||||
## How to exercise it
|
||||
|
||||
- `.oss-scanner/start-postgres.sh` starts the local PostgreSQL server (user `root`, password `root`, db `laravel`).
|
||||
- `php artisan test` runs the PHPUnit suite. Endpoint tests in `tests/Unit/Endpoint/Api/V1/` show how to create users,
|
||||
organizations and members with factories and call the API with a given role; they are the quickest way to write a
|
||||
reproducer. Example: `php artisan test --filter=TimeEntryEndpointTest`.
|
||||
- To run the app: `php artisan migrate:fresh --seed && php artisan serve` (http://127.0.0.1:8000). Note that the
|
||||
test suite and the app share the same database.
|
||||
- There is no network: Gotenberg (PDF generation) and mail delivery are not available. Mail uses the `array`
|
||||
driver in tests. The 8 PDF export tests in `TimeEntryEndpointTest` fail for this reason; that is expected.
|
||||
|
||||
## How we rate severity
|
||||
|
||||
- **Critical**: unauthenticated access to other users' data or accounts; authentication bypass; remote code execution;
|
||||
SQL injection reachable by any registered user; reading or writing data of an organization the attacker is not a
|
||||
member of.
|
||||
- **High**: privilege escalation within an organization (e.g. employee to admin/owner, or performing admin-only
|
||||
actions); access to data the role must not see (other members' time entries, billable rates, member emails) when
|
||||
the organization settings do not allow it; stored XSS that executes in another user's session; SSRF via PDF
|
||||
rendering or imports; account takeover requiring user interaction.
|
||||
- **Medium**: information disclosure with limited impact, CSRF on state-changing endpoints, issues requiring an
|
||||
unusual but realistic configuration, denial of service by a single authenticated request (e.g. pathological
|
||||
import file).
|
||||
- **Low**: everything else with real security impact.
|
||||
|
||||
## Anything to leave alone
|
||||
|
||||
Please do not report (see also `SECURITY.md`):
|
||||
- Theoretical findings without a working reproducer.
|
||||
- Missing or weak security headers in isolation; TLS / mail DNS configuration.
|
||||
- Self-XSS; CSRF on non-state-changing endpoints (logout, theme).
|
||||
- CSV / spreadsheet formula injection in exports.
|
||||
- Owners or admins acting destructively within their own organization.
|
||||
- Anything requiring direct DB, shell or filesystem access on a self-hosted instance.
|
||||
- Anything that requires being a super admin, including issues inside the Filament admin panel.
|
||||
- Missing OAuth scope enforcement (not implemented yet).
|
||||
- Rate-limit tuning and generic DoS through volume of requests.
|
||||
|
||||
## Reports and patches
|
||||
|
||||
Please include the affected endpoint or code path, the attacker's role and the victim, a PHPUnit test (in the style of
|
||||
`tests/Unit/Endpoint/Api/V1/`) that reproduces the issue, and a minimal patch that follows the existing patterns
|
||||
(authorisation in form requests/controllers via `PermissionStore`).
|
||||
@@ -51,7 +51,6 @@ use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -614,24 +613,7 @@ class TimeEntryController extends Controller
|
||||
$this->checkPermission($organization, 'time-entries:create:all');
|
||||
}
|
||||
|
||||
// Lock the creation of running time entries per user, so that concurrent requests can not create more than one running time entry
|
||||
$lock = $request->input('end') === null ? Cache::lock('time-entries:running:'.$member->user_id, 10) : null;
|
||||
$lock?->block(5);
|
||||
|
||||
try {
|
||||
return $this->storeTimeEntry($organization, $member, $request);
|
||||
} finally {
|
||||
$lock?->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TimeEntryStillRunningApiException
|
||||
*/
|
||||
private function storeTimeEntry(Organization $organization, Member $member, TimeEntryStoreRequest $request): JsonResource
|
||||
{
|
||||
// A user can only have one running time entry, across all organizations
|
||||
if ($request->input('end') === null && TimeEntry::query()->where('user_id', $member->user_id)->whereNull('end')->exists()) {
|
||||
if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) {
|
||||
throw new TimeEntryStillRunningApiException;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,7 @@ use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\TimeEntryFilter;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -2446,77 +2444,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertJsonPath('error', true);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_fails_if_user_already_has_active_time_entry_in_another_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:create:own',
|
||||
]);
|
||||
$otherOrganization = Organization::factory()->create();
|
||||
$otherMember = Member::factory()->forOrganization($otherOrganization)->forUser($data->user)->create();
|
||||
TimeEntry::factory()->forOrganization($otherOrganization)->forMember($otherMember)->active()->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||
'billable' => true,
|
||||
'start' => Carbon::now()->toIso8601ZuluString(),
|
||||
'end' => null,
|
||||
'member_id' => $data->member->getKey(),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonPath('key', 'time_entry_still_running');
|
||||
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($data->organization, 'organization')->count());
|
||||
}
|
||||
|
||||
public function test_store_endpoint_releases_running_time_entry_lock_after_request(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:create:own',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||
'billable' => true,
|
||||
'start' => Carbon::now()->toIso8601ZuluString(),
|
||||
'end' => null,
|
||||
'member_id' => $data->member->getKey(),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(201);
|
||||
$this->assertTrue(Cache::lock('time-entries:running:'.$data->user->getKey(), 10)->get());
|
||||
}
|
||||
|
||||
public function test_store_endpoint_waits_for_running_time_entry_lock_and_fails_if_it_is_not_released(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:create:own',
|
||||
]);
|
||||
Cache::lock('time-entries:running:'.$data->user->getKey(), 10)->get();
|
||||
$this->withoutExceptionHandling();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
try {
|
||||
$this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
|
||||
'billable' => true,
|
||||
'start' => Carbon::now()->toIso8601ZuluString(),
|
||||
'end' => null,
|
||||
'member_id' => $data->member->getKey(),
|
||||
]);
|
||||
$this->fail('Expected LockTimeoutException');
|
||||
} catch (LockTimeoutException) {
|
||||
// Assert
|
||||
$this->assertSame(0, TimeEntry::query()->count());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_store_endpoint_validation_fails_if_task_id_does_not_belong_to_project_id(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
Reference in New Issue
Block a user