Investigating anonymous Power Pages exposure — forensic + remediation playbook
Praxisis D365 Wiki · Public reference · Client-specific details redacted
Source: Claude.ai chat (original: checking-security-risks-in-legacy-adx-studio-power-portals__fff9f958.md)
Added: 2026-10-02
Summary
- The uncomfortable headline: Power Pages keeps no request-level access logs by default, and the main logging mechanism (Application Insights) does not backfill — it only collects from the moment it's enabled. Whether you can answer "did anyone read the data" depends almost entirely on what was already switched on before the exposure window.
- The forensic landscape in priority order: (1) Application Insights (if already connected) · (2) Microsoft Purview audit (config-change timeline, not reads) · (3) Dataverse auditing (usually unhelpful for portal reads) · (4) Fallback paths when none of 1-3 was in place.
Key takeaways
Application Insights — the only real per-request source
- Must be connected before the exposure window to answer "did anyone read the data." Check PPAC → site → monitoring, or look for the instrumentation-key site setting. If it wasn't wired to a Log Analytics workspace before the window, there is no native historical record of anonymous reads.
- Default retention is 90 days — if the exposure predates that, even an enabled instance may have aged the data out. Set Log Analytics retention generously on new deployments.
- IP masking: Application Insights masks the last octet of IPv4 by default (privacy setting) →
client_IPis coarse unless masking was disabled. - KQL query patterns (adjust the time window):
- Anonymous API/OData pulls:
requests | where url has "/_api/" or url has "/_odata/" | summarize hits=count() by client_IP, path, bin(timestamp, 1h) | order by hits desc - Entity-list / entity-form pages (never touch
/_api/):pageViews | where url has "<partial-url-of-the-exposed-page>" | summarize views=count() by client_IP, url, bin(timestamp, 1d) - Bulk-extraction signature (one source hammering endpoints):
requests | summarize total=count(), paths=dcount(url) by client_IP, bin(timestamp, 1h) | where total > 100
- Anonymous API/OData pulls:
Purview audit — config-change timeline, not reads
- Microsoft Purview compliance portal → Audit → filter activity. Captures administrative activity (
PowerPlatformAdministratorActivityrecord type) well — right tool to answer "when was the anonymous permission introduced, and by whom." - Does NOT capture anonymous data reads.
Dataverse auditing — unlikely to help here
- Read auditing is normally off. Even when on, portal reads execute under the portal's own application identity, not the anonymous visitor — so it can't attribute a read to an external actor. Don't rely on it.
If Application Insights wasn't already on (the likely real-world case)
- Power Pages is Microsoft-hosted — you do NOT have access to underlying IIS request logs (you would on a self-hosted legacy Adxstudio box). Realistic options:
- Customer-managed WAF / CDN / Azure Front Door in front of the custom domain, if any — those logs are the best forensic source and may predate the exposure. Check whether such a layer exists.
- Microsoft support case — Microsoft can sometimes retrieve backend telemetry from their side for a defined window. For a suspected data exposure, this is the legitimate route most likely to produce an authoritative answer. Open it citing specific tables + window.
- Accept that access can be neither confirmed nor ruled out, and document that. "Absence of logs is not absence of access" is an honest finding counsel needs for the notification call.
The query-string / IDOR mechanism (why these exposures happen)
- Power Pages basic forms have a Record Source Type = Query String option. When set, the form decides which record to load by reading a GUID from the URL (parameter named in "Record ID Query String Parameter Name", conventionally
id). - URL pattern:
.../program-page/?id=<GUID>— the GUID is fully under the visitor's control. - If table permissions aren't enforced (or anonymous has Global Read), the attack is just: edit the address bar, swap in another record's GUID, read their record. No login, no clever exploit. If Write permissions are also granted, same swap allows modifying another record.
- Liquid pattern
{{request.params.id}}is the same mechanism — the page trusts the GUID from the URL. The exposure question is simply "does the permission layer check the caller is allowed to touch that specific GUID, or does it serve whatever's asked for?"
Learnings / how to apply
- Day 1 default for every Praxisis Power Pages engagement: enable Application Insights + Log Analytics with 1-year+ retention + IP-masking disabled (where privacy policy permits) + Site Checker in release pipeline. Treats "we don't have logs" as a bug, not a condition.
- Codify the "forensic triage sequence" as a Praxisis Sustain Operating Model incident-response page: (1) App Insights? (2) Purview config changes? (3) Front Door / WAF layer? (4) MS support case citing specific tables + window. (5) Document "neither confirmed nor ruled out" as the formal finding when steps 1-4 fail — provides legal counsel with an honest statement.
- Enumerate the query-string entity forms on any Power Pages security review: find basic forms with Record Source Type = Query String on sensitive tables (contacts, program records) sitting on anonymously-reachable pages. These are the IDOR surface by design — audit every one for permission enforcement.
- "Modern Power Pages = no IIS logs" is counter-intuitive for consultants coming from legacy Adxstudio — document explicitly so expectations are set before an incident, not during.
Need this applied to your own tenant?
Praxisis Sustain Self-Serve builds a private AI Wiki trained on your documents and tickets, with free Tier-2 support when the AI cannot answer. Book a 30-min overview.