@malamute/ai-rules 1.0.0 → 1.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/README.md +270 -121
- package/bin/cli.js +5 -2
- package/configs/_shared/.claude/rules/conventions/documentation.md +324 -0
- package/configs/_shared/.claude/rules/conventions/git.md +265 -0
- package/configs/_shared/.claude/rules/{performance.md → conventions/performance.md} +1 -1
- package/configs/_shared/.claude/rules/conventions/principles.md +334 -0
- package/configs/_shared/.claude/rules/devops/ci-cd.md +262 -0
- package/configs/_shared/.claude/rules/devops/docker.md +275 -0
- package/configs/_shared/.claude/rules/devops/nx.md +194 -0
- package/configs/_shared/.claude/rules/domain/backend/api-design.md +203 -0
- package/configs/_shared/.claude/rules/lang/csharp/async.md +220 -0
- package/configs/_shared/.claude/rules/lang/csharp/csharp.md +314 -0
- package/configs/_shared/.claude/rules/lang/csharp/linq.md +210 -0
- package/configs/_shared/.claude/rules/lang/python/async.md +337 -0
- package/configs/_shared/.claude/rules/lang/python/celery.md +476 -0
- package/configs/_shared/.claude/rules/lang/python/config.md +339 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/database/sqlalchemy.md +6 -1
- package/configs/_shared/.claude/rules/lang/python/deployment.md +523 -0
- package/configs/_shared/.claude/rules/lang/python/error-handling.md +330 -0
- package/configs/_shared/.claude/rules/lang/python/migrations.md +421 -0
- package/configs/_shared/.claude/rules/lang/python/python.md +172 -0
- package/configs/_shared/.claude/rules/lang/python/repository.md +383 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/testing.md +2 -69
- package/configs/_shared/.claude/rules/lang/typescript/async.md +447 -0
- package/configs/_shared/.claude/rules/lang/typescript/generics.md +356 -0
- package/configs/_shared/.claude/rules/lang/typescript/typescript.md +212 -0
- package/configs/_shared/.claude/rules/quality/error-handling.md +48 -0
- package/configs/_shared/.claude/rules/quality/logging.md +45 -0
- package/configs/_shared/.claude/rules/quality/observability.md +240 -0
- package/configs/_shared/.claude/rules/quality/testing-patterns.md +65 -0
- package/configs/_shared/.claude/rules/security/secrets-management.md +222 -0
- package/configs/_shared/.claude/skills/analysis/explore/SKILL.md +257 -0
- package/configs/_shared/.claude/skills/analysis/security-audit/SKILL.md +184 -0
- package/configs/_shared/.claude/skills/dev/api-endpoint/SKILL.md +126 -0
- package/configs/_shared/.claude/{commands/generate-tests.md → skills/dev/generate-tests/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/fix-issue.md → skills/git/fix-issue/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/review-pr.md → skills/git/review-pr/SKILL.md} +6 -0
- package/configs/_shared/.claude/skills/infra/deploy/SKILL.md +139 -0
- package/configs/_shared/.claude/skills/infra/docker/SKILL.md +95 -0
- package/configs/_shared/.claude/skills/infra/migration/SKILL.md +158 -0
- package/configs/_shared/.claude/skills/nx/nx-affected/SKILL.md +72 -0
- package/configs/_shared/.claude/skills/nx/nx-lib/SKILL.md +375 -0
- package/configs/_shared/CLAUDE.md +52 -149
- package/configs/angular/.claude/rules/{components.md → core/components.md} +69 -15
- package/configs/angular/.claude/rules/core/resource.md +285 -0
- package/configs/angular/.claude/rules/core/signals.md +323 -0
- package/configs/angular/.claude/rules/http.md +338 -0
- package/configs/angular/.claude/rules/routing.md +291 -0
- package/configs/angular/.claude/rules/ssr.md +312 -0
- package/configs/angular/.claude/rules/state/signal-store.md +408 -0
- package/configs/angular/.claude/rules/{state.md → state/state.md} +2 -2
- package/configs/angular/.claude/rules/testing.md +7 -7
- package/configs/angular/.claude/rules/ui/aria.md +422 -0
- package/configs/angular/.claude/rules/ui/forms.md +424 -0
- package/configs/angular/.claude/rules/ui/pipes-directives.md +335 -0
- package/configs/angular/.claude/settings.json +1 -0
- package/configs/angular/.claude/skills/ngrx-slice/SKILL.md +362 -0
- package/configs/angular/.claude/skills/signal-store/SKILL.md +445 -0
- package/configs/angular/CLAUDE.md +24 -216
- package/configs/dotnet/.claude/rules/background-services.md +552 -0
- package/configs/dotnet/.claude/rules/configuration.md +426 -0
- package/configs/dotnet/.claude/rules/ddd.md +447 -0
- package/configs/dotnet/.claude/rules/dependency-injection.md +343 -0
- package/configs/dotnet/.claude/rules/mediatr.md +320 -0
- package/configs/dotnet/.claude/rules/middleware.md +489 -0
- package/configs/dotnet/.claude/rules/result-pattern.md +363 -0
- package/configs/dotnet/.claude/rules/validation.md +388 -0
- package/configs/dotnet/.claude/settings.json +21 -3
- package/configs/dotnet/CLAUDE.md +53 -286
- package/configs/fastapi/.claude/rules/background-tasks.md +254 -0
- package/configs/fastapi/.claude/rules/dependencies.md +170 -0
- package/configs/{python → fastapi}/.claude/rules/fastapi.md +61 -1
- package/configs/fastapi/.claude/rules/lifespan.md +274 -0
- package/configs/fastapi/.claude/rules/middleware.md +229 -0
- package/configs/fastapi/.claude/rules/pydantic.md +433 -0
- package/configs/fastapi/.claude/rules/responses.md +251 -0
- package/configs/fastapi/.claude/rules/routers.md +202 -0
- package/configs/fastapi/.claude/rules/security.md +222 -0
- package/configs/fastapi/.claude/rules/testing.md +251 -0
- package/configs/fastapi/.claude/rules/websockets.md +298 -0
- package/configs/fastapi/.claude/settings.json +33 -0
- package/configs/fastapi/CLAUDE.md +144 -0
- package/configs/flask/.claude/rules/blueprints.md +208 -0
- package/configs/flask/.claude/rules/cli.md +285 -0
- package/configs/flask/.claude/rules/configuration.md +281 -0
- package/configs/flask/.claude/rules/context.md +238 -0
- package/configs/flask/.claude/rules/error-handlers.md +278 -0
- package/configs/flask/.claude/rules/extensions.md +278 -0
- package/configs/flask/.claude/rules/flask.md +171 -0
- package/configs/flask/.claude/rules/marshmallow.md +206 -0
- package/configs/flask/.claude/rules/security.md +267 -0
- package/configs/flask/.claude/rules/testing.md +284 -0
- package/configs/flask/.claude/settings.json +33 -0
- package/configs/flask/CLAUDE.md +166 -0
- package/configs/nestjs/.claude/rules/common-patterns.md +300 -0
- package/configs/nestjs/.claude/rules/filters.md +376 -0
- package/configs/nestjs/.claude/rules/interceptors.md +317 -0
- package/configs/nestjs/.claude/rules/middleware.md +321 -0
- package/configs/nestjs/.claude/rules/modules.md +26 -0
- package/configs/nestjs/.claude/rules/pipes.md +351 -0
- package/configs/nestjs/.claude/rules/websockets.md +451 -0
- package/configs/nestjs/.claude/settings.json +16 -2
- package/configs/nestjs/CLAUDE.md +57 -215
- package/configs/nextjs/.claude/rules/api-routes.md +358 -0
- package/configs/nextjs/.claude/rules/authentication.md +355 -0
- package/configs/nextjs/.claude/rules/components.md +52 -0
- package/configs/nextjs/.claude/rules/data-fetching.md +249 -0
- package/configs/nextjs/.claude/rules/database.md +400 -0
- package/configs/nextjs/.claude/rules/middleware.md +303 -0
- package/configs/nextjs/.claude/rules/routing.md +324 -0
- package/configs/nextjs/.claude/rules/seo.md +350 -0
- package/configs/nextjs/.claude/rules/server-actions.md +353 -0
- package/configs/nextjs/.claude/rules/state/zustand.md +6 -6
- package/configs/nextjs/.claude/settings.json +5 -0
- package/configs/nextjs/CLAUDE.md +69 -331
- package/package.json +23 -9
- package/src/cli.js +220 -0
- package/src/config.js +29 -0
- package/src/index.js +13 -0
- package/src/installer.js +361 -0
- package/src/merge.js +116 -0
- package/src/tech-config.json +29 -0
- package/src/utils.js +96 -0
- package/configs/python/.claude/rules/flask.md +0 -332
- package/configs/python/.claude/settings.json +0 -18
- package/configs/python/CLAUDE.md +0 -273
- package/src/install.js +0 -315
- /package/configs/_shared/.claude/rules/{accessibility.md → domain/frontend/accessibility.md} +0 -0
- /package/configs/_shared/.claude/rules/{security.md → security/security.md} +0 -0
- /package/configs/_shared/.claude/skills/{debug → dev/debug}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{learning → dev/learning}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{spec → dev/spec}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{review → git/review}/SKILL.md +0 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Marshmallow Validation Rules
|
|
2
|
+
|
|
3
|
+
## Activation
|
|
4
|
+
|
|
5
|
+
```yaml
|
|
6
|
+
paths:
|
|
7
|
+
- "**/*.py"
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Schema Definition
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
# GOOD - Clear schema with validation
|
|
14
|
+
from marshmallow import Schema, fields, validate, validates, ValidationError, post_load
|
|
15
|
+
|
|
16
|
+
class UserCreateSchema(Schema):
|
|
17
|
+
email = fields.Email(required=True)
|
|
18
|
+
password = fields.Str(required=True, validate=validate.Length(min=8))
|
|
19
|
+
name = fields.Str(required=True, validate=validate.Length(min=1, max=100))
|
|
20
|
+
age = fields.Int(validate=validate.Range(min=0, max=150))
|
|
21
|
+
|
|
22
|
+
class UserResponseSchema(Schema):
|
|
23
|
+
id = fields.Int(dump_only=True)
|
|
24
|
+
email = fields.Email()
|
|
25
|
+
name = fields.Str()
|
|
26
|
+
created_at = fields.DateTime(dump_only=True)
|
|
27
|
+
|
|
28
|
+
# BAD - No validation
|
|
29
|
+
class UserSchema(Schema):
|
|
30
|
+
email = fields.Str() # Should be fields.Email
|
|
31
|
+
password = fields.Str() # No length validation
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Custom Validation
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
# GOOD - Field-level validation
|
|
38
|
+
class UserSchema(Schema):
|
|
39
|
+
username = fields.Str(required=True)
|
|
40
|
+
|
|
41
|
+
@validates("username")
|
|
42
|
+
def validate_username(self, value):
|
|
43
|
+
if not value.isalnum():
|
|
44
|
+
raise ValidationError("Username must be alphanumeric")
|
|
45
|
+
if len(value) < 3:
|
|
46
|
+
raise ValidationError("Username must be at least 3 characters")
|
|
47
|
+
|
|
48
|
+
# GOOD - Cross-field validation
|
|
49
|
+
from marshmallow import validates_schema
|
|
50
|
+
|
|
51
|
+
class PasswordChangeSchema(Schema):
|
|
52
|
+
password = fields.Str(required=True)
|
|
53
|
+
password_confirm = fields.Str(required=True)
|
|
54
|
+
|
|
55
|
+
@validates_schema
|
|
56
|
+
def validate_passwords_match(self, data, **kwargs):
|
|
57
|
+
if data.get("password") != data.get("password_confirm"):
|
|
58
|
+
raise ValidationError("Passwords must match", field_name="password_confirm")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Load/Dump Hooks
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from marshmallow import pre_load, post_load, post_dump
|
|
65
|
+
|
|
66
|
+
class UserSchema(Schema):
|
|
67
|
+
email = fields.Email(required=True)
|
|
68
|
+
name = fields.Str(required=True)
|
|
69
|
+
|
|
70
|
+
@pre_load
|
|
71
|
+
def normalize_email(self, data, **kwargs):
|
|
72
|
+
if "email" in data:
|
|
73
|
+
data["email"] = data["email"].lower().strip()
|
|
74
|
+
return data
|
|
75
|
+
|
|
76
|
+
@post_load
|
|
77
|
+
def make_user(self, data, **kwargs):
|
|
78
|
+
return User(**data)
|
|
79
|
+
|
|
80
|
+
@post_dump
|
|
81
|
+
def remove_nulls(self, data, **kwargs):
|
|
82
|
+
return {k: v for k, v in data.items() if v is not None}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Nested Schemas
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
# GOOD - Nested relationships
|
|
89
|
+
class AddressSchema(Schema):
|
|
90
|
+
street = fields.Str(required=True)
|
|
91
|
+
city = fields.Str(required=True)
|
|
92
|
+
country = fields.Str(required=True)
|
|
93
|
+
|
|
94
|
+
class UserSchema(Schema):
|
|
95
|
+
name = fields.Str(required=True)
|
|
96
|
+
address = fields.Nested(AddressSchema)
|
|
97
|
+
addresses = fields.List(fields.Nested(AddressSchema))
|
|
98
|
+
|
|
99
|
+
# GOOD - Self-referential (e.g., comments with replies)
|
|
100
|
+
class CommentSchema(Schema):
|
|
101
|
+
id = fields.Int(dump_only=True)
|
|
102
|
+
text = fields.Str(required=True)
|
|
103
|
+
replies = fields.List(fields.Nested("self", exclude=("replies",)))
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Partial Loading
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
# GOOD - Partial updates
|
|
110
|
+
class UserUpdateSchema(Schema):
|
|
111
|
+
email = fields.Email()
|
|
112
|
+
name = fields.Str(validate=validate.Length(min=1, max=100))
|
|
113
|
+
|
|
114
|
+
@users_bp.route("/<int:user_id>", methods=["PATCH"])
|
|
115
|
+
def update_user(user_id: int):
|
|
116
|
+
schema = UserUpdateSchema()
|
|
117
|
+
data = schema.load(request.get_json(), partial=True)
|
|
118
|
+
user = UserService.update(user_id, data)
|
|
119
|
+
return jsonify(UserResponseSchema().dump(user))
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Schema Inheritance
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
# GOOD - Base schema with common fields
|
|
126
|
+
class BaseSchema(Schema):
|
|
127
|
+
class Meta:
|
|
128
|
+
strict = True
|
|
129
|
+
|
|
130
|
+
class TimestampMixin:
|
|
131
|
+
created_at = fields.DateTime(dump_only=True)
|
|
132
|
+
updated_at = fields.DateTime(dump_only=True)
|
|
133
|
+
|
|
134
|
+
class UserSchema(BaseSchema, TimestampMixin):
|
|
135
|
+
id = fields.Int(dump_only=True)
|
|
136
|
+
email = fields.Email(required=True)
|
|
137
|
+
name = fields.Str(required=True)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Error Handling
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
# GOOD - Centralized error handling
|
|
144
|
+
from marshmallow import ValidationError
|
|
145
|
+
from flask import jsonify
|
|
146
|
+
|
|
147
|
+
@app.errorhandler(ValidationError)
|
|
148
|
+
def handle_validation_error(error):
|
|
149
|
+
return jsonify({
|
|
150
|
+
"error": "Validation Error",
|
|
151
|
+
"details": error.messages,
|
|
152
|
+
}), 400
|
|
153
|
+
|
|
154
|
+
# In routes
|
|
155
|
+
@users_bp.route("/", methods=["POST"])
|
|
156
|
+
def create_user():
|
|
157
|
+
schema = UserCreateSchema()
|
|
158
|
+
try:
|
|
159
|
+
data = schema.load(request.get_json())
|
|
160
|
+
except ValidationError as e:
|
|
161
|
+
return jsonify({"errors": e.messages}), 400
|
|
162
|
+
|
|
163
|
+
user = UserService.create(data)
|
|
164
|
+
return jsonify(UserResponseSchema().dump(user)), 201
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## SQLAlchemy Integration
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
# GOOD - With marshmallow-sqlalchemy
|
|
171
|
+
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema
|
|
172
|
+
|
|
173
|
+
class UserSchema(SQLAlchemyAutoSchema):
|
|
174
|
+
class Meta:
|
|
175
|
+
model = User
|
|
176
|
+
include_relationships = True
|
|
177
|
+
load_instance = True
|
|
178
|
+
exclude = ("hashed_password",)
|
|
179
|
+
|
|
180
|
+
# Manual approach (more control)
|
|
181
|
+
class UserSchema(Schema):
|
|
182
|
+
class Meta:
|
|
183
|
+
load_instance = True
|
|
184
|
+
|
|
185
|
+
id = fields.Int(dump_only=True)
|
|
186
|
+
email = fields.Email(required=True)
|
|
187
|
+
|
|
188
|
+
@post_load
|
|
189
|
+
def make_user(self, data, **kwargs):
|
|
190
|
+
return User(**data)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Many Parameter
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
# GOOD - Serialize/deserialize collections
|
|
197
|
+
schema = UserSchema()
|
|
198
|
+
|
|
199
|
+
# Single object
|
|
200
|
+
user_data = schema.dump(user)
|
|
201
|
+
user = schema.load(data)
|
|
202
|
+
|
|
203
|
+
# Multiple objects
|
|
204
|
+
users_data = schema.dump(users, many=True)
|
|
205
|
+
users = schema.load(data_list, many=True)
|
|
206
|
+
```
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Flask Security Patterns
|
|
7
|
+
|
|
8
|
+
## Password Hashing
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from werkzeug.security import generate_password_hash, check_password_hash
|
|
12
|
+
|
|
13
|
+
class User(db.Model):
|
|
14
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
15
|
+
email: Mapped[str] = mapped_column(unique=True)
|
|
16
|
+
password_hash: Mapped[str]
|
|
17
|
+
|
|
18
|
+
def set_password(self, password: str):
|
|
19
|
+
self.password_hash = generate_password_hash(password)
|
|
20
|
+
|
|
21
|
+
def check_password(self, password: str) -> bool:
|
|
22
|
+
return check_password_hash(self.password_hash, password)
|
|
23
|
+
|
|
24
|
+
# Usage
|
|
25
|
+
user = User(email="test@example.com")
|
|
26
|
+
user.set_password("secure_password")
|
|
27
|
+
|
|
28
|
+
if user.check_password("secure_password"):
|
|
29
|
+
print("Password correct")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## CSRF Protection
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from flask_wtf.csrf import CSRFProtect
|
|
36
|
+
|
|
37
|
+
csrf = CSRFProtect()
|
|
38
|
+
csrf.init_app(app)
|
|
39
|
+
|
|
40
|
+
# In templates
|
|
41
|
+
<form method="post">
|
|
42
|
+
{{ csrf_token() }}
|
|
43
|
+
<!-- or -->
|
|
44
|
+
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
45
|
+
</form>
|
|
46
|
+
|
|
47
|
+
# For AJAX requests
|
|
48
|
+
<script>
|
|
49
|
+
fetch('/api/endpoint', {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: {
|
|
52
|
+
'X-CSRFToken': '{{ csrf_token() }}'
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify(data)
|
|
55
|
+
});
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
# Exempt API routes from CSRF
|
|
59
|
+
@csrf.exempt
|
|
60
|
+
@app.route("/api/webhook", methods=["POST"])
|
|
61
|
+
def webhook():
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
# Or exempt entire blueprint
|
|
65
|
+
csrf.exempt(api_bp)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Security Headers
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from flask_talisman import Talisman
|
|
72
|
+
|
|
73
|
+
# Basic security headers
|
|
74
|
+
Talisman(app, force_https=True)
|
|
75
|
+
|
|
76
|
+
# Custom configuration
|
|
77
|
+
Talisman(
|
|
78
|
+
app,
|
|
79
|
+
force_https=True,
|
|
80
|
+
strict_transport_security=True,
|
|
81
|
+
strict_transport_security_max_age=31536000,
|
|
82
|
+
content_security_policy={
|
|
83
|
+
"default-src": "'self'",
|
|
84
|
+
"script-src": ["'self'", "cdn.example.com"],
|
|
85
|
+
"style-src": ["'self'", "'unsafe-inline'"],
|
|
86
|
+
"img-src": ["'self'", "data:", "*.example.com"],
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Manual headers
|
|
91
|
+
@app.after_request
|
|
92
|
+
def add_security_headers(response):
|
|
93
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
94
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
95
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
96
|
+
return response
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Session Security
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# Configuration
|
|
103
|
+
app.config.update(
|
|
104
|
+
SECRET_KEY=os.environ["SECRET_KEY"],
|
|
105
|
+
SESSION_COOKIE_SECURE=True, # HTTPS only
|
|
106
|
+
SESSION_COOKIE_HTTPONLY=True, # No JavaScript access
|
|
107
|
+
SESSION_COOKIE_SAMESITE="Lax", # CSRF protection
|
|
108
|
+
PERMANENT_SESSION_LIFETIME=timedelta(hours=24),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Server-side sessions with Redis
|
|
112
|
+
from flask_session import Session
|
|
113
|
+
|
|
114
|
+
app.config["SESSION_TYPE"] = "redis"
|
|
115
|
+
app.config["SESSION_REDIS"] = redis.from_url(os.environ["REDIS_URL"])
|
|
116
|
+
Session(app)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Input Validation
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from markupsafe import escape
|
|
123
|
+
from marshmallow import Schema, fields, validate
|
|
124
|
+
|
|
125
|
+
# Always validate input
|
|
126
|
+
class UserInputSchema(Schema):
|
|
127
|
+
name = fields.Str(required=True, validate=validate.Length(min=1, max=100))
|
|
128
|
+
email = fields.Email(required=True)
|
|
129
|
+
bio = fields.Str(validate=validate.Length(max=500))
|
|
130
|
+
|
|
131
|
+
@users_bp.route("/", methods=["POST"])
|
|
132
|
+
def create_user():
|
|
133
|
+
schema = UserInputSchema()
|
|
134
|
+
data = schema.load(request.get_json()) # Validates and sanitizes
|
|
135
|
+
user = UserService.create(data)
|
|
136
|
+
return jsonify(UserSchema().dump(user)), 201
|
|
137
|
+
|
|
138
|
+
# Escape output for HTML
|
|
139
|
+
@app.route("/search")
|
|
140
|
+
def search():
|
|
141
|
+
query = request.args.get("q", "")
|
|
142
|
+
safe_query = escape(query) # Prevents XSS
|
|
143
|
+
return render_template("search.html", query=safe_query)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## SQL Injection Prevention
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
# GOOD - Use ORM or parameterized queries
|
|
150
|
+
user = User.query.filter_by(email=email).first()
|
|
151
|
+
|
|
152
|
+
# GOOD - Raw SQL with parameters
|
|
153
|
+
result = db.session.execute(
|
|
154
|
+
text("SELECT * FROM users WHERE email = :email"),
|
|
155
|
+
{"email": email}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# BAD - String interpolation (SQL injection vulnerable!)
|
|
159
|
+
result = db.session.execute(f"SELECT * FROM users WHERE email = '{email}'")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## API Key Authentication
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from functools import wraps
|
|
166
|
+
|
|
167
|
+
def require_api_key(f):
|
|
168
|
+
@wraps(f)
|
|
169
|
+
def decorated(*args, **kwargs):
|
|
170
|
+
api_key = request.headers.get("X-API-Key")
|
|
171
|
+
|
|
172
|
+
if not api_key:
|
|
173
|
+
return jsonify({"error": "API key required"}), 401
|
|
174
|
+
|
|
175
|
+
# Constant-time comparison to prevent timing attacks
|
|
176
|
+
import hmac
|
|
177
|
+
valid_key = current_app.config["API_KEY"]
|
|
178
|
+
if not hmac.compare_digest(api_key, valid_key):
|
|
179
|
+
return jsonify({"error": "Invalid API key"}), 401
|
|
180
|
+
|
|
181
|
+
return f(*args, **kwargs)
|
|
182
|
+
return decorated
|
|
183
|
+
|
|
184
|
+
@api_bp.route("/data")
|
|
185
|
+
@require_api_key
|
|
186
|
+
def get_data():
|
|
187
|
+
return jsonify({"data": "sensitive"})
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Rate Limiting
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from flask_limiter import Limiter
|
|
194
|
+
from flask_limiter.util import get_remote_address
|
|
195
|
+
|
|
196
|
+
limiter = Limiter(
|
|
197
|
+
key_func=get_remote_address,
|
|
198
|
+
default_limits=["200 per day", "50 per hour"],
|
|
199
|
+
storage_uri="redis://localhost:6379",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
@auth_bp.route("/login", methods=["POST"])
|
|
203
|
+
@limiter.limit("5 per minute")
|
|
204
|
+
def login():
|
|
205
|
+
...
|
|
206
|
+
|
|
207
|
+
# Per-user rate limiting
|
|
208
|
+
@limiter.limit("100 per hour", key_func=lambda: current_user.id)
|
|
209
|
+
def user_endpoint():
|
|
210
|
+
...
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Secure File Uploads
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from werkzeug.utils import secure_filename
|
|
217
|
+
import os
|
|
218
|
+
|
|
219
|
+
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf"}
|
|
220
|
+
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB
|
|
221
|
+
|
|
222
|
+
app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH
|
|
223
|
+
|
|
224
|
+
def allowed_file(filename: str) -> bool:
|
|
225
|
+
return "." in filename and \
|
|
226
|
+
filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
|
227
|
+
|
|
228
|
+
@files_bp.route("/upload", methods=["POST"])
|
|
229
|
+
def upload_file():
|
|
230
|
+
if "file" not in request.files:
|
|
231
|
+
return jsonify({"error": "No file provided"}), 400
|
|
232
|
+
|
|
233
|
+
file = request.files["file"]
|
|
234
|
+
|
|
235
|
+
if file.filename == "":
|
|
236
|
+
return jsonify({"error": "No file selected"}), 400
|
|
237
|
+
|
|
238
|
+
if not allowed_file(file.filename):
|
|
239
|
+
return jsonify({"error": "File type not allowed"}), 400
|
|
240
|
+
|
|
241
|
+
# Secure the filename
|
|
242
|
+
filename = secure_filename(file.filename)
|
|
243
|
+
|
|
244
|
+
# Generate unique filename
|
|
245
|
+
unique_filename = f"{uuid.uuid4()}_{filename}"
|
|
246
|
+
|
|
247
|
+
# Save to secure location
|
|
248
|
+
file.save(os.path.join(app.config["UPLOAD_FOLDER"], unique_filename))
|
|
249
|
+
|
|
250
|
+
return jsonify({"filename": unique_filename}), 201
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Secrets Management
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
import os
|
|
257
|
+
|
|
258
|
+
# GOOD - Environment variables
|
|
259
|
+
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]
|
|
260
|
+
app.config["DATABASE_URL"] = os.environ["DATABASE_URL"]
|
|
261
|
+
|
|
262
|
+
# GOOD - Secrets file (not in repo)
|
|
263
|
+
# config/secrets.py (in .gitignore)
|
|
264
|
+
|
|
265
|
+
# BAD - Hardcoded secrets
|
|
266
|
+
app.config["SECRET_KEY"] = "hardcoded-secret" # NEVER do this
|
|
267
|
+
```
|