Adapted from docs/admin/Security.md in the Wikantik repository; current as of release 2.4.58.
This page is for administrators who deploy and operate Wikantik. It describes how
users authenticate, how access is authorised, which protections are on by default, and
which settings can weaken them. It states what the code does today; each section names
the class or property that enforces it. To report a vulnerability, see the
security policy (SECURITY.md) instead. Deep references are linked from each
section.
Wikantik authenticates through JAAS. The default login module is
com.wikantik.auth.login.UserDatabaseLoginModule (wikantik.loginModule.class), which
checks the supplied password against the PostgreSQL-backed user database (see
PostgreSQL guide). New passwords are hashed with bcrypt.
| Method | How it works | Reference |
|---|---|---|
| Database accounts (default) | UserDatabaseLoginModule against the users table; the React login page posts credentials | PostgreSQL guide |
| HTTP Basic | BasicAuthFilter turns an Authorization: Basic header on /api/* and /admin/* into a real login, for scripts and CI | this page |
| Servlet-container auth | credentials supplied by the container (getUserPrincipal) are always picked up; to make users log in through the container, add your own <security-constraint> (with an <auth-constraint> role), <login-config> and <security-role> elements to web.xml. The shipped file has no commented-out block to enable: its only <security-constraint> is the TLS transport-guarantee (NONE; set CONFIDENTIAL for production) | wikantik.properties (authentication section) |
| Single sign-on | OpenID Connect and SAML 2.0 through pac4j, alongside local login | Single Sign-On guide |
| IdP provisioning | SCIM 2.0 server at /scim/v2/* for onboarding and offboarding | SCIM Provisioning guide |
| Custom JAAS module | set wikantik.loginModule.class to your own LoginModule (zero-argument constructor); options go in wikantik.loginModule.options.* | wikantik.properties |
Wikantik ships no LDAP login module. To authenticate against LDAP, supply a JAAS
LoginModule through wikantik.loginModule.class, or put an LDAP-capable IdP in front
of Wikantik and use SSO.
SSO links a login to a local account through wikantik.sso.identityClaim (default
sub). Set it to preferred_username only if your IdP keeps that claim stable and
unique, because it is mutable at most IdPs. SSO never adopts a pre-existing local
account of the same name that was not created by SSO; a name collision without a
matching marker fails closed. Configuration detail is in Single Sign-On guide.
JSESSIONID) must be issued with SameSite=Lax. The Tomcat
context template sets <CookieProcessor sameSiteCookies="lax" />
(Tomcat-context.xml.template). A Strict or missing attribute makes browsers
withhold the cookie on top-level navigations, so users look randomly logged out.
SessionCookiePolicyFilter logs an ERROR (rate-limited) when it sees the broken
policy; it never changes the response.web.xml marks JSESSIONID HttpOnly and cookie-only (no URL rewriting) but
deliberately does not set Secure, because the right value depends on topology.
Secure therefore depends on Tomcat knowing the request was HTTPS: either TLS reaches
Tomcat directly, or a TLS-terminating proxy sends X-Forwarded-Proto: https and the
RemoteIpValve is configured with protocolHeader="X-Forwarded-Proto" (the
comment in web.xml shows the valve and cookie processor). Without that, the session
cookie is issued without Secure.wikantik.cookieAuthentication (default false) turns on remember-me. A successful
password login then also issues a WikantikUID cookie that is HttpOnly,
SameSite=Lax, Secure on HTTPS, and holds an opaque, server-validated token.
RememberMeAuthFilter uses it to re-establish the session after a restart or
timeout. wikantik.cookieAuthentication.expiry sets the lifetime in days (default
14). The token-to-user mapping lives in $wikantik.workDir/logincookies; restrict
read access to that directory, because it can impersonate any user.MustChangePasswordFilter enforces that before other /api/* and /admin/* calls.PasswordValidator follows NIST 800-63B: length over composition rules, plus a
blocklist.
| Property | Default | Effect |
|---|---|---|
wikantik.password.minLength | 8 | shortest accepted password |
wikantik.password.maxLength | 128 | longest accepted password |
wikantik.password.blocklist.enabled | true | reject passwords on the bundled common-password list (case-insensitive) |
wikantik.login.throttling | true | slow repeated failed logins per account |
Deactivating an account locks it out of password and remember-me login.
Authorisation combines three layers: policy grants for what a role may do, groups for who holds the role, and page ACLs that narrow access to individual pages.
Default permissions for each role are rows in the policy_grants table
(principal_type, principal_name, permission_type, target, actions). Manage
them at Admin → Security (/admin/security), backed by /admin/policy. The
migration V003__policy_grants.sql seeds the baseline:
| Role | Seeded permissions |
|---|---|
All | view any page; edit own preferences and profile; log in |
Authenticated | modify and rename pages; create pages and groups; export the vault (V060__wiki_export_permission.sql added export); view groups; edit groups they belong to |
Asserted | view groups |
Admin | AllPermission (V043__admin_allpermission_canonical.sql collapsed the older page and wiki wildcard rows into a single all row) |
Permission types are page, wiki, group, admin and all. Page permissions are
view, comment, edit, modify, upload, rename, delete; wiki permissions are
createPages, createGroups, export, editPreferences, editProfile, login. Always change
grants through the admin UI or /admin/policy, because the policy is cached and only
the API refreshes it. The file-based WEB-INF/wikantik.policy applies only when no
wikantik.datasource is configured; if a datasource is configured but the database policy
cannot be initialised, startup fails with a WikiException rather than falling back to the
file.
Two rules in DatabasePolicy matter when you edit grants:
target is * and whose actions is * is converted to
AllPermission whatever its permission type. A page or wiki grant of */*
to a role makes that role a full administrator, so do not create one unless you mean
it. Use permission type all to say so explicitly. A wildcard action on a specific
target is malformed and is skipped with a warning.role and group rows match role and group
principals (they are interchangeable, which is how the seeded role Admin row applies
to the Admin wiki group and to a container role named Admin). user rows match
only the session's login name; a full name, wiki name or role never matches a user
row. A row with any other principal_type is skipped with a warning. Enter the login
name when you add a user grant./admin/* is protected by AdminAuthFilter. A request passes when the session holds
AllPermission or a scoped admin grant for the area it targets. AllPermission
comes from the Admin group: membership of the Admin group (the groups and
group_members tables) is what makes an account an administrator. A row in the roles
table alone is not enough: such an account authenticates but receives HTTP 403 on
/admin/*. The tell is that a wrong password returns 401 and a correct one returns 403.
Add an administrator through the API so the in-memory group cache updates. PUT
replaces the member list, so read it first:
curl -u admin:... http://localhost:8080/admin/groups/Admin
curl -u admin:... -X PUT -H 'Content-Type: application/json' \
-d '{"members":["existing1","existing2","new-admin"]}' \
http://localhost:8080/admin/groups/Admin
Whatever can write the Admin group can create administrators: the group editor and
PUT /admin/groups/Admin above. SCIM cannot — it refuses every write to the Admin group
with 403 (see SCIM Provisioning guide). Releases up to and
including 2.4.56 lacked that guard, so on those versions the SCIM bearer token is an
administrator-equivalent credential.
Other behaviour of AdminAuthFilter:
Accept: text/html) to an /admin/* route skips the
check so the React shell can load and send the user to the login page. JSON and XHR
calls always go through the check. A path containing a dot that is not .html is
never treated as a navigation, so it cannot skip authentication and still reach a
servlet.AdminLayout also redirects a signed-in user without the Admin role to
/wiki/Main; this is a convenience, and the server-side filter is the control./admin/* spans many functional areas behind one AllPermission check, so any
credential that reaches one reaches all of them, including apikeys, policy and
connector-credentials. To grant one area only, create a policy grant with permission
type admin, target set to the first path segment after /admin/, and action
access:
{"principalType":"user","principalName":"shipper-account","permissionType":"admin",
"target":"insights","actions":"access"}
Post it to /admin/policy (not raw SQL). AdminAuthFilter derives the area from the
request path, so a new endpoint gets its own area that nobody holds a grant for and
keeps requiring AllPermission. A bare /admin or a segment containing .. or :
never matches an area grant. AllPermission implies every area grant, so existing
administrators are unaffected. The content-intelligence shipper uses this; see
Content Intelligence guide.
A page can restrict itself with inline ACL markup in its body:
[{ALLOW view Admin}]()
[{ALLOW edit Admin,Editors}]()
Principals after the permission are comma-separated. Pages shipped in
wikantik-wikipages write the directive with a trailing (), for example
[{ALLOW edit Admin}](); the ACL itself is read from the [{ALLOW ...}]() part
(DefaultAclManager.ACL_REGEX).
When a page carries an ACL, a user needs both the matching role or group grant and an
ACL entry that names one of their principals. REST endpoints under /api/* enforce
ACLs through RestServletBase.checkPagePermission(), and /wiki/{slug}?format=md|json
hides restricted pages with a 404.
The directives name people by login, so their text is visible only to those who may change
the page. The page API (GET /api/pages/{name}, old versions included), ?format=md|json,
the diff endpoint, the Obsidian export and /tools/get_page remove every [{ALLOW ...}]() and
[{DENY ...}]() directive from the text they return to a caller without edit on the page. The
agent projection (/api/pages/for-agent/{id}, get_page_for_agent), read_pages on the
knowledge MCP and briefings never return them to anyone. Chunking and the Lucene content field
never contain them either, so search results, bundles and retrieval never quote them. Editors
and administrators see the directives as written in the page API and the editor, and ACL
evaluation is unchanged. Name groups rather than people in ACLs where you can: a login named in
an ACL is still visible to everyone who can edit that page.
Identify people by login name everywhere access is decided.
PUT /admin/groups/{name}, Admin → Security → Groups) rejects any member
that is not an existing login name with a 400 that names the bad entries. SCIM
group sync already maps users to login names.users.full_name) is required and 2 to 64 characters long after trimming. It has no
control characters, no invisible formatting characters (zero-width joiners, bidirectional
overrides) and no @. It must not be reserved (it equals a built-in role, Admin, a role
or group named in a policy grant, or an existing group), must not equal another account's
login name, and must not already be another account's display name, ignoring case,
surrounding whitespace and runs of whitespace. The rules run when a name is set or changed.
PUT /api/auth/profile and /admin/users (create and update; create requires a display
name) refuse a malformed or reserved name with 400 and a name that is taken with 409,
each with the reason. SCIM POST, PUT and PATCH on /scim/v2/Users answer 409 with
scimType uniqueness for a taken name and 400 with invalidValue for a malformed or
reserved one, without saving; a SCIM POST that carries no name.formatted creates the
account as "Member N". The application checks uniqueness; there is no database index
(migrations run on every container start, and an index would fail on existing duplicates),
so two saves racing for the same name can both succeed. Wiki names stay unique too: when
the wiki name derived from a display name is already held, the account gets a numbered one
(JohnSmith2), which is stored and read back as is, and kept when an identity provider
re-sends the same name. A save that still collides with another account's login or wiki
name is refused with a generic message: 409 uniqueness on SCIM; 400 on admin
create/update (/admin/users) and self-service (PUT /api/auth/profile). SCIM
POST /Groups returns 409 for a group name that is an account's login, full or wiki
name. SSO auto-provisioning never fails on a name and never uses the email address as one:
it takes the IdP's name claim when it passes the rules, adds a number ("Jane Doe 2") when
that name is taken, and falls back to "Member N" when there is no usable claim (see
Single Sign-On guide). A name is re-checked only when it changes, so an
account that predates these rules is never blocked from saving the rest of its profile.
bin/db/one-shots/dedupe_display_names.sh renames older duplicates once and gives any
account that has no stored user id one (see Wikantik Operations).People are shown to each other by display name. A login name is private: with Google
sign-in it is the user's email address. Only three things see one: the account's owner (on
the Preferences page), administrators on /admin/*, and the admin MCP server, which is
administrator-equivalent. The one exception is a login written into a page's access rules:
anyone who can edit that page sees those lines (see Page ACLs), which is why
you should name groups there instead of people.
PublicIdentity as it answers: the page, list, history,
recent-changes and search APIs, the for-agent projection, comment threads, mentions and the
mention picker, /feed.xml and /api/recent-articles, the knowledge MCP, /tools/*, the
server-rendered page and its data island, and the public ontology (dct:creator). A rename therefore applies to every attribution at
once: saving a profile drops the cached name, and PublicIdentity expires its cache after
60 seconds anyway. Two values are copied when a page is indexed or projected, so they follow a
rename only when the page is next saved or the indexes and the ontology are rebuilt: the
author field of the search index and the ontology's dct:creator. A stored value that is not a login (a name
recorded before 2.4.58, an agent label) is shown as written, except an email address, which
is shown as "unknown contributor".author: and verified_by: frontmatter fields are
the exception: a login there is shown as the account's display name, and an email address
that is no account as "unknown contributor". The change note mark_page_verified records,
verified by <login>, likewise names the verifier by display name wherever history is shown.list_pages, retrieve_context) matches the name readers see,
ignoring case, never the stored login, so it cannot confirm who wrote a page by login or
email address.Page ownership is covered in Page Ownership guide. Authentication,
authorisation, content and admin events go to a tamper-evident hash-chained log; see
Audit Log guide and Admin → Audit (/admin/audit).
Release 2.4.53 matches group members, user policy grants and ACL names by login name. Run these read-only queries against the wiki database before you deploy, so that nothing silently stops matching. The upgrade changes no data.
Group members that are not login names. Fix each by replacing the member with the login
name, using the group editor or PUT /admin/groups/{name}. Check the Admin group first:
an administrator listed by full name no longer counts as a member after the upgrade.
After the upgrade, startup logs one WARN (logger GroupMemberLoginCheck) listing such members,
and an ERROR naming any in the Admin group, until they are fixed.
SELECT gm.name, gm.member
FROM group_members gm
WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.login_name = gm.member)
ORDER BY gm.name, gm.member;
-- the login name each one should become
SELECT gm.name, gm.member, u.login_name
FROM group_members gm
JOIN users u ON gm.member IN (u.full_name, u.wiki_name)
WHERE gm.member <> u.login_name;
User-typed policy grants whose principal is not a login name. Such a row grants nothing
after the upgrade; re-create it with the login name at /admin/security. The type is
read trimmed and case-insensitively; rows with any type other than role, group or
user are skipped with a warning, so look for those too.
SELECT p.*
FROM policy_grants p
WHERE lower(trim(p.principal_type)) = 'user'
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.login_name = p.principal_name);
SELECT * FROM policy_grants
WHERE lower(trim(principal_type)) NOT IN ('role', 'group', 'user');
Role- or group-typed grants that name a user. Before 2.4.53 a grant matched on the
principal name alone, so a role or group row naming a user's login, full name or
wiki name granted that user. After the upgrade such a row matches only a role or group
of that name, so the user loses the permission. /admin/security offers role as the
default type, so check for these rows. Change each one to type user with the login
name shown in grant_to_login:
SELECT p.id, p.principal_type, p.principal_name, p.permission_type, p.target, p.actions,
u.login_name AS grant_to_login
FROM policy_grants p
JOIN users u ON p.principal_name IN (u.login_name, u.full_name, u.wiki_name)
WHERE lower(trim(p.principal_type)) IN ('role', 'group')
AND p.principal_name NOT IN ('All', 'Anonymous', 'Asserted', 'Authenticated')
AND NOT EXISTS (SELECT 1 FROM groups g WHERE g.name = p.principal_name)
ORDER BY p.id;
The query cannot see roles that the web container grants (the <security-role> names in
web.xml, used with container authentication). If a reported row names such a role, it
still works for members of that role; leave it, and add a separate user row only if the
named user relied on it.
Display-name collisions. These accounts keep working, and an ACL or session no longer
resolves through the colliding name. Renaming an account whose full or wiki name equals
another account's login removes the ambiguity. In 2.4.53 a shared full name was allowed and
needed no change, but an ACL that names it by full name is ambiguous, so use login names
there. (Since 2.4.58 display names are unique: a shared one is refused on save, and
bin/db/one-shots/dedupe_display_names.sh renames the existing duplicates once; see
Use login names in groups and ACLs.)
-- a full or wiki name that equals another account's login name
SELECT a.login_name, a.full_name, a.wiki_name, b.login_name AS shadowed_login
FROM users a
JOIN users b ON b.login_name IN (a.full_name, a.wiki_name) AND a.login_name <> b.login_name;
-- accounts that share a full name
SELECT full_name, array_agg(login_name)
FROM users
WHERE full_name IS NOT NULL AND trim(full_name) <> ''
GROUP BY full_name HAVING count(*) > 1;
-- full or wiki names equal (ignoring case) to a built-in role, a group, or a role/group grant
SELECT u.login_name, u.full_name, u.wiki_name
FROM users u
WHERE EXISTS (SELECT 1
FROM (VALUES ('admin'), ('all'), ('anonymous'), ('asserted'), ('authenticated')
UNION SELECT lower(name) FROM groups
UNION SELECT lower(principal_name) FROM policy_grants
WHERE lower(trim(principal_type)) IN ('role', 'group')) AS r(n)
WHERE r.n IN (lower(trim(u.full_name)), lower(trim(u.wiki_name))));
Page ACLs that name people by display name. ACLs live in page bodies, so no query finds
them. After the upgrade an ACL user entry grants only when it identifies exactly one
account, and then only that account's login: an entry naming a full or wiki name that
several accounts share stops granting, and an entry naming a name no account holds
matches only a container-authenticated login of exactly that name. Search your page store
for [{ALLOW and replace display names with login names:
grep -rn --include='*.md' '\[{ALLOW' /path/to/page-store
Also review active mcp, mcp_admin and all API keys that users created for themselves, because
non-administrators can no longer mint them (see API Keys guide). Revoke any you
do not want through /admin/apikeys:
SELECT k.id, k.principal_login, k.label, k.scope, k.created_at, k.last_used_at
FROM api_keys k
WHERE k.revoked_at IS NULL
AND k.scope IN ('mcp', 'mcp_admin', 'all')
AND k.created_by = k.principal_login
AND NOT EXISTS (SELECT 1 FROM group_members gm
WHERE gm.name = 'Admin' AND gm.member = k.principal_login)
ORDER BY k.created_at;
This last query does not see administrators whose rights come from a custom AllPermission
policy grant to some other group, so compare it with /admin/security.
wikantik.admin.bootstrap names a login that gets AllPermission regardless of database
grants. It exists only to recover access to a fresh or locked-out installation. The
override applies only to a session that authenticated with that login name. An
asserted ("remembered name") identity never qualifies, however it was obtained.
| Property | Default | Meaning |
|---|---|---|
wikantik.admin.bootstrap | blank | login name of the bootstrap admin; blank means no override |
wikantik.admin.bootstrap.maxAgeSeconds | blank (86400, 24 hours) | how long the override lasts after server start |
While it is active, startup logs a CRITICAL: BOOTSTRAP ADMIN OVERRIDE IS ACTIVE error
with the expiry. Remove the property once a real administrator works; do not leave it
set in production.
With wikantik.cookieAssertions (default true), a visitor who has not logged in can
"assert" a name through a cookie. The session then gets the Asserted role instead of
Authenticated. This is the old wiki convenience of remembering who you say you are; it
is not authentication and the property file itself calls it unsafe. An asserted session
holds only what the Asserted and All policy grants give it. By default that is
viewing groups (Asserted) plus the All grants: view any page, editPreferences,
editProfile and login (V003__policy_grants.sql). It never receives authenticated rights such as the bootstrap
override. Set wikantik.cookieAssertions=false if you do not want the feature.
API keys are SHA-256 hashed; only the hash is stored and the plaintext is shown once.
ApiKeyService.Scope has four values:
| Scope | Wire value | Reaches |
|---|---|---|
MCP_READ | mcp_read | /knowledge-mcp only (read-only) |
MCP_ADMIN | mcp_admin | /wikantik-admin-mcp and /knowledge-mcp (full admin) |
TOOLS | tools | /tools/* only |
ALL | all | every key-protected surface; the default when an administrator's POST omits scope (a non-admin self-service POST defaults to mcp_read) |
The MCP scopes are a rank hierarchy (mcp_read is contained in mcp_admin); tools is
separate. Before 2.4.54 the full-admin scope was named mcp; that name is still accepted as an
alias and keys stored under it remain full-admin (see
Legacy mcp scope name).
Prefer the narrowest scope. Non-administrators can self-mint only tools and
mcp_read keys; mcp_admin and all keys are issued by an administrator at /admin/apikeys. The /api/* REST surface uses session or Basic
authentication and does not accept API keys.
/knowledge-mcp and the other MCP and tools endpoints also fail closed. If there is no
DB-backed API key service (no datasource), no CIDR allowlist (mcp.access.allowedCidrs,
tools.access.allowedCidrs) and allowUnrestricted is not true, they answer 503 to
everything. Otherwise a request needs a key whose scope covers the endpoint (a key with
the wrong scope, or no valid key, gets 403) or a source address inside the allowlist. Each
has its own per-client limiter (mcp.ratelimit.*, tools.ratelimit.*). Details:
API Keys guide, MCP integration guide.
RateLimitFilter limits requests per client IP on a sliding window. It runs on
/api/*, /id/*, /export/* and /sparql, after request metrics and before the
authentication filters. It has two tiers:
| Tier | Paths | Per-client limit | Global limit |
|---|---|---|---|
| default | everything the filter maps | 25 requests per second | none (concurrency is bounded by BackpressureFilter) |
| expensive | prefixes /api/bundle, /api/search, /sparql | 3 requests per second | 10 requests per second across all clients |
Exempt from limiting: loopback callers (IPv4 and IPv6), any IPv4 CIDR in the exempt
list, and the exact path /api/health. The client IP is getRemoteAddr(), which is
the real client only if Tomcat's RemoteIpValve is configured to read the header your
proxy sets: CF-Connecting-IP behind Cloudflare, the deploy-local template hard-codes it,
and Docker deployments set it with PROXY_REMOTE_IP_HEADER (see
Docker deployment guide). A rejected request gets 429 with Retry-After: 1, a SecurityLog
line, and an increment of wikantik_ratelimit.rejected_total{tier=...}.
Configure it with environment variables, read once at startup. A limit of 0 disables
that bucket and all zeros disable the filter:
| Variable | Default |
|---|---|
WIKANTIK_RATELIMIT_DEFAULT_PERCLIENT | 25 |
WIKANTIK_RATELIMIT_EXPENSIVE_PERCLIENT | 3 |
WIKANTIK_RATELIMIT_EXPENSIVE_GLOBAL | 10 |
WIKANTIK_RATELIMIT_EXPENSIVE_PATHS | /api/bundle,/api/search,/sparql |
WIKANTIK_RATELIMIT_EXEMPT_CIDRS | empty |
Get the proxy header right. If the configured header does not match what your proxy
sends, every request appears to come from the proxy's own loopback or private address.
If the proxy is on loopback, loopback is exempt from rate limiting, so nobody is limited.
If the proxy is on a private but non-loopback address, every client shares one
rate-limit bucket. Either way, InternalNetworkFilter would let anyone
reach /metrics and /api/health, and any mcp.access.allowedCidrs or
tools.access.allowedCidrs entry covering that address would trust every caller. The
reverse is just as dangerous: a proxy that forwards a client-supplied copy of the
trusted header lets clients spoof 127.0.0.1. Make the proxy overwrite the header, and
check the result after any proxy change.
/sparql, /id/{type}/{id} and /export/* are public, read-only and permissive
about cross-origin access. They are safe to leave public because of a public/restricted
split: PublicProjectionFilter keeps pages, entities and edges that an anonymous guest
cannot view out of the materialised dataset entirely, so restricted content cannot be
queried. /sparql rejects update operations and caps results and run time. A page's
dct:creator comes from its author: frontmatter line: a value that is an account's login is
published as that account's display name, never as the login. See
Ontology Management guide.
Connector HTTP fetches (web crawler, sitemap, feed, GitHub, Confluence) pass through
EgressGuard, a server-side-request-forgery control. It rejects any URL whose scheme
is not http or https, and any host that resolves to a loopback, private,
link-local (including the cloud metadata address 169.254.169.254), multicast or
unspecified address, or an IPv6 unique-local address. It re-validates every redirect
hop, up to 5. To crawl an internal host on purpose, start the JVM with
-Dwikantik.connectors.egress.allowPrivate=true; the setting is read only as a system
property, and the scheme check still applies. The filesystem connector type is
properties-only and cannot be created from the admin UI. See
Connectors guide.
JDBCPlugin runs SQL authored in a page against the wiki's own datasource, using the
application's database role. It is off by default behind wikantik.plugin.jdbc.enabled
(false), checked before anything else. The additional admin check applies to the
user viewing the page, not to the author, so the real risk is an editor planting SQL
that runs, with the application's database role, when an administrator views the page.
Enable it only if you accept that a high-privilege database role executes
editor-supplied SQL.
A render that depends on the viewer (an ACL-aware include, a $username variable)
flags itself with Context.VAR_VIEWER_SENSITIVE. Those renders are excluded from the
principal-less document and HTML caches, so output rendered for one user is never
served to another.
ObjectInputFilter allow-lists wherever an
ObjectInputStream is used.Content-Security-Policy, Strict-Transport-Security,
X-Frame-Options: DENY, X-Content-Type-Options: nosniff,
Referrer-Policy: no-referrer-when-downgrade, Cross-Origin-Embedder-Policy: require-corp
and Cross-Origin-Resource-Policy: same-origin, applied by the filters in web.xml
(CSPFilter, STSFilter, ClickJackFilter, ContentTypeOptionsFilter,
ReferrerPolicyFilter, COEPFilter, CORPFilter)./api/health and /metrics are reachable only from loopback and RFC 1918 networks
(InternalNetworkFilter).wikantik.systemPages.mcpEditable (default
About) lists the exact page names that stay editable.SECURITY.md): how to report a vulnerability.Cluster Hub: Wikantik Platform Development Hub: Architecture, MCP Protocols, and Agent Briefings (wikantik-development)