@cristiancorreau/forge 3.0.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +265 -109
- package/assets/adapters/claude-code/commands/laravel-eloquent.md +7 -0
- package/assets/adapters/claude-code/commands/laravel-mcp.md +7 -0
- package/assets/adapters/claude-code/commands/laravel-pest.md +7 -0
- package/assets/adapters/claude-code/commands/laravel-security.md +7 -0
- package/assets/adapters/claude-code/commands/laravel-verify.md +7 -0
- package/assets/core/hooks/pre-bash-check.js +46 -0
- package/assets/core/hooks/pre-edit-check.js +24 -1
- package/assets/core/schemas/project.schema.json +3 -1
- package/assets/core/skills/laravel-eloquent/SKILL.md +453 -0
- package/assets/core/skills/laravel-mcp/SKILL.md +468 -0
- package/assets/core/skills/laravel-pest/SKILL.md +686 -0
- package/assets/core/skills/laravel-security/SKILL.md +658 -0
- package/assets/core/skills/laravel-verify/SKILL.md +462 -0
- package/assets/manifest.json +27 -2
- package/assets/profiles/astro/agents/frontend-engineer.md +2 -0
- package/assets/profiles/django/agents/api-engineer.md +2 -0
- package/assets/profiles/expo/agents/mobile-engineer.md +2 -0
- package/assets/profiles/express/agents/api-engineer.md +2 -0
- package/assets/profiles/fastapi/agents/api-engineer.md +2 -0
- package/assets/profiles/flask/agents/api-engineer.md +2 -0
- package/assets/profiles/flutter/agents/mobile-engineer.md +12 -10
- package/assets/profiles/go-gin/agents/api-engineer.md +3 -1
- package/assets/profiles/hono-drizzle/agents/api-engineer.md +2 -0
- package/assets/profiles/laravel/README.md +16 -2
- package/assets/profiles/laravel/agents/api-engineer.md +2 -0
- package/assets/profiles/laravel/agents/fullstack-engineer.md +4 -2
- package/assets/profiles/laravel/agents/laravel-specialist.md +607 -0
- package/assets/profiles/laravel/agents/laravel-test-engineer.md +448 -0
- package/assets/profiles/nestjs/agents/api-engineer.md +3 -1
- package/assets/profiles/nextjs-admin/agents/admin-engineer.md +2 -0
- package/assets/profiles/playwright-crawler/agents/scanner-engineer.md +2 -0
- package/assets/profiles/rails/agents/fullstack-engineer.md +2 -0
- package/assets/profiles/rust/agents/api-engineer.md +2 -0
- package/assets/profiles/springboot/agents/api-engineer.md +11 -9
- package/assets/profiles/sveltekit/agents/frontend-engineer.md +4 -2
- package/assets/profiles/vuenuxt/agents/frontend-engineer.md +12 -10
- package/assets/profiles/wordpress/agents/divi-engineer.md +2 -0
- package/assets/profiles/wordpress/agents/elementor-engineer.md +2 -0
- package/dist/cli.js +15 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/add.d.ts +2 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +187 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/mcp.d.ts +42 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +141 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/update.d.ts +30 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +180 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +40 -1
- package/dist/commands/validate.js.map +1 -1
- package/dist/lib/catalog.d.ts +7 -0
- package/dist/lib/catalog.d.ts.map +1 -1
- package/dist/lib/catalog.js +20 -0
- package/dist/lib/catalog.js.map +1 -1
- package/dist/lib/mcp-tools.d.ts +37 -0
- package/dist/lib/mcp-tools.d.ts.map +1 -0
- package/dist/lib/mcp-tools.js +124 -0
- package/dist/lib/mcp-tools.js.map +1 -0
- package/dist/lib/skill-security.d.ts +66 -0
- package/dist/lib/skill-security.d.ts.map +1 -0
- package/dist/lib/skill-security.js +225 -0
- package/dist/lib/skill-security.js.map +1 -0
- package/dist/lib/skill-source.d.ts +29 -0
- package/dist/lib/skill-source.d.ts.map +1 -0
- package/dist/lib/skill-source.js +94 -0
- package/dist/lib/skill-source.js.map +1 -0
- package/dist/tui/dashboard.d.ts.map +1 -1
- package/dist/tui/dashboard.js +3 -6
- package/dist/tui/dashboard.js.map +1 -1
- package/dist/tui/panel.d.ts.map +1 -1
- package/dist/tui/panel.js +3 -6
- package/dist/tui/panel.js.map +1 -1
- package/dist/tui/wizard.d.ts.map +1 -1
- package/dist/tui/wizard.js +3 -13
- package/dist/tui/wizard.js.map +1 -1
- package/dist/ui/colors.d.ts +3 -1
- package/dist/ui/colors.d.ts.map +1 -1
- package/dist/ui/colors.js +11 -2
- package/dist/ui/colors.js.map +1 -1
- package/dist/ui/header.d.ts.map +1 -1
- package/dist/ui/header.js +4 -3
- package/dist/ui/header.js.map +1 -1
- package/dist/ui/theme.d.ts +24 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +32 -0
- package/dist/ui/theme.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
# Skill: laravel-security
|
|
2
|
+
|
|
3
|
+
Seguridad de aplicaciones Laravel: auth, autorización, validación, mass assignment,
|
|
4
|
+
CSRF, rate limiting, inyección SQL, XSS, secrets, uploads y deploy seguro. Actívalo al
|
|
5
|
+
crear o revisar cualquier endpoint, modelo, formulario o configuración que toque datos
|
|
6
|
+
de usuario, autenticación o producción.
|
|
7
|
+
|
|
8
|
+
Triggers: /laravel-security, "seguridad laravel", "auth laravel", "policy laravel",
|
|
9
|
+
"form request", "mass assignment", "sanctum o passport", "csrf laravel", "rate limit laravel",
|
|
10
|
+
"inyección sql laravel", "blade xss", "asegurar endpoint laravel", "deploy seguro laravel".
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Cuándo usar este skill
|
|
15
|
+
|
|
16
|
+
- Al crear o modificar endpoints (rutas web o API) que requieren auth o autorización.
|
|
17
|
+
- Al definir o cambiar modelos Eloquent (`$fillable`/`$guarded`, casts).
|
|
18
|
+
- Al escribir validación de input: SIEMPRE en un Form Request, nunca inline en el controller.
|
|
19
|
+
- Al elegir el paquete de auth (Sanctum vs Fortify vs Passport).
|
|
20
|
+
- Al renderizar datos de usuario en Blade (`{{ }}` vs `{!! !!}`).
|
|
21
|
+
- Al manejar secrets, uploads, queries crudas o cualquier `DB::raw`.
|
|
22
|
+
- Antes de cada deploy a producción (`APP_DEBUG=false`, `key:generate`, HTTPS, headers).
|
|
23
|
+
|
|
24
|
+
> Las versiones recientes de Laravel usan **estructura slim** (PHP 8.3+): NO existe
|
|
25
|
+
> `app/Http/Kernel.php`. Todo el middleware se configura en `bootstrap/app.php` dentro de
|
|
26
|
+
> `->withMiddleware()`. Verifica la estructura leyendo `bootstrap/app.php` en tu proyecto.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 1. Autenticación — elegir el paquete correcto
|
|
31
|
+
|
|
32
|
+
No uses Passport por defecto. La regla general:
|
|
33
|
+
|
|
34
|
+
| Paquete | Para qué | Cuándo |
|
|
35
|
+
|---------|----------|--------|
|
|
36
|
+
| **Sanctum** | Tokens de API + cookie de SPA + mobile, con abilities/scopes | Default para casi todo: SPA propia, app mobile, API first-party |
|
|
37
|
+
| **Fortify** | Backend headless de auth por **sesión** (sin UI) | App web first-party que renderiza su propio frontend |
|
|
38
|
+
| **Passport** | Servidor **OAuth2 / OAuth 2.1** completo | Solo clientes de terceros, "Log in with X", delegación, M2M (client-credentials) |
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# API / SPA / mobile (default):
|
|
42
|
+
composer require laravel/sanctum
|
|
43
|
+
|
|
44
|
+
# App web por sesión, sin frontend de auth:
|
|
45
|
+
composer require laravel/fortify
|
|
46
|
+
|
|
47
|
+
# Solo si necesitas un servidor OAuth2 real:
|
|
48
|
+
composer require laravel/passport
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Sanctum — tokens de API con abilities (scopes):**
|
|
52
|
+
|
|
53
|
+
```php
|
|
54
|
+
// Emitir un token con abilities acotadas (principio de mínimo privilegio).
|
|
55
|
+
$token = $user->createToken('mobile', ['posts:read', 'posts:create']);
|
|
56
|
+
|
|
57
|
+
return ['token' => $token->plainTextToken];
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```php
|
|
61
|
+
// routes/api.php — proteger e inspeccionar abilities.
|
|
62
|
+
use Illuminate\Support\Facades\Route;
|
|
63
|
+
|
|
64
|
+
Route::middleware('auth:sanctum')->group(function () {
|
|
65
|
+
Route::post('/posts', [PostController::class, 'store'])
|
|
66
|
+
->middleware('ability:posts:create');
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```php
|
|
71
|
+
// Dentro del controller, verificación granular adicional.
|
|
72
|
+
if (! $request->user()->tokenCan('posts:create')) {
|
|
73
|
+
abort(403, 'Token sin permiso.');
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Reglas:
|
|
78
|
+
|
|
79
|
+
- El middleware de auth corre ANTES de cualquier lógica. Sin token/sesión válida → 401, sin procesar nada.
|
|
80
|
+
- Emite tokens con las abilities mínimas necesarias, no con `['*']`.
|
|
81
|
+
- Nunca confíes en headers que el cliente controla (`X-User-Id`, `X-Role`); deriva la identidad del token/sesión.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 2. Autorización — Policies, Gates y `@can`
|
|
86
|
+
|
|
87
|
+
La autorización va en Policies (o Gates), nunca dispersa con `if` ad-hoc en cada controller.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
php artisan make:policy PostPolicy --model=Post
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```php
|
|
94
|
+
// app/Policies/PostPolicy.php
|
|
95
|
+
namespace App\Policies;
|
|
96
|
+
|
|
97
|
+
use App\Models\Post;
|
|
98
|
+
use App\Models\User;
|
|
99
|
+
|
|
100
|
+
class PostPolicy
|
|
101
|
+
{
|
|
102
|
+
public function update(User $user, Post $post): bool
|
|
103
|
+
{
|
|
104
|
+
return $user->id === $post->user_id; // ownership check (anti-IDOR)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public function delete(User $user, Post $post): bool
|
|
108
|
+
{
|
|
109
|
+
return $user->id === $post->user_id || $user->is_admin;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```php
|
|
115
|
+
// En el controller: Gate::authorize() lanza 403 automáticamente si falla.
|
|
116
|
+
use Illuminate\Support\Facades\Gate;
|
|
117
|
+
|
|
118
|
+
public function update(UpdatePostRequest $request, Post $post)
|
|
119
|
+
{
|
|
120
|
+
Gate::authorize('update', $post); // resuelve la Policy y aborta con 403 si no pasa
|
|
121
|
+
|
|
122
|
+
$post->update($request->validated());
|
|
123
|
+
|
|
124
|
+
return $post->toResource(); // las versiones recientes auto-descubren PostResource
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
> En el esqueleto slim de Laravel el controller base (`App\Http\Controllers\Controller`)
|
|
129
|
+
> suele estar vacío: puede NO incluir el trait `AuthorizesRequests`, así que `$this->authorize(...)`
|
|
130
|
+
> no existiría por defecto. Verifica leyendo el controller base de tu proyecto. Usa
|
|
131
|
+
> `Gate::authorize(...)` (funciona sin trait) o el middleware `can:`. Si prefieres
|
|
132
|
+
> `$this->authorize(...)`, agrega `use AuthorizesRequests;` al controller base.
|
|
133
|
+
|
|
134
|
+
```php
|
|
135
|
+
// Atajos útiles para route model binding: autoriza vía la Policy antes del controller.
|
|
136
|
+
Route::put('/posts/{post}', [PostController::class, 'update'])
|
|
137
|
+
->middleware('can:update,post'); // no requiere ningún trait en el controller
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```blade
|
|
141
|
+
{{-- En Blade: nunca muestres acciones que el usuario no puede ejecutar. --}}
|
|
142
|
+
@can('update', $post)
|
|
143
|
+
<a href="{{ route('posts.edit', $post) }}">Editar</a>
|
|
144
|
+
@endcan
|
|
145
|
+
|
|
146
|
+
@cannot('delete', $post)
|
|
147
|
+
<span class="text-muted">No puedes borrar este post</span>
|
|
148
|
+
@endcannot
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**IDOR (Insecure Direct Object Reference):** al cargar un recurso por ID, verifica SIEMPRE
|
|
152
|
+
ownership/permiso en la Policy. No asumas que un ID "difícil de adivinar" protege nada. Que el
|
|
153
|
+
recurso aparezca en route model binding NO implica que el usuario actual pueda verlo.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 3. Validación — siempre en un Form Request
|
|
158
|
+
|
|
159
|
+
Nunca valides en el cuerpo del controller con `$request->validate([...])` para nada no trivial.
|
|
160
|
+
Usa un Form Request: combina **autorización + validación** y mantiene el controller limpio.
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
php artisan make:request StorePostRequest
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
```php
|
|
167
|
+
// app/Http/Requests/StorePostRequest.php
|
|
168
|
+
namespace App\Http\Requests;
|
|
169
|
+
|
|
170
|
+
use Illuminate\Foundation\Http\FormRequest;
|
|
171
|
+
use Illuminate\Validation\Rule;
|
|
172
|
+
|
|
173
|
+
class StorePostRequest extends FormRequest
|
|
174
|
+
{
|
|
175
|
+
public function authorize(): bool
|
|
176
|
+
{
|
|
177
|
+
// La autorización corre ANTES de validar. false => 403 automático.
|
|
178
|
+
return $this->user()->can('create', \App\Models\Post::class);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
public function rules(): array
|
|
182
|
+
{
|
|
183
|
+
return [
|
|
184
|
+
'title' => ['required', 'string', 'max:255'],
|
|
185
|
+
'body' => ['required', 'string'],
|
|
186
|
+
'status' => ['required', Rule::in(['draft', 'published'])],
|
|
187
|
+
'tags' => ['array', 'max:10'],
|
|
188
|
+
'tags.*' => ['string', 'max:30'],
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Normaliza input ANTES de validar (no para confiar en él, sino para validarlo bien).
|
|
193
|
+
protected function prepareForValidation(): void
|
|
194
|
+
{
|
|
195
|
+
$this->merge(['title' => trim((string) $this->input('title'))]);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
```php
|
|
201
|
+
// Controller: la validación corre automáticamente al type-hintear el Form Request.
|
|
202
|
+
public function store(StorePostRequest $request)
|
|
203
|
+
{
|
|
204
|
+
// Usa SOLO los datos validados; nunca $request->all() ni $request->input() crudo.
|
|
205
|
+
$post = $request->user()->posts()->create($request->validated());
|
|
206
|
+
|
|
207
|
+
// Alternativa con whitelist explícita:
|
|
208
|
+
// $post->fill($request->safe()->only(['title', 'body', 'status']));
|
|
209
|
+
|
|
210
|
+
return $post->toResource();
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Reglas:
|
|
215
|
+
|
|
216
|
+
- `authorize(): bool` controla el acceso; `rules()` controla la forma del input. Los dos siempre.
|
|
217
|
+
- Recupera datos con `$request->validated()` o `$request->safe()->only([...])` — nunca `->all()`.
|
|
218
|
+
- Prefiere la sintaxis de array de reglas (`['required', 'max:255']`) sobre el string `'required|max:255'`.
|
|
219
|
+
- Una regla `confirmed`, `exists`, `unique` mal puesta es un bug de seguridad (p. ej. `email` sin `unique`).
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## 4. Mass assignment — `$fillable` o `$guarded`
|
|
224
|
+
|
|
225
|
+
Sin protección, un atacante envía `is_admin=1` o `user_id=99` en el body y Eloquent lo persiste.
|
|
226
|
+
|
|
227
|
+
```php
|
|
228
|
+
// app/Models/Post.php
|
|
229
|
+
namespace App\Models;
|
|
230
|
+
|
|
231
|
+
use Illuminate\Database\Eloquent\Model;
|
|
232
|
+
|
|
233
|
+
class Post extends Model
|
|
234
|
+
{
|
|
235
|
+
// Whitelist explícita: SOLO estos campos son asignables en masa.
|
|
236
|
+
protected $fillable = ['title', 'body', 'status'];
|
|
237
|
+
|
|
238
|
+
// NUNCA pongas en $fillable: id, user_id, is_admin, role, email_verified_at, etc.
|
|
239
|
+
// Esos campos se asignan a mano desde el servidor con valores de confianza.
|
|
240
|
+
|
|
241
|
+
// En las versiones recientes de Laravel, define casts con el MÉTODO casts(),
|
|
242
|
+
// no la propiedad legacy $casts. Verifica el estilo soportado por tu versión.
|
|
243
|
+
protected function casts(): array
|
|
244
|
+
{
|
|
245
|
+
return [
|
|
246
|
+
'is_admin' => 'boolean',
|
|
247
|
+
'published_at' => 'datetime',
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
```php
|
|
254
|
+
// Asigna los campos sensibles desde el servidor, fuera de la asignación masiva.
|
|
255
|
+
$post = $request->user()->posts()->create($request->validated()); // user_id viene de la relación
|
|
256
|
+
$post->forceFill(['approved_by' => auth()->id()])->save(); // valor de confianza
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Endurecimiento global recomendado (atrapa silencios peligrosos en dev/test):
|
|
260
|
+
|
|
261
|
+
```php
|
|
262
|
+
// app/Providers/AppServiceProvider.php — boot()
|
|
263
|
+
use Illuminate\Database\Eloquent\Model;
|
|
264
|
+
|
|
265
|
+
public function boot(): void
|
|
266
|
+
{
|
|
267
|
+
// Lanza excepción si se intenta asignar un atributo fuera de $fillable.
|
|
268
|
+
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
|
|
269
|
+
Model::preventAccessingMissingAttributes(! $this->app->isProduction());
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Regla: `$fillable` (allowlist) sobre `$guarded = []` (que abre todo). Nunca uses `$guarded = []`
|
|
274
|
+
combinado con `Model::create($request->all())`.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## 5. CSRF — `PreventRequestForgery`
|
|
279
|
+
|
|
280
|
+
En las versiones recientes de Laravel el middleware de CSRF se llama **`PreventRequestForgery`**
|
|
281
|
+
(renombrado desde `VerifyCsrfToken`) y ya está en el grupo `web` por defecto. Con la estructura
|
|
282
|
+
slim no hay `app/Http/Kernel.php`: si necesitas excluir rutas (p. ej. webhooks), se configura en
|
|
283
|
+
`bootstrap/app.php`. Verifica el nombre del middleware en la documentación oficial de tu versión.
|
|
284
|
+
|
|
285
|
+
```php
|
|
286
|
+
// bootstrap/app.php
|
|
287
|
+
use Illuminate\Foundation\Application;
|
|
288
|
+
use Illuminate\Foundation\Configuration\Middleware;
|
|
289
|
+
|
|
290
|
+
return Application::configure(basePath: dirname(__DIR__))
|
|
291
|
+
->withMiddleware(function (Middleware $middleware) {
|
|
292
|
+
// Excluir SOLO rutas de webhooks externos que firman su propia request.
|
|
293
|
+
$middleware->validateCsrfTokens(except: [
|
|
294
|
+
'stripe/webhook',
|
|
295
|
+
'webhooks/*',
|
|
296
|
+
]);
|
|
297
|
+
})
|
|
298
|
+
->create();
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```blade
|
|
302
|
+
{{-- Formularios web: incluir SIEMPRE el token. --}}
|
|
303
|
+
<form method="POST" action="{{ route('posts.store') }}">
|
|
304
|
+
@csrf
|
|
305
|
+
{{-- ... --}}
|
|
306
|
+
</form>
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Reglas:
|
|
310
|
+
|
|
311
|
+
- No deshabilites CSRF globalmente. Excluye solo endpoints concretos de webhooks que validan firma propia.
|
|
312
|
+
- Las APIs con Sanctum por **token** (header `Authorization: Bearer`) no necesitan CSRF; las SPA de Sanctum por **cookie** sí dependen del flujo CSRF de Sanctum (`/sanctum/csrf-cookie`).
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## 6. Rate limiting — `RateLimiter` y `throttle`
|
|
317
|
+
|
|
318
|
+
Sin rate limiting, login y endpoints de IA quedan expuestos a brute force y abuso de costo.
|
|
319
|
+
|
|
320
|
+
```php
|
|
321
|
+
// app/Providers/AppServiceProvider.php — boot()
|
|
322
|
+
use Illuminate\Cache\RateLimiting\Limit;
|
|
323
|
+
use Illuminate\Http\Request;
|
|
324
|
+
use Illuminate\Support\Facades\RateLimiter;
|
|
325
|
+
|
|
326
|
+
public function boot(): void
|
|
327
|
+
{
|
|
328
|
+
// Límite por usuario autenticado, con fallback a IP.
|
|
329
|
+
RateLimiter::for('api', function (Request $request) {
|
|
330
|
+
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Login: agresivo y por (email + IP) para frenar credential stuffing.
|
|
334
|
+
RateLimiter::for('login', function (Request $request) {
|
|
335
|
+
return Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip());
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
```php
|
|
341
|
+
// routes/api.php — aplicar el limiter nombrado.
|
|
342
|
+
Route::middleware(['throttle:api'])->group(function () {
|
|
343
|
+
Route::apiResource('posts', PostController::class);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Login con su propio limiter.
|
|
347
|
+
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:login');
|
|
348
|
+
|
|
349
|
+
// Throttle inline simple: 100 requests por minuto.
|
|
350
|
+
Route::get('/search', SearchController::class)->middleware('throttle:100,1');
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Regla: aplica `throttle` a TODA ruta de auth (login, registro, reset password, verificación)
|
|
354
|
+
y a cualquier endpoint que dispare costo (IA, envío de email/SMS, exportaciones).
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## 7. Inyección SQL — bindings, nunca concatenación
|
|
359
|
+
|
|
360
|
+
Eloquent y el Query Builder usan bindings por defecto y son seguros. El peligro aparece con
|
|
361
|
+
`DB::raw`, `whereRaw`, `orderByRaw`, `selectRaw` cuando metes input del usuario sin bindings.
|
|
362
|
+
|
|
363
|
+
```php
|
|
364
|
+
use Illuminate\Support\Facades\DB;
|
|
365
|
+
|
|
366
|
+
// PELIGRO — concatenación directa de input. NUNCA:
|
|
367
|
+
$users = DB::select("SELECT * FROM users WHERE email = '" . $request->email . "'");
|
|
368
|
+
User::whereRaw("name = '" . $request->name . "'")->get();
|
|
369
|
+
|
|
370
|
+
// CORRECTO — bindings parametrizados:
|
|
371
|
+
$users = DB::select('SELECT * FROM users WHERE email = ?', [$request->email]);
|
|
372
|
+
User::whereRaw('name = ?', [$request->name])->get();
|
|
373
|
+
|
|
374
|
+
// Mejor aún — query builder de alto nivel (binding implícito):
|
|
375
|
+
User::where('email', $request->email)->get();
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
```php
|
|
379
|
+
// orderBy con columna dinámica: NUNCA pases el input directo a orderByRaw.
|
|
380
|
+
// Valida contra una allowlist de columnas permitidas.
|
|
381
|
+
$sortable = ['created_at', 'title', 'status'];
|
|
382
|
+
$column = in_array($request->input('sort'), $sortable, true)
|
|
383
|
+
? $request->input('sort')
|
|
384
|
+
: 'created_at';
|
|
385
|
+
|
|
386
|
+
Post::orderBy($column)->paginate();
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Reglas:
|
|
390
|
+
|
|
391
|
+
- Nombres de columna/tabla NO pueden ir como binding (un `?` solo liga valores). Para columnas
|
|
392
|
+
dinámicas, valida contra una allowlist; nunca interpoles el string del usuario.
|
|
393
|
+
- `DB::raw` con input de usuario es la causa #1 de SQLi en Laravel. Si no puedes evitarlo, usa
|
|
394
|
+
el segundo argumento de bindings.
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## 8. XSS — Blade `{{ }}` vs `{!! !!}`
|
|
399
|
+
|
|
400
|
+
```blade
|
|
401
|
+
{{-- {{ }} escapa HTML automáticamente (htmlspecialchars). SEGURO por defecto. --}}
|
|
402
|
+
<h1>{{ $post->title }}</h1>
|
|
403
|
+
<p>Hola, {{ $user->name }}</p>
|
|
404
|
+
|
|
405
|
+
{{-- {!! !!} imprime HTML SIN escapar. Es la puerta directa a XSS. --}}
|
|
406
|
+
{{-- NUNCA con datos de usuario sin sanitizar. --}}
|
|
407
|
+
<div>{!! $userBio !!}</div> {{-- PELIGRO si $userBio viene del usuario --}}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Si necesitas renderizar HTML provisto por usuarios (p. ej. contenido de un editor rich-text),
|
|
411
|
+
sanitízalo en el servidor con una allowlist antes de guardarlo o mostrarlo:
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
composer require mews/purifier # HTML Purifier para Laravel
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
```php
|
|
418
|
+
// Sanitiza antes de persistir; solo entonces es defendible usar {!! !!}.
|
|
419
|
+
$post->body = clean($request->input('body')); // allowlist de tags/atributos seguros
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Reglas:
|
|
423
|
+
|
|
424
|
+
- `{{ }}` siempre, salvo que tengas una razón explícita y el contenido esté sanitizado.
|
|
425
|
+
- En atributos y URLs, valida esquemas: bloquea `javascript:` en `href`/`src`.
|
|
426
|
+
- En JSON embebido en Blade usa `@json($data)` (escapa correctamente para `<script>`).
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## 9. Secrets — `.env` / `config`, nunca hardcodeados
|
|
431
|
+
|
|
432
|
+
```php
|
|
433
|
+
// PELIGRO — clave hardcodeada en código que va a git:
|
|
434
|
+
$client = new StripeClient('sk_live_51H...');
|
|
435
|
+
|
|
436
|
+
// CORRECTO — el secret vive en .env y se lee vía config (cacheable).
|
|
437
|
+
// .env: STRIPE_SECRET=sk_live_...
|
|
438
|
+
// config/services.php: 'stripe' => ['secret' => env('STRIPE_SECRET')],
|
|
439
|
+
$client = new StripeClient(config('services.stripe.secret'));
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Reglas:
|
|
443
|
+
|
|
444
|
+
- Nunca llames `env()` fuera de archivos `config/*`. Con `config:cache` en producción, `env()`
|
|
445
|
+
devuelve `null` en runtime. Lee siempre vía `config('...')`.
|
|
446
|
+
- `.env` jamás se commitea (debe estar en `.gitignore`); commitea solo `.env.example` con placeholders.
|
|
447
|
+
- Rota cualquier secret que haya tocado un commit o un log, aunque lo hayas borrado después.
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
## 10. Cifrado de datos sensibles — `Crypt`
|
|
452
|
+
|
|
453
|
+
```php
|
|
454
|
+
use Illuminate\Support\Facades\Crypt;
|
|
455
|
+
|
|
456
|
+
// Cifrado simétrico autenticado con APP_KEY (AES-256-GCM).
|
|
457
|
+
$encrypted = Crypt::encryptString($apiTokenDeTercero);
|
|
458
|
+
$plain = Crypt::decryptString($encrypted);
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
```php
|
|
462
|
+
// Mejor: castea el atributo como 'encrypted' para cifrar/descifrar transparente.
|
|
463
|
+
protected function casts(): array
|
|
464
|
+
{
|
|
465
|
+
return [
|
|
466
|
+
'oauth_token' => 'encrypted',
|
|
467
|
+
'recovery_codes' => 'encrypted:array',
|
|
468
|
+
];
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Reglas:
|
|
473
|
+
|
|
474
|
+
- Passwords NO se cifran, se **hashean**: `Hash::make()` / `Hash::check()` (bcrypt/argon2). Cifrar
|
|
475
|
+
un password es un error (es reversible).
|
|
476
|
+
- `Crypt` depende de `APP_KEY`. Si rotas la key, los datos cifrados con la anterior dejan de
|
|
477
|
+
descifrarse: planifica re-cifrado.
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## 11. HTTPS y headers de seguridad
|
|
482
|
+
|
|
483
|
+
```php
|
|
484
|
+
// app/Providers/AppServiceProvider.php — boot()
|
|
485
|
+
use Illuminate\Support\Facades\URL;
|
|
486
|
+
|
|
487
|
+
public function boot(): void
|
|
488
|
+
{
|
|
489
|
+
// Fuerza generación de URLs https en producción (detrás de proxy/load balancer).
|
|
490
|
+
if ($this->app->isProduction()) {
|
|
491
|
+
URL::forceScheme('https');
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Confía en el proxy solo de forma explícita (TrustProxies se configura en `bootstrap/app.php`):
|
|
497
|
+
|
|
498
|
+
```php
|
|
499
|
+
// bootstrap/app.php
|
|
500
|
+
->withMiddleware(function (Middleware $middleware) {
|
|
501
|
+
$middleware->trustProxies(at: '*', headers:
|
|
502
|
+
Illuminate\Http\Request::HEADER_X_FORWARDED_FOR |
|
|
503
|
+
Illuminate\Http\Request::HEADER_X_FORWARDED_PROTO
|
|
504
|
+
);
|
|
505
|
+
})
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Headers de seguridad vía un middleware propio (registrado en el grupo `web`):
|
|
509
|
+
|
|
510
|
+
```bash
|
|
511
|
+
php artisan make:middleware SecurityHeaders
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
```php
|
|
515
|
+
// app/Http/Middleware/SecurityHeaders.php
|
|
516
|
+
use Closure;
|
|
517
|
+
use Illuminate\Http\Request;
|
|
518
|
+
use Symfony\Component\HttpFoundation\Response;
|
|
519
|
+
|
|
520
|
+
public function handle(Request $request, Closure $next): Response
|
|
521
|
+
{
|
|
522
|
+
$response = $next($request);
|
|
523
|
+
|
|
524
|
+
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
|
525
|
+
$response->headers->set('X-Frame-Options', 'DENY');
|
|
526
|
+
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
527
|
+
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
528
|
+
// Content-Security-Policy: define una política acorde a tu frontend.
|
|
529
|
+
$response->headers->set('Content-Security-Policy', "default-src 'self'");
|
|
530
|
+
|
|
531
|
+
return $response;
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
```php
|
|
536
|
+
// bootstrap/app.php — añadir al grupo web.
|
|
537
|
+
->withMiddleware(function (Middleware $middleware) {
|
|
538
|
+
$middleware->web(append: [\App\Http\Middleware\SecurityHeaders::class]);
|
|
539
|
+
})
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
---
|
|
543
|
+
|
|
544
|
+
## 12. Uploads seguros
|
|
545
|
+
|
|
546
|
+
```php
|
|
547
|
+
// En el Form Request: valida tipo real, tamaño y dimensiones.
|
|
548
|
+
public function rules(): array
|
|
549
|
+
{
|
|
550
|
+
return [
|
|
551
|
+
'avatar' => ['required', 'file', 'mimes:jpg,jpeg,png,webp', 'max:2048'], // KB
|
|
552
|
+
'doc' => ['required', 'file', 'mimetypes:application/pdf', 'max:10240'],
|
|
553
|
+
];
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
```php
|
|
558
|
+
// Guarda con nombre generado por Laravel (hash), nunca con el nombre del cliente.
|
|
559
|
+
// Por defecto va a storage/app/private (NO accesible públicamente).
|
|
560
|
+
$path = $request->file('avatar')->store('avatars'); // disco 'local' privado
|
|
561
|
+
// Para archivos públicos, usa el disco 'public' conscientemente:
|
|
562
|
+
// $path = $request->file('doc')->store('docs', 'public');
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
Reglas:
|
|
566
|
+
|
|
567
|
+
- Valida `mimes`/`mimetypes` y `max` SIEMPRE. No confíes en la extensión ni en el `Content-Type` del cliente.
|
|
568
|
+
- Nunca uses `getClientOriginalName()` como nombre de archivo (path traversal / sobrescritura).
|
|
569
|
+
- Sirve archivos privados a través de un controller que verifica autorización, no por URL directa.
|
|
570
|
+
- Almacena uploads fuera del docroot; el directorio de subida no debe poder ejecutar PHP.
|
|
571
|
+
|
|
572
|
+
---
|
|
573
|
+
|
|
574
|
+
## 13. Deploy seguro
|
|
575
|
+
|
|
576
|
+
```bash
|
|
577
|
+
# 1. Generar APP_KEY (una vez por entorno; sin ella Crypt/sesiones fallan).
|
|
578
|
+
php artisan key:generate
|
|
579
|
+
|
|
580
|
+
# 2. Cachear config/rutas/eventos (rápido y obliga a leer secrets vía config()).
|
|
581
|
+
php artisan config:cache
|
|
582
|
+
php artisan route:cache
|
|
583
|
+
php artisan event:cache
|
|
584
|
+
|
|
585
|
+
# 3. Optimizar autoloader de Composer sin dependencias de dev.
|
|
586
|
+
composer install --no-dev --optimize-autoloader
|
|
587
|
+
|
|
588
|
+
# 4. Migraciones sin prompt interactivo.
|
|
589
|
+
php artisan migrate --force
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
Checklist `.env` de producción:
|
|
593
|
+
|
|
594
|
+
```
|
|
595
|
+
✓ APP_ENV=production
|
|
596
|
+
✓ APP_DEBUG=false ← CRÍTICO: con true se exponen stacktraces, queries y secrets
|
|
597
|
+
✓ APP_KEY=base64:... ← generada, no vacía
|
|
598
|
+
✓ APP_URL=https://... ← https, no http
|
|
599
|
+
✓ SESSION_SECURE_COOKIE=true ← cookies de sesión solo por https
|
|
600
|
+
✓ LOG_LEVEL=error ← no loguear debug/info con PII en producción
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
Reglas:
|
|
604
|
+
|
|
605
|
+
- `APP_DEBUG=false` en producción es no negociable. Con `true`, cualquier error 500 filtra
|
|
606
|
+
entorno, conexión a BD, fragmentos de código y a veces valores de `.env`.
|
|
607
|
+
- Nunca dejes `/telescope`, `/horizon`, `/_ignition` ni rutas de debug accesibles sin gate de auth.
|
|
608
|
+
- Verifica los runtime logs tras el deploy, no solo que el build pasó.
|
|
609
|
+
|
|
610
|
+
---
|
|
611
|
+
|
|
612
|
+
## Checklist OWASP por endpoint
|
|
613
|
+
|
|
614
|
+
Antes de mergear cualquier endpoint que toque auth, autorización o datos sensibles:
|
|
615
|
+
|
|
616
|
+
```
|
|
617
|
+
AUTENTICACIÓN
|
|
618
|
+
✓ El endpoint exige auth (auth:sanctum / sesión) si no es deliberadamente público
|
|
619
|
+
✓ Sin token/sesión válida → 401, antes de cualquier lógica
|
|
620
|
+
✓ La identidad sale del token/sesión, NO de headers que el cliente controla
|
|
621
|
+
|
|
622
|
+
AUTORIZACIÓN (Broken Access Control — #1 OWASP)
|
|
623
|
+
✓ Hay una Policy/Gate que decide quién puede ejecutar la acción
|
|
624
|
+
✓ Gate::authorize(...) o middleware can: se invoca en CADA método (no solo en uno)
|
|
625
|
+
✓ IDOR cubierto: al cargar por ID se verifica ownership/permiso del recurso concreto
|
|
626
|
+
|
|
627
|
+
VALIDACIÓN E INPUT
|
|
628
|
+
✓ Toda entrada pasa por un Form Request (authorize() + rules()), no validación inline
|
|
629
|
+
✓ Se usa $request->validated()/safe(), nunca ->all() ni ->input() crudo al persistir
|
|
630
|
+
✓ Mass assignment protegido con $fillable (allowlist); is_admin/user_id NO están ahí
|
|
631
|
+
|
|
632
|
+
INYECCIÓN
|
|
633
|
+
✓ Sin DB::raw/whereRaw/orderByRaw con input concatenado; bindings o allowlist de columnas
|
|
634
|
+
✓ Blade usa {{ }}; cualquier {!! !!} solo con contenido sanitizado en servidor
|
|
635
|
+
|
|
636
|
+
CONFIGURACIÓN Y EXPOSICIÓN
|
|
637
|
+
✓ Rate limiting (throttle) en rutas de auth y de costo (IA, email/SMS, export)
|
|
638
|
+
✓ Secrets vía config()/.env, nunca hardcodeados; .env fuera de git
|
|
639
|
+
✓ Errores de producción no exponen stacktrace ni mensajes internos (APP_DEBUG=false)
|
|
640
|
+
✓ Uploads: mimes/mimetypes + max validados, nombre generado, almacenamiento privado
|
|
641
|
+
✓ CSRF activo en rutas web; excepciones solo para webhooks con firma propia
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
Severidades (alineadas con el skill `security-audit`):
|
|
645
|
+
|
|
646
|
+
- **CRÍTICO**: endpoint sin auth, SQLi por `DB::raw`+input, IDOR sin Policy, `APP_DEBUG=true` en prod, secret hardcodeado en git.
|
|
647
|
+
- **ALTO**: bypass de autorización por rol, mass assignment de `is_admin`/`user_id`, `{!! !!}` con input de usuario.
|
|
648
|
+
- **MEDIO**: sin rate limiting en login/IA, CSRF deshabilitado de más, headers de seguridad ausentes.
|
|
649
|
+
- **BAJO**: cookies sin `Secure`, logs verbosos en producción, dependencias desactualizadas sin CVE activo.
|
|
650
|
+
|
|
651
|
+
---
|
|
652
|
+
|
|
653
|
+
## Relación con otros skills
|
|
654
|
+
|
|
655
|
+
- Complementa a `security-audit` (checklist agnóstico al stack) con la implementación concreta en Laravel.
|
|
656
|
+
- `new-feature` lo invoca cuando la feature toca auth, autorización o datos sensibles.
|
|
657
|
+
- Lo puede invocar el agente `forge-audit-specialist` o un reviewer durante un PR.
|
|
658
|
+
- Standalone: no depende de otros skills para ejecutarse.
|