The previous lesson closed the network, but it left one door that has to stay open: port 443 on the API. That is where Nimbus's product lives, and no firewall rule can tell a legitimate booking from an attempt to read another clinic's data. Only the application can make that distinction. This lesson goes into the code: how security is built into the development lifecycle, how the attacks in the catalogue from 02-02 are genuinely fixed, how the IDOR lesson is generalised so it cannot happen again through forgetfulness, and how all of that is automated in a pipeline that breaks the build when it should.

Contents

  1. The secure development lifecycle (SSDLC)
  2. Input validation: allow rather than block
  3. Injection: parameterised queries and the misused ORM
  4. XSS, CSRF and the browser as a boundary
  5. Access control: from IDOR to centralised authorisation
  6. SSRF, deserialisation and file upload
  7. API security
  8. Managing secrets in the code and in the deployment
  9. Security headers and rolling out a CSP
  10. Dependencies and the product's supply chain
  11. Security testing in CI and code review
  12. Errors, frontend, mobile and metrics

  1. The secure development lifecycle (SSDLC)

Security is not a phase before launch: it is a property decided at every stage. The figure that justifies the approach is well known and consistent across the industry: fixing a flaw at design time costs one unit; in development, around five; in testing, around fifteen; and in production, between thirty and a hundred — because that includes redesign, an emergency deployment, customer communication and, sometimes, breach notification. Nimbus's IDOR illustrates it: caught at design time it would have been a ten-minute architectural decision; caught in the pentest it was four hours of development, two of testing, a retest and an entry in the risk register.

flowchart LR
    R["REQUIREMENTS\nSecurity as an acceptance\ncriterion, not a wish"] --> D["DESIGN\nThreat modelling\nSTRIDE + DFD (01-04)"]
    D --> C["DEVELOPMENT\nSecure patterns, secrets\nmanager, gitleaks pre-commit"]
    C --> P["TESTING\nSAST + SCA + DAST\nin CI (11)"]
    P --> DE["DEPLOYMENT\nArtefact signing (03-07),\ninjected secrets"]
    DE --> O["OPERATION\nDetection (05-02), vulns\n(05-01), response (04-05)"]
    O -->|"what is learned returns to design"| D

What makes this work in a company with five technical people is not a heavyweight process: it is three cheap control points. An explicit security requirement in the stories that touch personal data; half an hour of threat modelling when designing a new module; and a CI that says no. Everything else is refinement.


  1. Input validation: allow rather than block

All input is hostile until proven otherwise, and "input" is not just the form: it is URL parameters, headers, cookies, files, queue messages and third-party responses.

Blocklist (denylist) Allowlist
Defines What is forbidden What is permitted
Fails on Everything that was not imagined Nothing: the unknown is rejected
Maintenance and example Constant, always one step behind: "reject if it contains <script>" Stable: "the name is 1-80 characters from this set"

The blocklist always loses, because the attacker only needs one encoding that was not on the list. Correct validation defines the expected shape of the data: type, length, range, format and set of admissible values.

from pydantic import BaseModel, Field, EmailStr, field_validator
from datetime import datetime

class CreateBooking(BaseModel):
    # Each field declares its shape (type, length, pattern). Whatever does not fit
    # is rejected with a 422 BEFORE touching any business logic.
    name: str = Field(min_length=1, max_length=80, pattern=r"^[\w\s'\-\.áéíóúñÁÉÍÓÚÑ]+$")
    email: EmailStr
    phone: str = Field(pattern=r"^\+?[0-9]{9,15}$")
    start: datetime
    notes: str = Field(default="", max_length=500)

    @field_validator("start")
    @classmethod
    def not_in_past(cls, v: datetime) -> datetime:
        # A BUSINESS rule, not a format one: without it the type is correct and the
        # application accepts bookings in 1970. Syntax alone is not enough.
        if v < datetime.now(v.tzinfo):
            raise ValueError("The start date cannot be in the past")
        return v

Two clarifications that avoid a frequent confusion. Validation does not replace the protection specific to each destination: a validated name still needs a parameterised query when it goes to SQL and encoding when it goes to HTML, because the danger is not in the data but in the context where it is used. And validation is always done on the server; the browser's is usability, and getting around it is as easy as using curl.


  1. Injection: parameterised queries and the misused ORM

# BAD - concatenation. The user's text turns into SQL code.
db.execute(f"SELECT * FROM bookings WHERE customer = '{name}' AND tenant_id = {tid}")

# ALSO BAD - the ORM does not protect you if you hand it hand-built SQL. This is
# the real mistake: the team believes it is protected "because it uses the ORM".
session.execute(text(f"SELECT * FROM bookings WHERE customer = '{name}'"))

# GOOD - parameterised: the structure travels down one channel and the data down another.
# The engine NEVER interprets the parameter as SQL, whatever it contains.
session.execute(
    text("SELECT * FROM bookings WHERE customer = :name AND tenant_id = :tid"),
    {"name": name, "tid": current_tenant})

# GOOD - the ORM API, which parameterises by construction.
session.query(Booking).filter(Booking.customer == name,
                              Booking.tenant_id == current_tenant).all()

Why parameterisation works and escaping does not: when you parameterise, the engine first receives the structure of the query, compiles it and only then inserts the values as data. There is no string to interpret. Manual escaping, by contrast, depends on getting every encoding and every character set right, and one unforeseen case is enough to break it.

What cannot be parameterised are the identifiers: table names, column names and the sort direction. There is only one correct solution there, and it is translating against a closed list: a dictionary {"date": "start", "customer": "customer"} where the key is what the user sends and the value is the real column name, with a default for anything unknown, and a direction that can only be ASC or DESC. What comes from the client is never interpolated directly.

And two defence-in-depth layers you already know: the nimbus_api role with no DDL permissions (so an injection cannot create or drop tables) and RLS in PostgreSQL, which filters by tenant in the engine itself even if the query gets it wrong.


  1. XSS, CSRF and the browser as a boundary

XSS happens when user-controlled data ends up interpreted as code in somebody else's browser. The defence has two layers.

The first is encoding on output, according to the context: text in HTML, a value inside an attribute, a string in JavaScript and a parameter in a URL are not encoded the same way. In Nimbus's SPA (React) the framework already escapes text content by default, and the danger is concentrated in the exceptions: dangerouslySetInnerHTML, dynamic URL construction (href="javascript:...") and server-side templates. If rich HTML has to be accepted, it is sanitised with a trusted library and an allowlist of tags and attributes; writing your own sanitiser is a guarantee of failure.

The second layer is the CSP (§9), which limits the damage when the first one fails. And one measure that determines the real impact: the session cookie with HttpOnly, so that an XSS cannot read it.

CSRF is different: the attacker steals nothing, but makes the victim's browser perform a legitimate action, taking advantage of the fact that cookies are sent automatically.

response.set_cookie(        # the four properties that matter
    "session", token,
    httponly=True,   # invisible to JavaScript: neutralises theft via XSS
    secure=True,     # over HTTPS only
    samesite="Lax",  # does not travel on a POST from another site -> cuts CSRF
    max_age=28800, path="/")

SameSite=Lax cuts most CSRF on its own, and Strict is stricter still at the cost of breaking inbound links. For sensitive operations an anti-CSRF token is added using the double-submit pattern: a random value in a readable cookie and the same value in a header that your own JavaScript adds; another site cannot read the cookie, so it cannot compose the header. And one rule solves half the problem by design: requests that change state are never GET.

An architectural note: if the SPA authenticates with a token in the Authorization header instead of a cookie, classic CSRF disappears — the browser does not add that header on its own — but the problem of where to store the token appears, which is covered in §12.


  1. Access control: from IDOR to centralised authorisation

Broken access control is the number-one flaw in the OWASP Top 10, and it is the one that has cost Nimbus the most: an IDOR in 01-04 and a residual one in the pentest in 05-03. The pattern of the mistake repeats: authorisation is decided in each route, and one developer forgetting once is enough.

# BEFORE - each endpoint remembers (or does not) to filter. The IDOR is a matter of time.
@router.get("/reservas/{booking_id}")
def view(booking_id: int, db=Depends(get_db)):
    return db.get(Booking, booking_id)      # returns ANYBODY'S booking

# AFTER - authorisation is centralised into reusable dependencies.

def get_current_user(token: str = Depends(oauth2)) -> User:
    return verify_token(token)                        # 03-07

def current_tenant(u: User = Depends(get_current_user)) -> int:
    return u.tenant_id      # from the verified token, NEVER from the request

def require(*permissions: str):
    """Dependency factory: declares the required permission in the route itself."""
    def check(u: User = Depends(get_current_user)) -> User:
        if set(permissions) - set(ROLES[u.role]):     # roles.yaml (module 2)
            # Logging the denial feeds the escalation detection (05-02).
            log.warning("authz.denied", extra={"fields": {"actor_id": u.id}})
            raise HTTPException(403, "Not authorised")
        return u
    return check

def get_booking(booking_id: int, tid: int = Depends(current_tenant),
                db=Depends(get_db)) -> Booking:
    """The SINGLE point where a booking is resolved: the tenant filter lives
    here, so bypassing it requires a deliberate act and not forgetfulness."""
    b = db.query(Booking).filter(Booking.id == booking_id,
                                 Booking.tenant_id == tid).first()
    if not b:
        # 404 and not 403: a 403 would confirm that this id exists in ANOTHER tenant.
        raise HTTPException(404, "Not found")
    return b

@router.get("/reservas/{booking_id}")
def view(booking: Booking = Depends(get_booking),
         _=Depends(require("bookings:read"))):
    return booking

Five principles are made concrete in that code: deny by default (with no permission declared, nothing gets through); the tenant identifier is never user input; a single point of resolution per resource, so that failure requires a deliberate act and not forgetfulness; 404 instead of 403 so as not to confirm the existence of other people's resources; and logging every denial, which is the source of the escalation detection.

Three reinforcements complete the model: RLS in PostgreSQL as a safety net underneath in case the query gets it wrong; automated tenant isolation tests in CI — one test per endpoint that tries, with tenant A's token, to reach a resource belonging to tenant B and requires a 404; and checking that the identifiers are not predictable (UUIDs instead of consecutive integers), which is not a control on its own but greatly raises the cost of discovering the flaw.


  1. SSRF, deserialisation and file upload

SSRF is getting your server to make a request to a destination the attacker chooses. In the cloud it is especially serious because the instance metadata service answers on an internal IP and hands out credentials.

import ipaddress, socket
from urllib.parse import urlparse

ALLOWED_DOMAINS = {"api.pasarela-ejemplo.com", "hooks.proveedor-correo.com"}

def safe_url(url: str) -> str:
    p = urlparse(url)
    if p.scheme != "https" or p.hostname not in ALLOWED_DOMAINS:
        raise ValueError("Destination not permitted")   # ALLOWLIST
    # Check the resolved IP too: an allowed domain can point at an internal IP,
    # and without this the name allowlist is bypassed via DNS.
    ip = ipaddress.ip_address(socket.gethostbyname(p.hostname))
    if ip.is_private or ip.is_loopback or ip.is_link_local:
        raise ValueError("Internal destination blocked")  # covers 169.254.169.254
    return url

Essential complements: block the metadata service in the egress firewall (05-04) and require its session-based version (IMDSv2 or equivalent, 05-07); do not follow redirects automatically, because an allowed destination can redirect to an internal one; and short timeouts.

Insecure deserialisation: you never deserialise a format that can instantiate arbitrary objects (pickle, yaml.load without SafeLoader, eval) with data that comes from outside. You use JSON with a validated schema, full stop.

File upload (the scanned forms in bucket A-02), with six controls applied together:

TYPES = {"application/pdf": ".pdf", "image/jpeg": ".jpg", "image/png": ".png"}
MAX = 10 * 1024 * 1024

async def upload(f: UploadFile, tid: int = Depends(current_tenant)):
    data = await f.read(MAX + 1)
    if len(data) > MAX:
        raise HTTPException(413, "File too large")              # 1) size
    # 2) REAL type from the content, not from the extension or the Content-Type:
    #    the attacker controls both.
    mime = magic.from_buffer(data, mime=True)
    if mime not in TYPES:
        raise HTTPException(415, "Type not permitted")
    key = f"tenant/{tid}/{uuid4().hex}{TYPES[mime]}"     # 3) generated name
    # 4) Outside the webroot, in the private bucket (never a public one).
    s3.put_object(Bucket="nimbus-adjuntos-prod", Key=key, Body=data,
                  ContentType=mime, ServerSideEncryption="aws:kms")
    queue.send("scan_attachment", {"key": key})             # 5) antivirus
    return {"key": key}

And the sixth control, already familiar: the download is served with a 120-second signed URL (03-07) generated after the authorisation has been checked, never with a public link and never by serving the file from the application server.


  1. API security

The OWASP API Security Top 10 exists because APIs fail differently from websites: there is no interface limiting what can be asked for.

Risk What it is At Nimbus
API1 BOLA Broken authorisation at object level: reaching somebody else's resource The IDOR. Resolved in §5
API2 Broken authentication · API3 BOPLA Login with no rate limit · broken authorisation at property level: returning or accepting extra fields Fixed in 05-03 · the profile returned role and tenant_id, and accepted role on the PATCH
API4 Unrestricted consumption · API5 Function-level authorisation · API7 SSRF No pagination or quotas · a normal user calls an administrative endpoint · §6 A per-tenant quota · require("...") on every route · allowlist
API8 Misconfiguration · API9 Inventory Open CORS, verbose errors · versions and environments never retired Explicit CORS · v0 still live and unmanaged, and A-22

BOPLA deserves code, because it is the quietest one: the object is returned whole "because it is convenient" and leaks internal fields, or it is accepted whole and lets a client promote themselves to administrator.

class ProfileOut(BaseModel):                  # OUTPUT allowlist:
    id: UUID; name: str; email: EmailStr      # `role`, `tenant_id` and the hash do not go out
class ProfileIn(BaseModel):
    name: str | None = None; phone: str | None = None
    model_config = {"extra": "forbid"}        # a `role` in the body -> error 422

@router.patch("/perfil", response_model=ProfileOut)
def update(changes: ProfileIn, u: User = Depends(get_current_user)):
    return repo.update(u.id, changes.model_dump(exclude_unset=True))

extra: "forbid" is the line that prevents mass assignment: without it, any field sent that matches an attribute of the model would be applied silently. And response_model guarantees that the output is always filtered, even if the internal object grows new fields in the future.

Completing the chapter: rate limiting per customer and per tenant, not just per IP (a clinic with a hundred employees shares an IP, and an attacker with a hundred IPs shares an account); versioning with an announced retirement date, because the biggest risk of versioning is the v0 nobody switched off; mandatory pagination with a hard cap on the server (limit at most 100, even if they ask for 10,000); CORS with an explicit list of origins, never * with credentials; and documentation that does not leak — the public OpenAPI schema describes only the public endpoints, and /docs is not served in production without authentication.


  1. Managing secrets in the code and in the deployment

Method Security When
Secret in the code Unacceptable Never. It is R-06 and day 5 of 02-06
.env file on the server · injected environment variable Low · medium Local development with .gitignore · acceptable if the source is a manager
Secrets manager with role-based access and rotation High Nimbus's target (03-06); in CI, per-environment encrypted variables

Three operating rules. The container receives the secret at start-up, from the manager and with a service identity, and never carries it baked into the image (05-07). CI secrets are scoped per environment, so that a pull request from a branch does not reach production credentials — a very common and very exploitable flaw. And prevention goes before the mistake: gitleaks declared in .pre-commit-config.yaml (repository gitleaks/gitleaks, rev: v8.18.4, hooks: [{id: gitleaks}]) is the only barrier that acts before the secret enters the history. The CI scan (§11) is the net for anyone without the hook installed, and immediate rotation is still mandatory when something slips through.


  1. Security headers and rolling out a CSP

# CSP: the strongest defence against XSS. 'self' = our own origin only.
# No 'unsafe-inline' and no 'unsafe-eval', which cancel out much of its value.
add_header Content-Security-Policy "default-src 'self'; script-src 'self';
  style-src 'self'; img-src 'self' data:; font-src 'self';
  connect-src 'self' https://api.nimbusreservas.example;
  frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" always;

# HTTPS mandatory for one year, subdomains included. Only once EVERY subdomain
# serves HTTPS: reverting it in browsers takes months.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

# Stops the browser "guessing" the type and executing as a script something
# served as text or an image.
add_header X-Content-Type-Options "nosniff" always;

# Limits what travels in the Referer to other sites (URLs with identifiers).
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# Switches off browser capabilities the application does not use.
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
server_tokens off;   # and what should NOT be there: the server version

Notes: frame-ancestors 'none' replaces the old X-Frame-Options and prevents clickjacking; always makes the header be sent on error responses too, which is where it gets forgotten; and preload is only requested when you are sure, because reverting it takes months.

How a CSP is rolled out without breaking the SPA, in four steps: (1) publish the policy as Content-Security-Policy-Report-Only with your own report-uri, so that it blocks nothing and only reports; (2) collect reports for two weeks, which is how long it takes for all the real user flows to be exercised; (3) adjust the policy for the legitimate origins that appear — usually analytics, fonts and the odd payment iframe — without falling into the temptation of adding 'unsafe-inline' to silence the warnings, because that switches off the main protection; and (4) move to blocking mode and keep the report-uri, which from then on becomes a detection signal: new reports may mean an XSS in progress.


  1. Dependencies and the product's supply chain

Linking back to 04-04, Nimbus's policy has five pieces. Pinning by hash: a requirements.txt generated with pip-compile --generate-hashes and installed with pip install --require-hashes, so that a version republished with different content breaks the installation instead of slipping in unannounced. An update bot (Dependabot or Renovate) that opens a pull request per dependency, grouping patch updates weekly and separating the major ones, with the deadline policy from 05-01. Enough test coverage to be able to merge a security update the same day without fear: without tests, patching gets postponed and that is the real cause of most delays. An SBOM generated on each release and published alongside the artefact, plus the VEX from 05-01. And an assessment before adding a new dependency: is it maintained? how many maintainers? how many transitive dependencies does it drag in? could we write what it does in twenty lines? A three-line dependency with twelve transitive ones is a worse deal than writing it yourself.


  1. Security testing in CI and code review

One semgrep rule of your own is worth more than a hundred generic ones, because it knows your code:

# .semgrep/nimbus-tenant.yml
rules:
  - id: query-without-tenant-filter
    languages: [python]
    severity: ERROR
    message: >
      Query on a multi-tenant model with no tenant_id filter. Use the
      `get_resource` dependency instead of querying directly (§5).
    patterns:
      - pattern-either:                       # 1) what we are looking for
          - pattern: $DB.query(Booking)...
          - pattern: $DB.get(Booking, ...)
      - pattern-not: $DB.query(...).filter(..., Booking.tenant_id == ..., ...)
      - pattern-not-inside:                   # 2) where it IS permitted
          def get_booking(...): ...
    paths: { exclude: ["tests/", "migrations/"] }   # 3) excluded paths

It is the rule that would have caught the pentest's IDOR in the pull request itself. The general pattern of a good in-house rule is the three parts you can see: what is being looked for, where it is legitimately permitted (pattern-not-inside) and which paths are excluded. Without the last two, the rule generates noise and gets switched off.

# .github/workflows/appsec.yml
name: Application security
on: [pull_request]
permissions: { contents: read, security-events: write }
jobs:
  static:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: gitleaks/gitleaks-action@v2          # BLOCKS: secrets
      # --error makes the job fail on ERROR findings (our own rules);
      # the community ones come in as WARNING and merely report.
      - run: pip install semgrep pip-audit
      - run: semgrep --config .semgrep/ --config p/python --error --sarif -o sast.sarif
      - run: pip-audit -r requirements.txt --strict
      # BLOCKS: verifies that tenant A cannot see tenant B's data. It is the net
      # that stops an IDOR reaching production again.
      - run: pytest tests/security/test_tenant_isolation.py -q
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with: { sarif_file: sast.sarif }
  dast:
    runs-on: ubuntu-latest
    needs: [static]
    steps:
      # `baseline` is passive: it does not attack, it only observes what the
      # server responds. Safe against preproduction and finishes in minutes.
      - uses: zaproxy/action-baseline@v0.12.0
        with: { target: "https://preprod.nimbusreservas.example" }
        continue-on-error: true      # REPORTS, does not block: DAST is noisy

The policy on what breaks the build is the most important design decision in the pipeline:

Check Blocking? Reason
Secret detected Yes, always There is no legitimate case; the cost of letting it through is a compromised credential
Your own semgrep rule (ERROR) Yes Patterns that have already hurt us; a false positive is documented as an exception
Tenant isolation tests Yes It is risk R-04, the most serious one in the product
SCA: critical with a patch available Yes Actionable immediately
SCA with no patch · community SAST · DAST No, they warn Unresolvable or high-noise: blocking teaches people to bypass the control

Code review with a security focus. Iván reviews every pull request with seven questions, not with a general read-through: does it touch authorisation and does it use the common dependencies? does any query concatenate, or skip the tenant filter? does any new input lack a validation schema? does the response use response_model and avoid returning extra fields? does any secret, internal URL or credential appear, even in a comment or a test file? do the errors leak stack traces or data? is the new dependency justified and pinned? A change that touches authorisation or personal data is reviewed by two people, and it is the only process rule Nimbus imposes in writing.


  1. Errors, frontend, mobile and metrics

Error handling. The client receives a generic message and an identifier; the detail goes to the internal log.

@app.exception_handler(Exception)
async def unhandled_error(request: Request, exc: Exception):
    # The FULL stack trace goes to the internal log (05-02), never to the response.
    log.exception("error.unhandled", extra={"fields": {"path": request.url.path}})
    return JSONResponse(status_code=500, content={
        "error": "An internal error has occurred",     # no trace, no SQL, no path
        "reference": request_id.get(),                 # Ruben cross-references it with the log
    })

A stack trace in the response gives away versions, filesystem paths, table names and sometimes connection credentials. And in production debug mode is switched off, with no exceptions.

Frontend. The SPA's dependencies are audited the same way as the server's (npm audit, osv-scanner) and with the same bot. On where to store the token:

localStorage HttpOnly + Secure + SameSite cookie
Reachable from JavaScript Yes: any XSS steals it No
Sent automatically No (you have to add it) Yes → requires CSRF protection
Persistence and revocation Until you delete it; hard to revoke Expiry and revocation from the server

The HttpOnly cookie is the better option: XSS is more frequent than CSRF and its consequences are worse, and CSRF is solved with SameSite plus a token, whereas a token in localStorage has no defence at all against XSS. Besides, the frontend is not a security control: hiding a button based on the role is usability; the permission is always checked on the server.

Mobile application. Three measures: certificate pinning with a rotation plan — pinning with no plan for change leaves the app useless the day the certificate rotates; no secrets stored in the binary, because it is decompiled in minutes; and secure storage of credentials in the system keychain. The principle that unites them: never trust the client; every check that matters is repeated on the server.

AppSec metric Nimbus target
Coverage of tenant isolation tests 100 % of the endpoints that return customer data
Vulnerabilities introduced per release · application MTTR A decreasing trend · ≤ 7 days for criticals
PRs with a security review when touching personal data · dependencies with an unapplied patch > 30 days 100 % · 0

Common Mistakes and Tips

  • Trusting browser validation. It is usability; curl ignores it. All validation is repeated on the server.
  • Believing the ORM protects you on its own. It protects you if you use its API; with text() and an f-string, injection is back.
  • Deciding authorisation in each endpoint, or returning 403 instead of 404 for somebody else's resources. The first guarantees somebody will forget; the second confirms that this identifier exists in another tenant, and that is already a leak.
  • Adding 'unsafe-inline' to stop the CSP complaining. That is switching it off while keeping up appearances.
  • Storing the token in localStorage, or returning stack traces to the client. The first is stolen by any XSS; the second gives away versions, paths, table names and sometimes credentials.
  • Tip: write a semgrep rule of your own for every incident. It is the cheapest way of making sure a flaw does not happen twice, and it would have caught the IDOR in the pull request.
  • Tip: the tenant isolation tests are the best investment in the chapter. One per endpoint, blocking in CI, and R-04 stops depending on anybody's memory.
  • Tip: roll out the CSP in Report-Only for two weeks. It is the difference between protecting the SPA and breaking it on a Friday afternoon.

Exercises

Exercise 1 — Review a pull request

Iván proposes this new endpoint. Identify every security problem and rewrite it.

@router.post("/informes/exportar")
def export_report(tenant_id: int, output_format: str, columns: str, db=Depends(get_db)):
    sql = f"SELECT {columns} FROM bookings WHERE tenant_id = {tenant_id}"
    rows = db.execute(sql).fetchall()
    path = f"/var/www/html/export/{tenant_id}_{output_format}.csv"
    write_file(path, rows)
    return {"url": f"https://nimbusreservas.example/export/{tenant_id}_{output_format}.csv"}

Exercise 2 — Design the defence of a new feature

The teleconsultation module will let clinics upload a logo that will be shown in the virtual waiting room, and configure a webhook URL to which Nimbus will send notifications when a consultation ends.

  1. List the risks of each of the two features.
  2. Define the specific controls, stating where each one is applied.
  3. Write the validation of the webhook URL and explain which attack each line cuts.

Exercise 3 — Decide the pipeline policy

The team is arguing about what should break the build. Lucía wants to block on any high or critical finding from any tool; Iván says that way nothing ever gets deployed. Propose a reasoned policy, stating for each check whether it blocks or reports, and explain how exceptions are managed and what signal would indicate that the policy is badly calibrated.


Solutions

Exercise 1

Five blocks of problems, from most to least serious:

# Problem Consequence
1 tenant_id arrives as a parameter A direct IDOR: exporting any clinic's data. It is the pentest's flaw, repeated
2 columns and tenant_id are concatenated into the SQL Total injection, and by two routes. columns is also the point that cannot be parameterised and here it is not validated against a closed list
3 Writing into /var/www/html with a predictable URL The file ends up served publicly and without authentication, and anyone can try identifiers to download other people's exports
4 No permission check and no rate limit Any authenticated user exports everything; and a loop produces a DoS and fills the disk
5 No audit record A bulk export leaves no trail and D-05 from 05-02 cannot detect it
COLUMNS = {"date": "start", "customer": "customer", "status": "status"}  # CLOSED list

@router.post("/informes/exportar")
@limit("3/hour")                                          # quota per user and tenant
def export_report(request: ExportRequest,                 # validated schema
                  u: User = Depends(require("reports:export")),
                  tid: int = Depends(current_tenant),     # from the token, not the URL
                  db=Depends(get_db)):
    cols = [COLUMNS[c] for c in request.columns if c in COLUMNS]
    if not cols: raise HTTPException(422, "Invalid columns")
    rows = db.execute(
        text(f"SELECT {', '.join(cols)} FROM bookings "
             "WHERE tenant_id = :t AND start BETWEEN :d AND :h LIMIT 50000"),
        {"t": tid, "d": request.date_from, "h": request.date_to}).fetchall()
    # PRIVATE bucket, random key and a short-lived signed URL: never in the
    # webroot and never with a predictable name.
    key = f"export/{tid}/{uuid4().hex}.csv"
    s3.put_object(Bucket="nimbus-adjuntos-prod", Key=key, Body=to_csv(rows))
    record_audit("booking.exported", actor=u.id, tenant=tid, record_count=len(rows))
    return {"url": generate_signed_url(key, expiry_seconds=120)}   # feeds D-05 (05-02)

Exercise 2

(1) Risks. The logo is a file upload: an SVG with embedded JavaScript (stored XSS that runs in the browser of every patient in that room), a huge file that exhausts disk or bandwidth, a type faked through the extension or the Content-Type, path traversal in the name and malware hosted under Nimbus's domain. The webhook is SSRF by design: the customer chooses where our server calls — including 169.254.169.254 to steal the instance's credentials, or internal addresses to scan the VPC — plus redirects towards internal destinations, huge or slow responses that exhaust threads, and use of Nimbus as an amplifier against third parties.

(2) Controls. For the logo: maximum size and dimensions; real type from the content limited to PNG and JPEG, with SVG explicitly forbidden; reprocessing the image, which by re-encoding it removes any embedded payload and is the most effective control; a server-generated name; storage in the private bucket with a signed URL, or on a separate content domain so that a flaw does not inherit the application's origin; nosniff when serving it; and antivirus. For the webhook: HTTPS only, validation of the resolved IP with private, link-local and loopback ranges blocked; redirects disabled; short timeouts and a response size limit; retries with exponential backoff and deactivation after N failures; sending from a separate egress network with no route to the VPC (05-04); an HMAC signature of the body so the receiver can verify the origin (03-04); and verification of URL ownership before enabling it.

(3) Validation:

def validate_webhook(url: str) -> str:
    p = urlparse(url)
    if p.scheme != "https":                     # cuts plain http, file://,
        raise ValueError("HTTPS only")          # gopher:// and odd schemes
    if p.port and p.port != 443:                # cuts internal port scanning
        raise ValueError("Port not permitted")
    ips = {ipaddress.ip_address(i[4][0]) for i in socket.getaddrinfo(p.hostname, None)}
    for ip in ips:                              # ALL the IPs, not just the first:
        if (ip.is_private or ip.is_loopback     # a domain resolves to several, and
                or ip.is_link_local             # 169.254.169.254 is the metadata service
                or ip.is_reserved):
            raise ValueError("Internal destination blocked")
    return url

And the warning that makes any prior validation incomplete: between the moment of validating and the moment of connecting, DNS can change (DNS rebinding). That is why validation in the code is necessary but not sufficient, and the control that really closes the risk is the network one: the process that sends webhooks goes out through a subnet with no route to the VPC or to the metadata service.

Exercise 3

Both are partly right, and the synthesis is a criterion, not a threshold: block what is unambiguous and actionable now; report what is ambiguous or has no available solution. Applied to Nimbus, the policy is the table in §11: secrets, your own semgrep rules, the tenant isolation tests and critical vulnerabilities with a patch available block; community SAST, the DAST baseline and vulnerabilities with no patch report.

Lucía's position fails for a specific reason: blocking on any high from any tool means that a false positive from a generic rule or a patchless CVE in a transitive library halts the deployment of an urgent fix. The predictable result is not more security, but somebody adding --no-verify or disabling the job, and then the good checks are lost too. Iván's fails if taken to the extreme: a pipeline that never blocks is a report, not a control.

Exception management: they are declared in the repository (.semgrepignore, the SCA's suppressions file) with three mandatory fields — reason, owner and expiry date — and are reviewed on the same cycle as the exceptions from 04-02; a suppression with no expiry is a permanent hole dressed up as a technical decision. Signs of bad calibration, in both directions: if more than 20 % of runs fail on security, or if commits appear with the check bypassed, the policy is too strict and checks have to be moved to informational mode or rules tuned; if the pipeline has gone six months without blocking anything and the pentest keeps finding pattern flaws, it is too lax. The metric that settles the argument is how many of the last pentest's findings would have been caught by the pipeline: if it is few, the problem is not the threshold, it is the rules.


Conclusion

You have worked on the surface that cannot be closed with a firewall. You know how to build security into the SSDLC with three cheap control points, and why fixing in production costs between thirty and a hundred times more than at design time. You have a command of input validation with an allowlist and declarative schemas, and of the distinction that avoids false confidence: validating does not replace protecting each destination, because the danger is in the context of use, not in the data.

You know how to genuinely fix the attacks from 02-02: parameterised queries — and why an ORM is not enough if used with text() and f-strings — with a closed list for what cannot be parameterised; output encoding and a CSP against XSS, with HttpOnly so an XSS cannot reach the session; SameSite and anti-CSRF tokens, plus the rule that nothing which changes state is a GET; SSRF with an allowlist, validation of the resolved IP and blocking of the metadata service; and file upload with six combined controls and download over a signed URL. And you take away the central piece: centralised authorisation in reusable dependencies, where tenant_id comes from the token and never from the request, with a single point of resolution per resource, 404 instead of 403, denials logged, RLS as the safety net underneath and automated tenant isolation tests in CI. The IDOR stops depending on nobody forgetting. You know the OWASP API Top 10 applied to Nimbus, with BOPLA resolved through response_model and extra: "forbid" against mass assignment, rate limiting per customer and per tenant, pagination with a hard cap, explicit CORS, versioning with an announced retirement and documentation that does not leak. You know how to manage secrets with a manager, injection at start-up, per-environment isolation in CI and gitleaks at pre-commit; you have the complete block of headers explained one by one and the four-step procedure for rolling out a CSP without breaking the SPA, with the 'unsafe-inline' trap flagged; and you know how to pin dependencies by hash, automate updates and assess a dependency before adding it. And you have the AppSec pipeline with a semgrep rule of your own that would have caught the IDOR in the pull request, an informational ZAP baseline, the reasoned policy on what breaks the build and the seven code-review questions, as well as safe error handling, the localStorage versus HttpOnly cookie comparison, protection of the mobile app and the AppSec metrics.

The code now defends itself. But all that code runs on something: an operating system with packages, services, users and permissions, and around forty laptops spread between Valencia and the homes of half the staff. That is where the python -m http.server nobody switched off has been alive since 01-04, and that is where the unencrypted machines and the accounts with unnecessary administrator privileges are. In System Hardening and Endpoint Security (05-06) we apply surface reduction to the operating system: reproducible baselines with CIS and OpenSCAP, sshd_config directive by directive, auditd, fail2ban, patch management as a process, disk encryption, antivirus versus EDR, MDM, osquery for interrogating the estate, and automation with Ansible so that the baseline is never applied by hand.

Fundamentals of Information Security Course

Module 1: Introduction to Information Security

Module 2: Cybersecurity

Module 3: Cryptography

Module 4: Risk Management and Protection Measures

Module 5: Security Tools and Techniques

Module 6: Best Practices and Regulations

Module 7: Final Project

© Copyright 2026. All rights reserved