Don’t Ship a Fortress with an Open Window: How OWASP ZAP Exposed Our Access Control Gaps
Welcome back to this series. Previously, we automated our data seeding pipeline into staging on every deploy, ready for QA to stress-test…
Don’t Ship a Fortress with an Open Window: How OWASP ZAP Exposed Our Access Control Gaps

Image Source: https://securemyorg.com/owasp-zap-a-comprehensive-guide/
Welcome back to this series. Previously, we automated our data seeding pipeline into staging on every deploy, ready for QA to stress-test our new Sekretariat dashboard.
And then it hit me: we had just made our staging environment very data-rich. Exam schedules, faculty user accounts, invigilator assignments — all sitting in a live, internet-accessible database. If our access controls weren’t airtight, that data was one crafted HTTP request away from being exposed to the wrong person.
So this sprint, we put security front and center. We mapped our implementation against the OWASP Top 10, implemented role-based access control on both the backend and frontend, and ran OWASP ZAP against our own app to find what we’d missed. What ZAP found was humbling — and fixing it made the codebase genuinely more trustworthy.
The Theory: OWASP, Authentication vs. Authorization, and Why Both Matter
Before writing a line of security code, it’s worth knowing what you’re defending against. The OWASP Top 10 is the industry-standard reference for the most critical web application security risks, maintained by the Open Web Application Security Foundation. It’s not a theoretical list — every item on it represents real attack patterns observed across real applications.
Let’s focus on three that were directly relevant to SiNgawas:
1. Broken Access Control — Users performing actions or accessing data outside their permitted scope. This is the #1 vulnerability on the OWASP Top 10, and for good reason. A classic example: a Lecturer intercepting an API request and approving their own exam event by manipulating the payload — bypassing the Admin entirely. We’ll come back to this one.
2. Injection — Unsanitized input being passed directly into queries or commands, allowing attackers to manipulate database logic. Django’s ORM defends against this by default through parameterized queries, but you still need to be deliberate about never constructing raw SQL from user input.
3. Security Misconfiguration — Insecure default settings, unnecessary features left enabled, or missing standard security headers. This is the one ZAP is particularly good at finding automatically — missing CSP headers, permissive X-Frame-Options, absent HSTS — all things that are easy to overlook until a scanner tells you they're missing.
One more distinction that’s worth being explicit about, because the terms get mixed up constantly:
Authentication is who you are. Can you prove your identity to the system? SiNgawas uses the university’s CAS SSO (sso.ui.ac.id) for this — users log in with their institutional credentials, and our backend validates the CAS ticket to establish identity.
Authorization is what you’re allowed to do. Once the system knows who you are, it needs to enforce what actions you can take. A Lecturer who is authenticated has no business approving events. A Student who is authenticated has no business viewing the export endpoint. These are authorization rules, and they need to be enforced at the API layer — not just hidden in the UI.
The Django security documentation and the OWASP Authentication Cheat Sheet both make this clear: never rely solely on the frontend to enforce access rules. The backend must always validate.
The Implementation: Defense at Every Layer
Backend: Django Permission Classes
The backbone of our authorization is a set of custom Django REST Framework permission classes. The most critical one is IsEventOwnerOrAdmin, which protects the event management endpoints:
class IsEventOwnerOrAdmin(BasePermission):
def has_permission(self, request, view):
return IsAuthenticatedAndActive().has_permission(request, view)
def has_object_permission(self, request, view, obj):
if is_admin_user(request.user):
return True
# Check if the user is an SDM (Lecturer/Coordinator)
# for the course associated with this event
mata_kuliah_term = getattr(obj, "mata_kuliah_term", None)
if not mata_kuliah_term:
return False
return SDMMataKuliahTerm.objects.filter(
user=request.user,
mata_kuliah_term=mata_kuliah_term,
role__in=[SDMMataKuliahTerm.Role.DOSEN, SDMMataKuliahTerm.Role.KOOR],
).exists()
Two layers of checking. has_permission runs first — is the user even authenticated and active? Then has_object_permission runs on the specific object being accessed — is this user actually the course owner for this event, or an Admin? A Lecturer can't touch events for courses they don't teach. Period.
Injection is handled by leaning on Django ORM’s parameterized queries everywhere. Our get_queryset for event listing is a good example — no raw SQL, just ORM filters that the database driver handles safely:
def get_queryset(self):
user = self.request.user
if is_admin_user(user):
return Event.objects.all()
# Lecturers/Coordinators only see events for their own courses
owned_matkul_terms = SDMMataKuliahTerm.objects.filter(
user=user,
role__in=[SDMMataKuliahTerm.Role.DOSEN, SDMMataKuliahTerm.Role.KOOR],
).values_list("mata_kuliah_term_id", flat=True)
return Event.objects.filter(mata_kuliah_term_id__in=owned_matkul_terms)
The Sekretariat export endpoint — the feature I wrote about back in Sprint 1 — uses a dedicated permission class and a custom content negotiation class to prevent format-override attacks:
class SekretariatExportSummaryView(APIView):
permission_classes = [IsSekretariatOrAdmin]
content_negotiation_class = IgnoreUrlFormatOverrideContentNegotiation
No IsSekretariatOrAdmin? No data. The content negotiation class ensures a user can't bypass the renderer by appending .json or .xml to the URL to get a different data format than intended.
For Security Misconfiguration, Django’s settings.py gets hardened in non-debug environments:
if not DEBUG:
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
X_FRAME_OPTIONS = 'DENY'
These settings are deliberately gated on not DEBUG — you don't want HSTS enforcement breaking your local dev loop, but you absolutely want it in production.
Frontend: Route Guards with ProtectedRoute
Authentication and authorization on the frontend is enforced through a ProtectedRoute component that wraps every protected route in the React Router tree:
const ProtectedRoute = ({ allowedRoles }: ProtectedRouteProps = {}) => {
const { isAuthenticated, loading, user } = useAuth()
const location = useLocation()
if (loading) {
return (
<div className="flex justify-center items-center min-h-screen">
<output className="animate-spin rounded-full h-12 w-12 border-b-2
border-blue-600" aria-label="Loading"></output>
<p className="ml-3 text-blue-600">Verifying authentication...</p>
</div>
)
}
if (!isAuthenticated) {
const target = buildUnauthRedirect(location.pathname)
return <Navigate to={target.to} replace state={target.state} />
}
if (allowedRoles && allowedRoles.length > 0) {
const hasRequiredRole = user.role && allowedRoles.includes(user.role)
if (!hasRequiredRole) {
const roleNames = allowedRoles
.map((role) =>
role.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
)
.join(' atau ')
const target = buildUnauthRedirect(location.pathname, sessionStorage, {
errorMessage: `Akses ditolak. Halaman ini hanya untuk ${roleNames}`,
})
return <Navigate to={target.to} replace state={target.state} />
}
}
return <Outlet />
}
Three checks, in order: is the auth state still loading? (show spinner, don’t flash a redirect) → is the user authenticated at all? (redirect to login with the intended destination preserved) → does the user’s role match allowedRoles? (redirect with a descriptive error message).
The role validation reads from the JWT decoded on the client side. But — and this is important — the JWT role check on the frontend is purely UX. It prevents a Lecturer from even seeing the Admin panel. The backend permission classes are the real enforcement. If someone bypasses the frontend routing and fires a raw API request with a Lecturer token, the Django permission class stops them cold.

Screenshots are real implementation of SiNgawas in staging environment
The ZAP Discovery: What the Scanner Found That We Missed
With the permission logic in place, I felt pretty confident about the codebase. Then I ran OWASP ZAP against localhost:3001 using Manual Explore + Automated Scan, and the first report humbled me.

Screenshot from OWASP ZAP Scan Report (Before Changes)
The High risk count was zero — good. But ten Medium alerts on our own app felt like a lot. Reading through them, the issues clustered into two categories.
Category 1: Missing Security Headers on our app
ZAP flagged localhost:3001 for: Content Security Policy (CSP) Header Not Set, Missing Anti-clickjacking Header, and Sub Resource Integrity Attribute Missing. All three were triggered by the same root cause: we had Google Tag Manager's script tag in our index.html, loaded from an external domain, with no CSP header to declare which external sources were trusted.
<!-- BEFORE — index.html, flagged by ZAP -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-TM4WR5GGYH"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-TM4WR5GGYH');
</script>
ZAP was right to flag this. An external script without a CSP or SRI hash is a supply chain attack vector — if Google’s CDN were ever compromised, our users’ browsers would execute malicious code with no browser-level protection.
The fix was two-pronged: remove Google Tag Manager from index.html entirely (analytics can be handled differently), and implement a proper CSP header in the nginx config. We also moved the Google Fonts import from an external @import URL to a locally-bundled CSS variable, eliminating another cross-domain dependency.
/* BEFORE — src/index.css */
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap');
/* AFTER — fonts served locally via CSS variable */
:root {
--font-sans: Manrope, Inter, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
The nginx security headers went from permissive to strict:
# BEFORE
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'
'unsafe-inline' https://www.googletagmanager.com; ..." always;
# (No Referrer-Policy, no Cache-Control)
# AFTER
add_header X-Frame-Options "DENY" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self';
style-src 'self'; font-src 'self' data:; img-src 'self' data:;
connect-src 'self' https://api-singawas.ppl.cs.ui.ac.id https://*.sentry.io
https://sso.ui.ac.id; frame-ancestors 'none'; base-uri 'self';
form-action 'self'; object-src 'none'; manifest-src 'self';
worker-src 'self';" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Cache-Control "no-store" always;
Notable changes: X-Frame-Options upgraded from SAMEORIGIN to DENY (SiNgawas should never be embedded in an iframe, period). The CSP now has no unsafe-inline on script-src for our own origin. frame-ancestors 'none' replaces the old X-Frame-Options as the modern equivalent. Referrer-Policy added. Cache-Control: no-store prevents browsers from caching sensitive authenticated responses.
Category 2: The Remaining Alerts Are Someone Else’s Problem (Mostly)
The remaining Medium alerts in both reports — the CSP wildcard directives, session ID in URL rewrite, cookie SameSite issues — all point to sso.ui.ac.id, which is the university's CAS server. That's not our code. We can't fix it. Knowing that distinction matters: don't let alerts against third-party systems distract you from fixing your own.

Screenshot from OWASP ZAP Scan Report (After Changes)
The Takeaway: Security Is a Sprint, Not a Checkbox
The second ZAP report — High: 0, Medium: 7, Low: 7, with all remaining Medium alerts pointing to the university’s SSO server and nothing to our own application — felt genuinely satisfying. Not because the number is zero (it won’t ever be zero if you’re scanning a realistic app that talks to third-party services). But because every issue that was in our control got fixed.
The most useful reframe from this sprint: security isn’t a phase you do at the end. It’s a lens you apply continuously. The IsEventOwnerOrAdmin permission class came out of thinking about authorization during feature development, not after. The CSP header fix came from running ZAP before the release, not after a breach report.
A few things I’d tell anyone starting this kind of work:
The OWASP Top 10 is a reading list, not a checklist. Actually read the descriptions. The article will make you immediately think of three places in your own code that need checking.
Run ZAP early and run it often. The first scan is always the most alarming. The second scan, after fixes, tells you whether your changes actually worked — which is a different and important question.
And read the scan results carefully before panicking. Plenty of ZAP’s alerts will point at third-party domains you don’t control. The skill is distinguishing “our problem” from “not our problem” — and then actually fixing the ones that are.
The pattern is consistent: invest in doing things right during the sprint, and the next sprint starts from a better baseline. Security, like testing and like infrastructure, is not a tax on development. It’s what makes the development sustainable.
SiNgawas is a Django + React application managing exam invigilator scheduling at Fasilkom UI, Universitas Indonesia. OWASP ZAP scans were run using Manual Explore + Automated Scan against the local development build.
메타데이터
- post_id
- bcba7e131e9c
- slug
- dont-ship-a-fortress-with-an-open-window-how-owasp-zap-exposed-our-access-control-gaps-bcba7e131e9c
- url
- https://medium.com/@stefanustanjaya230105/dont-ship-a-fortress-with-an-open-window-how-owasp-zap-exposed-our-access-control-gaps-bcba7e131e9c
- canonical_url
- https://medium.com/@stefanustanjaya230105/dont-ship-a-fortress-with-an-open-window-how-owasp-zap-exposed-our-access-control-gaps-bcba7e131e9c
- author_url
- https://medium.com/@stefanustanjaya230105
- status
- ok
- fetched_at
- 2026-06-24 23:31:39