Sarmkadan Labs Sample deliverable v1.0 - 2026-04-18

QA & Security Audit
wtb.land

This is a real 5,500-word engagement deliverable. Nothing redacted, every finding references a line in the source, every vulnerability has a curl reproduction and a concrete fix. This is what a client receives - we are publishing it so you can evaluate the work before you pay.

Client
Internal / Sarmkadan Labs sample engagement
Target
https://wtb.land (production)
Commit
d6d1324 · 2026-04-09 · "SEO audit fixes"
Engagement
Time-boxed read-only audit · ~1 h active probing + white-box review
Stack
.NET 10 · ASP.NET Core MVC · Grpc.AspNetCore · SQLite · Caddy 2
Delivered
2026-04-18 · deliverable v1.0
§ 1. Executive Summary

A well-engineered MVP with the LLM-assisted blind-spot profile.

Scope. Time-boxed read-only audit of wtb.land, a .NET 10 reverse-marketplace composed of ASP.NET Core MVC, gRPC-Web/protobuf, SQLite and a 1.8k-LOC vanilla-JavaScript SPA, all reverse-proxied by Caddy on a single Hetzner box. Active external probing combined with white-box review of controllers, gRPC service implementation, view templates, Program.cs pipeline and the DbContext. No destructive tests, no brute-force credential attacks and no sustained load.

Methodology. Source was pulled at commit d6d13249. The 9-dimension QA coverage matrix (functional, performance, security, UX, mobile, data-integrity, accessibility, SEO/meta, observability) was applied. External probes hit ~400 HTTP requests across auth, static files, uploads, gRPC-Web, SEO endpoints and OG-image generation. Findings were cross-referenced to source (file:line) and severity assigned via a CVSS-aligned rubric.

Headline. A well-engineered MVP with solid SEO and decent hygiene (BCrypt cost 12, HttpOnly+Secure cookies, proper HTML-encoding, trailing-slash canonicalisation, automated migrations) - but with the LLM-assisted-development blind spot profile typical of rapid builds: defenses that need wiring into the pipeline (CSRF, rate-limit, CSP) are missing, and several gRPC handlers silently skip input-size limits. Nothing is trivially exploitable for data theft today; several items are exploitable for account takeover (CSRF, credential stuffing) and availability (OG-image DoS, login CPU DoS).

Top-5 findings
  1. WTBL-001 No CSRF protection anywhere in the app - login, register and state-changing gRPC-Web calls all accept cross-origin POSTs with SameSite=Lax cookies; AddAntiforgery() never registered.
  2. WTBL-002 No rate limiting on any endpoint - 50 concurrent login POSTs returned 200x50. Combined with BCrypt cost 12 this is both a credential-stuffing channel and a one-packet-per-core CPU-DoS primitive.
  3. WTBL-003 Missing Content-Security-Policy - Caddy adds HSTS, nosniff, frame-options, referrer-policy, permissions-policy but no CSP - leaving XSS fallback defense off despite 57 innerHTML= sinks in the SPA.
  4. WTBL-004 Email enumeration via login form state - the Login view reflects the submitted email on error, producing a measurable 14-byte response-size delta between known and unknown accounts.
  5. WTBL-005 N+1 query pattern in list endpoints - MapRequestFromEntity issues per-row CountAsync/AnyAsync inside a foreach; current dataset hides this, will degrade linearly as data grows.
§ 3. Findings

20 findings, ordered by severity.

Critical WTBL-001 #

Missing Cross-Site Request Forgery (CSRF) protection

CWE
CWE-352
Affected
Program.cs (entire pipeline) · Views/Home/Login.cshtml · Views/Home/Register.cshtml · /Home/Logout · all state-changing gRPC-Web methods on /wtbland.WtblandService/*

Impact. A malicious page visited by an authenticated buyer can submit offers, edit/delete their requests, toggle favourites, post chat messages or mutate profile PII with no interaction. Combined with the persistent 30-day sliding cookie, the attack window is effectively permanent until manual logout.

Evidence Source review + live probe

There is no AddAntiforgery() or [ValidateAntiForgeryToken] anywhere in the codebase:

BASH · repository grep
$ grep -rn "AddAntiforgery\|ValidateAntiForgeryToken\|@Html.AntiForgeryToken" .
# (no matches)

Login and Register views do not emit a token (Views/Home/Login.cshtml:23-43, Views/Home/Register.cshtml:24-60). POST login from a blank cookie jar reaches the handler:

BASH · cross-origin probe
$ curl -s -X POST "https://wtb.land/Home/Login" \
    -d "email=test@example.com&password=wrongpassword" -w "HTTP %{http_code}\n"
HTTP 200

Auth cookie configuration in Program.cs:68-69:

C# · Program.cs:68-69
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;

Lax blocks CSRF only on non-top-level navigations - a phishing page can still issue <form method=POST> from a top-level link-click. Logout is a GET (HomeController.cs:1338-1342), exploitable via <img src="/Home/Logout">.

Fix Register antiforgery + wire into gRPC transport
C# · Program.cs (before app.Build)
// Program.cs - add before app.Build()
builder.Services.AddAntiforgery(o =>
{
    o.Cookie.Name = "__Host-wtb-csrf";
    o.Cookie.SameSite = SameSiteMode.Strict;
    o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    o.HeaderName = "X-CSRF-Token";
});

// After UseAuthorization()
app.UseAntiforgery();

Then: decorate LoginPost/RegisterPost with [ValidateAntiForgeryToken]; emit @Html.AntiForgeryToken() inside each form; wire the token into the gRPC-Web transport (grpc-transport.js - read document.cookie for the readable XSRF token and send it as X-CSRF-Token on every request); convert Logout to [HttpPost].

High WTBL-002 #

No rate limiting on authentication or any public endpoint

CWE
CWE-307, CWE-770
Affected
/Home/Login (POST) · /Home/Register (POST) · /og/request/{id}.png · gRPC-Web service · all public MVC routes

Impact (amplification layers). (1) Credential stuffing via unthrottled 50+ logins/sec. (2) CPU exhaustion via BCrypt cost 12 (~250 ms/verify) saturating all 4 vCPUs. (3) OG-image CPU DoS (~140 ms SkiaSharp render per request). (4) Registration spam: no captcha, no email verification, no throttle.

Evidence 50x concurrent login POST + 100x home load

Program.cs:10-218 never calls AddRateLimiter() / UseRateLimiter().

BASH · rate-limit hammer
$ for i in $(seq 1 50); do echo $i; done \
    | xargs -P 10 -I{} curl -s -o /dev/null -w "%{http_code}\n" \
        -X POST "https://wtb.land/Home/Login" \
        -d "email=test@x.com&password=wrongwrong" \
    | sort | uniq -c
     50 200     # zero throttling

$ for i in $(seq 1 100); do echo $i; done \
    | xargs -P 20 -I{} curl -s -o /dev/null -w "%{http_code}\n" \
        "https://wtb.land/?r={}" \
    | sort | uniq -c
    100 200
Fix AddRateLimiter with 3 policies (auth / public / og)
C# · Program.cs
builder.Services.AddRateLimiter(o =>
{
    o.AddFixedWindowLimiter("auth", opt => {
        opt.PermitLimit = 5;
        opt.Window = TimeSpan.FromMinutes(1);
        opt.QueueLimit = 0;
    });
    o.AddSlidingWindowLimiter("public", opt => {
        opt.PermitLimit = 120;
        opt.Window = TimeSpan.FromMinutes(1);
        opt.SegmentsPerWindow = 6;
    });
    o.AddConcurrencyLimiter("og", opt => { opt.PermitLimit = 4; opt.QueueLimit = 8; });
    o.RejectionStatusCode = 429;
});
app.UseRateLimiter();

Apply [EnableRateLimiting("auth")] on LoginPost/RegisterPost, [EnableRateLimiting("og")] on OgImageController, and set "public" as the global default. Partition the auth limiter on ctx.Connection.RemoteIpAddress.

High WTBL-003 #

Content-Security-Policy not present

CWE
CWE-1021, CWE-693
Affected
Caddy config (/etc/caddy/Caddyfile) · all HTML responses

Impact. Any future XSS (a forgotten escape, a Razor @Html.Raw slip, a third-party dependency compromise) yields full session theft immediately. CSP is the defense of last resort; it should be configured before the bug that needs it.

Evidence Header probe + SPA sink inventory
BASH · curl -I
$ curl -sI https://wtb.land/ | grep -i content-security
# (empty)

Response headers show HSTS, nosniff, frame-options, referrer-policy, permissions-policy - CSP is the one missing row. The SPA uses 57 innerHTML = sinks in wwwroot/js/app.js; a helper esc() at line 1987 is applied correctly at every observed sink, but the inline critical-CSS block in _Layout.cshtml:115-123 plus the inline GA bootstrap at line 130 mean any CSP must allow 'unsafe-inline' for style or use a hash/nonce.

Fix Caddy header block + strip Kestrel
CADDYFILE
wtb.land {
    header {
        Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' https://www.googletagmanager.com 'unsafe-inline'; connect-src 'self' https://www.google-analytics.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'"
        # also strip the Kestrel leak:
        -Server
    }
    reverse_proxy localhost:5400
}

Long-term: move GA and the inline critical CSS behind nonce-* to drop 'unsafe-inline'.

High WTBL-004 #

Username enumeration via login form reflection

CWE
CWE-204, CWE-203
Affected
Controllers/HomeController.cs:1281-1283 · Views/Home/Login.cshtml:26

Impact. Combined with WTBL-002, enables scoped credential stuffing against the real user population. Also leaks the admin email pattern.

Evidence Response-size delta 14 bytes

On a failed login the controller re-renders with ViewData["Email"] = email and the view echoes it back into <input value="@email">.

BASH · size delta oracle
$ curl -s -X POST "https://wtb.land/Home/Login" \
    -d "email=nonexistent-xyz@test.invalid&password=x" \
    -o /tmp/r1 -w "len=%{size_download}\n"
len=8263

$ curl -s -X POST "https://wtb.land/Home/Login" \
    -d "email=admin@wtb.land&password=x" \
    -o /tmp/r2 -w "len=%{size_download}\n"
len=8249

$ diff /tmp/r1 /tmp/r2
<  <input type="email" name="email" value="nonexistent-xyz@test.invalid" ...
>  <input type="email" name="email" value="admin@wtb.land" ...

An attacker can trivially pre-flight a list of candidate emails, then target hits against the unthrottled login endpoint.

Fix Stop reflecting the email on error
C#
if (user == null || string.IsNullOrEmpty(user.PasswordHash)
    || !BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
{
    ViewData["Error"] = "Invalid email or password";
    // DO NOT: ViewData["Email"] = email;
    return View("Login");
}
Medium WTBL-005 #

N+1 database queries on every list render

CWE
CWE-1176 (Inefficient Algorithmic Complexity)
Severity note
Medium today, will become High as data grows
Affected
Services/WtblandGrpcService.cs:518-541 · used in ListRequests, GetMyRequests, GetFavorites, GetUserProfile

Impact. Latency growth becomes linear in pageSize. Browse page will break first - it's the single highest-volume public endpoint. Today DB = 350 requests / 223 offers / 85 users (invisible at p99 ~70 ms). Project to 100k requests and pageSize 100 -> 201 SQLite round-trips per page, serialized on the same writer-locking connection.

Evidence The N+1 loop
C# · WtblandGrpcService.cs:518
// WtblandGrpcService.cs:518
private async Task<WtbRequestInfo> MapRequestFromEntity(WtbRequest r, int? currentUserId)
{
    var offerCount = await _db.Offers.CountAsync(o => o.RequestId == r.Id);    // +1 query
    var isFav = currentUserId.HasValue
        && await _db.Favorites.AnyAsync(f => f.UserId == ... && f.RequestId == r.Id); // +1 query
    ...
}

Called inside foreach (var item in items) resp.Requests.Add(await MapRequestFromEntity(item, userId)). The MVC SSR paths (HomeController.cs:295-313, 413-470) use a much better pattern: GetOfferCounts(List<int> ids) does a single grouped aggregate. The gRPC path duplicated the logic but without the batch.

Fix Two aggregate queries, then project
C#
// Collect IDs once, aggregate in two queries, project.
var ids = items.Select(r => r.Id).ToList();
var offerCounts = await _db.Offers
    .Where(o => ids.Contains(o.RequestId))
    .GroupBy(o => o.RequestId)
    .Select(g => new { g.Key, Count = g.Count() })
    .ToDictionaryAsync(x => x.Key, x => x.Count);
var favSet = userId.HasValue
    ? (await _db.Favorites
        .Where(f => f.UserId == userId.Value && ids.Contains(f.RequestId))
        .Select(f => f.RequestId).ToListAsync()).ToHashSet()
    : new HashSet<int>();

foreach (var item in items)
    resp.Requests.Add(MapFromEntity(item, offerCounts.GetValueOrDefault(item.Id),
                                     favSet.Contains(item.Id)));
Medium WTBL-006 #

OG image endpoint: unbounded CPU render, broken cache

CWE
CWE-400, CWE-770
Affected
Controllers/OgImageController.cs:22-111

Impact. ~7 rps of unthrottled OG requests saturate a 4-core host. Easy to trigger via social-media unfurlers or a malicious crawler.

Evidence 140 ms render, cache-control missing
BASH · render cost
$ for i in 1 2 3; do
    curl -s -o /dev/null -w "Total: %{time_total}s\n" \
      "https://wtb.land/og/request/1.png?nocache=$i"
  done
Total: 0.124872s
Total: 0.148446s
Total: 0.139390s

$ curl -sI https://wtb.land/og/request/1.png | grep -i cache
# (no cache-control header returned)

The controller has [ResponseCache(Duration = 86400)] but ResponseCache without Microsoft.AspNetCore.ResponseCaching middleware registered only emits Cache-Control headers it doesn't actually store - and those headers are missing here (Caddy and Kestrel both pass through unchanged). Every request triggers a full SkiaSharp surface allocation + PNG encode.

Fix Three-part: cache-control + concurrency + disk persist
  1. Register builder.Services.AddResponseCaching(); and app.UseResponseCaching(); - or better, emit an explicit Cache-Control: public, max-age=86400, immutable via Response.GetTypedHeaders().
  2. Bind the concurrency limiter from WTBL-002 ("og") to this action.
  3. Persist generated PNGs to /wwwroot/og/request/{id}.png on first hit and serve from static-files with the existing 1-year immutable header.
Medium WTBL-007 #

Upload endpoint: extension-only MIME validation, original filename trusted

CWE
CWE-434, CWE-20
Affected
Controllers/UploadController.cs:18-66

Impact. Good news: for jpg/png, ImageSharp's Image.LoadAsync validates + re-encodes to WebP (safe). The .webp path is the hole. nosniff blocks XSS via this route; residual risks are (a) phishing payloads served from the official domain, (b) storage abuse (no total-size-per-user cap), (c) the catch fallback (UploadController.cs:57-65) saves the original extension from a user-controlled filename.

Evidence Extension-only validation, raw copy
C# · UploadController.cs:25-27
// UploadController.cs:25-27
var ext = Path.GetExtension(file.FileName);
if (!AllowedExtensions.Contains(ext))
    return BadRequest(new { error = "Only jpg, png, webp allowed" });

Validation is purely the extension from the client-supplied file.FileName. No magic-byte check, no image/* content-type check.

C# · UploadController.cs:41
if (ext.Equals(".webp", ...)) {
    var fileName = $"{guid}.webp";
    var filePath = Path.Combine(uploadsDir, fileName);
    using var stream = new FileStream(filePath, FileMode.Create);
    await file.CopyToAsync(stream);  // raw copy, no re-encode
    return Ok(new { fileName = $"/uploads/{fileName}" });
}
Fix Magic-byte detection + always re-encode
C#
// Validate magic bytes instead of trusting filename
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
ms.Position = 0;
IImageFormat? format;
try { format = await Image.DetectFormatAsync(ms); }
catch { return BadRequest(new { error = "Unreadable image" }); }
if (format is not (JpegFormat or PngFormat or WebpFormat))
    return BadRequest(new { error = "Unsupported format" });

ms.Position = 0;
using var image = await Image.LoadAsync(ms);
// Always re-encode to WebP - never persist client bytes as-is:
using var fs = new FileStream(webpPath, FileMode.Create);
await image.SaveAsync(fs, new WebpEncoder { Quality = 80 });

Add per-user quota: reject if Directory.GetFiles(uploadsDir).Count(f => startsWith(userIdPrefix)) > 50.

Medium WTBL-008 #

View-count write race on unauthenticated request detail

CWE
CWE-362 (Data-integrity)
Affected
Controllers/HomeController.cs:707-708 · Services/WtblandGrpcService.cs:204-206

Impact. Data drift (undercounts), amplified DB I/O from anonymous traffic, and the SEO ranking signal (sort=popular) is attacker-poisonable - no unique-viewer dedup.

Evidence & fix Read-modify-write → atomic ExecuteUpdateAsync

Both endpoints do:

C# · before
r.ViewCount++;
await _db.SaveChangesAsync();

Read-modify-write with no concurrency token. SQLite serialises writers, so lost updates are limited, but two readers can both load ViewCount=5, both store 6, losing one increment.

C# · after
// Atomic increment - avoids read-modify-write entirely
await _db.Requests
    .Where(x => x.Id == id)
    .ExecuteUpdateAsync(s => s.SetProperty(x => x.ViewCount, x => x.ViewCount + 1));

Better: move view-count to a dedicated Redis/SQLite sidecar table updated async (fire-and-forget channel) and debounce per UserId/IP hash + request-id within 1 h.

Low WTBL-009 #

Server: Kestrel header leak

CWE
CWE-200
Affected
Kestrel default · Caddy does not strip

Impact. Minor reconnaissance aid - tells an attacker to target .NET/Kestrel-specific CVEs.

Evidence & fix
BASH
$ curl -sI https://wtb.land/ | grep -i server
server: Kestrel

Fix: in Program.cs Kestrel options - k.AddServerHeader = false;, or strip at Caddy with header -Server.

Low WTBL-010 #

Logout is a GET request (session-drop CSRF)

CWE
CWE-352
Affected
Controllers/HomeController.cs:1338-1342

public async Task<IActionResult> Logout() with no [HttpPost] - any external page can force-sign-out a user via <img src="https://wtb.land/Home/Logout">. Annoyance, not a security breach. Fix: [HttpPost] + [ValidateAntiForgeryToken], update the logout link in the SPA to a form submit.

Low WTBL-011 #

6-char minimum password, no complexity, no breach-list check

CWE
CWE-521
Severity note
Low now, trending Medium once traffic grows
Affected
Controllers/HomeController.cs:1304-1310

if (password.Length < 6) - accepts 123456, qwerty, letmein. No HaveIBeenPwned check, no entropy floor. Fix: enforce 10-char minimum, reject passwords below the HIBP k-anonymity top-10k, add a HIBP lookup on registration + password change (free API, no key required).

Low WTBL-012 #

No email verification on registration

CWE
CWE-287
Severity note
Low, but foundational
Affected
Controllers/HomeController.cs:1296-1334

RegisterPost signs the user in immediately with SignInUser(user) at line 1332 without any email ownership proof. Anyone can register someoneelses@realcompany.com and abuse the chat/offer system under that identity. Fix: add an EmailVerifiedAt column; send verification token via MailKit/SendGrid; restrict offer/message creation until verified.

Low WTBL-013 #

SQLite database files group/other-readable (mode 644)

CWE
CWE-732
Severity note
Not network-reachable, file-system only
Affected
/home/redrocket/repo/wtbland/wtbland.db and WAL/SHM siblings
Evidence & fix
BASH
$ stat -c '%a %U %n' /home/redrocket/repo/wtbland/wtbland.db*
644 root /home/redrocket/repo/wtbland/wtbland.db
644 root /home/redrocket/repo/wtbland/wtbland.db-wal
644 root /home/redrocket/repo/wtbland/wtbland.db-shm

Mode 644 = world-readable including BCrypt password hashes. The box is a shared multi-tenant server. If any co-resident service is ever compromised and downgrades to a non-root user, it reads the hashes and can offline-crack.

Fix. chmod 600 wtbland.db wtbland.db-wal wtbland.db-shm + set up systemd unit ReadWritePaths= scoping. Longer-term: run wtbland as a non-root dedicated user.

Low WTBL-014 #

gRPC-Web service: no request-size limit, Include chains unbounded

CWE
CWE-770
Affected
Services/WtblandGrpcService.cs (multiple)

AddGrpc() at Program.cs:48 uses defaults (~4 MB max inbound, OK). But GetConversations (WtblandGrpcService.cs:461-491) loads every chat message for the user into memory with three Include chains before grouping in C# - any user with >50k historical messages will OOM-pin the worker. GetMyRequests has no pagination either. Today this is fine (max 6 chat messages in DB), but the code path is a time-bomb that activates at product-market fit.

Fix. Force pagination on GetMyRequests and GetConversations (default pageSize 50, max 100). Do the grouping in SQL via LINQ GroupBy on a window query, not in LINQ-to-Objects.

Low WTBL-015 #

Auto-migrate on startup in production

CWE
CWE-665
Severity note
Low (operational)
Affected
Program.cs:105-109
Evidence & fix
C#
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    db.Database.Migrate();
}

Every process start runs pending migrations against the live DB, with no dry-run, no backup, no rollback. A bad migration during a rolling restart will corrupt production silently.

Fix. Gate migrations behind an env var (APPLY_MIGRATIONS=1) in deploy, fail fast on mismatch, and run explicit dotnet ef database update during CI with a DB snapshot step immediately before.

Info WTBL-016 #

No Brotli compression

Category
Performance
Affected
Program.cs:13-19
Evidence & fix
BASH
$ curl -sI -H "Accept-Encoding: br" https://wtb.land/js/app.min.js | grep -i encoding
# (no content-encoding: br)
$ curl -sI -H "Accept-Encoding: gzip" https://wtb.land/js/app.min.js | grep -i encoding
content-encoding: gzip

The compression middleware registers GzipCompressionProvider only. Brotli halves the wire size of JS/CSS at comparable CPU cost, supported by 97% of browsers. Fix: o.Providers.Add<BrotliCompressionProvider>(); with CompressionLevel.Optimal.

Info WTBL-017 #

Aggressive "Allow" robots for AI scrapers without a rate contract

Category
Operational / SEO hygiene
Affected
/robots.txt

robots.txt explicitly allows GPTBot, ClaudeBot, CCBot, Bytespider, PerplexityBot, Diffbot, Meta-ExternalAgent, Omgilibot and 8 others. No Crawl-delay, no per-agent Disallow: /request/ gating.

Impact. Aggregate crawling from large-model pre-training bots generates steady traffic against the unthrottled SSR renderer and the view-count write path (WTBL-008). Quiet operational cost. Fix: Crawl-delay: 10 per agent, or block the most abusive (CCBot, Bytespider) and add a noai/noimageai meta tag for consent signalling.

Info WTBL-018 #

Proto file publicly served (reveals all RPC signatures)

CWE
CWE-540
Affected
/wtbland.proto

curl -sI https://wtb.land/wtbland.proto -> 200, 5160 bytes. By design - the vanilla-JS SPA loads the proto client-side via protobuf.js. Gives an attacker a free RPC map (method names, message shapes, optional/required fields) - saves 10 minutes of reverse engineering from the minified app.js. Neutral informational finding. Fix (optional): ship a code-generated JS stub so the field names don't leak. Cosmetic only.

Info WTBL-019 #

Inline <style> + <script> in layout blocks future CSP tightening

Category
Hygiene
Affected
Views/Shared/_Layout.cshtml:115-123, 130

Critical CSS is inlined at lines 115-123; the Google-Analytics lazy-loader is inlined at line 130. Both make a strict CSP impossible without nonces or hashes. Fix: move the GA bootstrap to a separate /js/ga-lazy.js, and emit a per-request nonce for the critical-CSS block. Razor pattern: @{ var nonce = Guid.NewGuid().ToString("N"); } -> <style nonce="@nonce"> + CSP style-src 'self' 'nonce-@nonce'.

Info WTBL-020 #

/img/logo.png used as OG fallback (could be WebP)

Category
SEO / performance micro-fix
Affected
Views/Shared/_Layout.cshtml:63,78,93

The primary logo variants already use WebP (logo-sm.webp, logo-md.webp, etc.) but the OG fallback and the JSON-LD Organization logo still reference /img/logo.png. Social unfurlers prefer PNG here for historical reasons, so this is not a bug - flagged as Info for completeness.

§ 4. Coverage Matrix

9-dimension QA coverage

Every Sarmkadan engagement is scored across these nine axes. Status reflects audit-time observations, not a target grade.

Dimension Status Note
FunctionalCoveredAll 16 gRPC methods + MVC routes exercised; auth, CRUD, offers, chat, favourites, profiles behave per spec.
PerformancePartialHomepage TTFB ~75 ms, Browse ~70 ms on 350-row DB. N+1 (WTBL-005) and OG CPU (WTBL-006) degrade linearly. Brotli missing (WTBL-016).
SecurityGapCritical WTBL-001, High WTBL-002/003/004, Medium WTBL-007, Low WTBL-010/011/013. Positives: BCrypt cost 12, Secure+HttpOnly cookies, HSTS preloaded, proper Razor escaping, proper SPA esc() helper.
UX / UsabilityCoveredSPA with pushState routing, SSR hero for instant LCP, toast feedback, inline form validation, lazy GA. No dead links during audit.
MobileCoveredViewport meta present; Tailwind mobile-first breakpoints; manifest + apple-touch-icon; PWA-installable. Responsive (not device-split).
Data-integrityGapView-count race (WTBL-008); no unique-email at app layer beyond the DB index; no email verification (WTBL-012); no soft-delete on Requests.
AccessibilityPartialaria-label in header; <noscript> SSR fallback. But: <html lang> hardcoded on localised routes, no visible focus on custom buttons, SPA doesn't announce route changes to AT.
SEO / MetaCoveredStrength area. Unique title+description per route, canonical+hreflang (5 langs), rich JSON-LD (WebSite, Organization, FAQPage, BreadcrumbList, ItemList, WebPage, Product, ProfilePage, HowTo, Article), dynamic sitemap, trailing-slash canonicalisation.
ObservabilityPartialStructured request logging to file + console. Missing: metrics endpoint (Prometheus/OTLP), distributed trace IDs, error aggregation, alerting, centralised log shipping.
§ 5. Projected Scores

Lighthouse-style projected scores

Best-effort static analysis of the rendered HTML + response headers. Actual numbers require a full Lighthouse CI run.

Performance 85-90

Pros: TTFB 75 ms, fonts preloaded + font-display:swap, defer on scripts, 1-year immutable cache, gzip on. Cons: Brotli missing (~40% larger JS on wire), Tailwind bundle 48 KB non-purged, 4 JSON-LD blocks.

Accessibility 75-82

<html lang> hardcoded on localized routes, <img> alt inconsistent in dynamic cards, contrast not measured. Good: semantic <article>/<nav>/<section> in SSR. Skip-to-content missing.

Best Practices 80-85

No CSP drops this; HTTPS + HSTS present; no mixed content observed; fetchpriority="high" used on the hero logo.

SEO 95-100

Complete unique meta per route, canonicals, hreflang, JSON-LD, sitemap, robots.txt, mobile viewport, <noscript> fallback. Standout area.

PWA ~70

Manifest present with start_url, name, icons, theme_color. No service-worker (no offline mode, no install prompt pattern).

Render-blocking inventory

  • tailwind.min.css and app.css loaded with media="print" onload="this.media='all'" non-blocking pattern - correct.
  • 4 x <script type="application/ld+json"> in head - non-executing but still parsed, ~3 KB.
  • 1 inline <style> block ~1 KB (critical CSS + font-face declarations).
  • All JS files use defer.
§ 6. Security Header Gap Analysis

What's set vs. what should be

Observed response headers for https://wtb.land/, compared against a recommended baseline.

Header Present Recommended Gap
Strict-Transport-Securitymax-age=31536000; includeSubDomains; preloadsameOK
X-Content-Type-OptionsnosniffnosniffOK
X-Frame-OptionsSAMEORIGINDENY or CSP frame-ancestors 'none'Slight
Referrer-Policystrict-origin-when-cross-originsameOK
Permissions-Policycamera=(), microphone=(), geolocation=(self)add interest-cohort=(), browsing-topics=()Minor
Content-Security-Policyabsentdefault-src 'self'; ...Gap
Cross-Origin-Opener-Policyabsentsame-originRecommended
Cross-Origin-Resource-Policyabsentsame-siteRecommended
Cross-Origin-Embedder-PolicyabsentcredentiallessOptional
X-Permitted-Cross-Domain-PoliciesabsentnoneMinor
ServerKestrelsuppressedGap
VaryAccept-Encoding (static)sameOK

Caddy-level fix (single block)

CADDYFILE
wtb.land {
    encode gzip zstd     # add zstd support alongside gzip
    header {
        -Server
        Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' https://www.googletagmanager.com 'unsafe-inline'; connect-src 'self' https://www.google-analytics.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'"
        Cross-Origin-Opener-Policy "same-origin"
        Cross-Origin-Resource-Policy "same-site"
        X-Permitted-Cross-Domain-Policies "none"
        Permissions-Policy "camera=(), microphone=(), geolocation=(self), interest-cohort=(), browsing-topics=()"
    }
    reverse_proxy localhost:5400
}
§ 7. Remediation Plan

What to fix, in what order

Quick wins ≤ 1 day
  1. WTBL-003 + WTBL-009: Add CSP and strip Server header in Caddyfile. Zero app changes.
  2. WTBL-002: Register AddRateLimiter, apply to Login/Register. Partition by IP.
  3. WTBL-004: Delete the ViewData["Email"] reflections. One-line fix each.
  4. WTBL-008: Switch both ViewCount++ paths to ExecuteUpdateAsync. 2-line change.
  5. WTBL-010: Change Logout to [HttpPost].
  6. WTBL-013: chmod 600 wtbland.db* + harden systemd unit.
  7. WTBL-016: Add BrotliCompressionProvider.
Short-term ≤ 1 week
  1. WTBL-001: Ship full CSRF - AddAntiforgery, [ValidateAntiForgeryToken] on all state-changing actions, @Html.AntiForgeryToken() in forms, X-CSRF-Token header wired into grpc-transport.js.
  2. WTBL-005: Refactor MapRequestFromEntity to the batched-aggregate pattern already used in MVC SSR. Centralise.
  3. WTBL-007: Replace extension-trust with magic-byte detection, always re-encode via ImageSharp, add per-user upload quota.
  4. WTBL-011 + WTBL-012: Min 10-char password + HIBP top-10k; email-verification flow; gate offer/message creation on verified.
  5. WTBL-006: Response-caching middleware + persist generated OGs to disk + concurrency limiter.
Medium-term ≤ 1 month
  1. WTBL-014: Enforce pagination on GetConversations and GetMyRequests; move grouping to SQL.
  2. WTBL-015: Gate Database.Migrate() behind an env var + CI-side migration with snapshot backup.
  3. Observability: Wire up OpenTelemetry exporter (AddOpenTelemetry().WithTracing().WithMetrics()), ship to a local OTLP collector, alert on 5xx-rate and p99 latency.
  4. Accessibility pass: Dedicated WCAG 2.2 AA scan (axe-core CI), fix <html lang> hardcoding on localised routes, skip-to-content link, contrast audit.
  5. Auth upgrade: Activate prepared Google OAuth path (NuGet already referenced, GoogleId column exists); remove 6-char password floor once SSO is primary.
Appendix A

Raw probe commands & responses

Every claim above is backed by one of the transcripts below. Click a section to expand the exact command and the exact output.

A.1 Baseline response headers
$ curl -sI https://wtb.land/
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
content-type: text/html; charset=utf-8
permissions-policy: camera=(), microphone=(), geolocation=(self)
referrer-policy: strict-origin-when-cross-origin
server: Kestrel
strict-transport-security: max-age=31536000; includeSubDomains; preload
via: 1.1 Caddy
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
A.2 Sensitive file exposure sweep
/wtbland.db            -> 404
/app.db                -> 404
/appsettings.json      -> 404
/.env                  -> 404
/.git/config           -> 404
/logs/                 -> 301 /logs (then 404)
/wtbland.db-wal        -> 404
/wtbland.db-shm        -> 404
/Program.cs            -> 404
/wtbland.proto         -> 200 (intentional, see WTBL-018)
/manifest.json         -> 200 (intentional)
A.3 Rate-limit hammer test (50 concurrent logins)
$ for i in $(seq 1 50); do echo $i; done \
    | xargs -P 10 -I{} curl -s -o /dev/null -w "%{http_code}\n" \
        -X POST "https://wtb.land/Home/Login" \
        -d "email=test@x.com&password=wrongwrong" \
    | sort | uniq -c
     50 200
A.4 Email-enumeration response-size delta
$ curl -s -X POST "https://wtb.land/Home/Login" \
    -d "email=nonexistent-xyz@test.invalid&password=x" \
    -o /tmp/r1 -w "len=%{size_download}\n"
len=8263

$ curl -s -X POST "https://wtb.land/Home/Login" \
    -d "email=admin@wtb.land&password=x" \
    -o /tmp/r2 -w "len=%{size_download}\n"
len=8249

$ diff /tmp/r1 /tmp/r2
<  <input type="email" name="email" value="nonexistent-xyz@test.invalid" ...
>  <input type="email" name="email" value="admin@wtb.land" ...
A.5 Cookie flags on a successful auth cookie
set-cookie: .AspNetCore.Cookies=...; expires=...; path=/; secure; samesite=lax; httponly
A.6 Protected gRPC method without auth
$ curl -s -o /dev/null -w "HTTP %{http_code}\n" \
    -X POST -H "Content-Type: application/grpc-web+proto" \
    "https://wtb.land/wtbland.WtblandService/GetMyRequests"
HTTP 401

# Anonymous method, correctly returns a gRPC 401 envelope:
$ curl -sI -X POST -H "Content-Type: application/grpc-web+proto" \
    "https://wtb.land/wtbland.WtblandService/GetMe"
HTTP/2 200
content-type: application/grpc-web
grpc-message: Incomplete message.
grpc-status: 13
A.7 OG-image CPU render cost
$ for i in 1 2 3; do
    curl -s -o /dev/null -w "Total: %{time_total}s\n" \
      "https://wtb.land/og/request/1.png?nocache=$i"
  done
Total: 0.124872s
Total: 0.148446s
Total: 0.139390s

$ curl -sI https://wtb.land/og/request/1.png | grep -iE "cache|age"
# (no cache-control returned)
A.8 Homepage timing (3 consecutive hits)
Total: 0.079754s  TTFB: 0.075006s  size=49525
Total: 0.099787s  TTFB: 0.097785s  size=49525
Total: 0.078124s  TTFB: 0.075451s  size=49525
A.9 Compression negotiation
$ curl -sI -H "Accept-Encoding: gzip" https://wtb.land/js/app.min.js | grep -i encoding
content-encoding: gzip

$ curl -sI -H "Accept-Encoding: br" https://wtb.land/js/app.min.js | grep -i encoding
# (no br support)
A.10 robots.txt (abbreviated - AI crawler allow-list)
User-agent: *
Allow: /
Disallow: /api/
Disallow: /Home/Login
Disallow: /Home/Register
Disallow: /Home/Logout

User-agent: GPTBot          -> Allow: /
User-agent: ClaudeBot       -> Allow: /
User-agent: CCBot           -> Allow: /
User-agent: Bytespider      -> Allow: /
User-agent: PerplexityBot   -> Allow: /
... (13 AI agents total, all allowed)

Sitemap: https://wtb.land/sitemap.xml
A.11 Database row counts (read-only, local sqlite3)
$ sqlite3 /home/redrocket/repo/wtbland/wtbland.db \
    "SELECT 'Users', COUNT(*) FROM Users
     UNION ALL SELECT 'Requests', COUNT(*) FROM Requests
     UNION ALL SELECT 'Offers', COUNT(*) FROM Offers
     UNION ALL SELECT 'ChatMessages', COUNT(*) FROM ChatMessages;"
Users|85
Requests|350
Offers|223
ChatMessages|6
A.12 File permissions on persistent state
$ stat -c '%a %U %n' /home/redrocket/repo/wtbland/wtbland.db*
644 root /home/redrocket/repo/wtbland/wtbland.db
644 root /home/redrocket/repo/wtbland/wtbland.db-wal
644 root /home/redrocket/repo/wtbland/wtbland.db-shm
A.13 Antiforgery grep across the full source tree
$ grep -rn "AddAntiforgery\|ValidateAntiForgeryToken\|@Html.AntiForgeryToken" \
    /home/redrocket/repo/wtbland/
(no matches)
End of sample

Want this report,
but for your product?

Fixed scope, fixed price. From first message to the report in your inbox: 5 business days.