Dog API Security Audit: 7 Findings, Including One False-Positive Worth Explaining
https://dog.ceo/api/breeds/image/randomAbout This API
The Dog API at dog.ceo is a free, no-auth REST API that serves random dog images organized by breed. The canonical endpoint is https://dog.ceo/api/breeds/image/random, which returns a one-line JSON object of the form {"message": "<image-url>", "status": "success"}. There are companion endpoints: /api/breeds/list/all returns the breed taxonomy, /api/breed/{breed}/images returns every image for a breed, and a handful of others. The image CDN behind it (images.dog.ceo) holds tens of thousands of breed-classified photos sourced from the Stanford Dogs Dataset.
The API has been live since around 2017. The maintainer is Elliott Landsworthy; the source repo is at github.com/ElliottLandsworthy/dog-ceo-api. The implementation is a small PHP application — the X-Powered-By: PHP/8.3.29 header makes the runtime obvious — fronted by Cloudflare and Varnish for caching. The community is active; the GitHub repo has a steady drip of issues and PRs about adding new breeds and fixing image classification edge cases.
Inside the JavaScript ecosystem, the Dog API is the “render an image” tutorial endpoint. Where SWAPI is used to teach pagination and search, where FakeStoreAPI is used to teach product grids, the Dog API is used to teach the simplest possible fetch-then-render flow: hit the URL, take the message field, drop it into an <img src>. It is in roughly every “your first React app” tutorial that needs an image to show, every “intro to useEffect” demo, every “build a Tinder-for-dogs” weekend project. It is, by tutorial reach, one of the most-called public APIs on the open web.
This audit is the eleventh in middleBrick's public-API case-study series. We scanned https://dog.ceo/api/breeds/image/random and walked the findings against the actual API surface and the redirect chain on a flagged path.
Threat Model
The Dog API is a low-stakes target. The data is public dog photos, the response shape is a single message field plus a status, and the only state is the random selection that picks an image per request. An attacker compromising the endpoint yields nothing of value beyond the ability to redirect tutorial demos at non-dog images, which would be a defacement rather than a breach. The maintainer's operational risk is denial-of-service against the PHP origin, and Cloudflare + Varnish absorb most of that.
Propagated patterns
The interesting threat surface, as with most of the public mocks in this series, is what the API teaches the developers consuming it.
Pattern 1: render-the-message-as-image-src. Every Dog API tutorial shows the same code: fetch(url).then(r => r.json()).then(d => img.src = d.message). The message field is a URL, and the URL is dropped directly into an img src. On the Dog API this is fine — the URL is always under images.dog.ceo and the maintainer controls the CDN. Students who copy this pattern into their own “return a user-uploaded image URL” flow inherit the assumption that any URL their backend returns is safe to render. That assumption breaks the moment the URL is user-controllable.
Pattern 2: trust-the-status-field. The Dog API includes a status field that is always "success". Tutorials check data.status === 'success' and proceed. The pattern is robust here because the API doesn't really fail. Students who copy this pattern into a real backend often skip HTTP-status-code checking entirely and trust a status field in the response body — which is a fine convention but only if the field is checked alongside the HTTP code, not instead of it.
What's not in the threat model
There is no PII anywhere. No authentication. No sessions. No user-supplied content. The image URLs are all under a single CDN domain. The /api/config path that the scanner flagged is a 301 redirect to a public marketing page, not a privileged surface. The PHP version disclosure is real but not directly exploitable on a read-only endpoint that takes no parameters and writes nothing.
Methodology
middleBrick ran a black-box scan against https://dog.ceo/api/breeds/image/random. The scan was read-only — no destructive HTTP methods, no auth headers, no probe payloads beyond the standard endpoint-discovery and CORS sweeps.
Twelve security checks ran. Seven produced findings; the rest produced clean negatives. Of the seven, one (the /api/config BFLA) merits a manual follow-up because the raw scanner result and the actual resource behind the URL disagree about severity. We followed the redirect chain by hand:
$ curl -sI https://dog.ceo/api/config
HTTP/2 301
location: http://dog.ceo/api/config/
$ curl -sIL https://dog.ceo/api/config/
HTTP/2 200
content-type: text/html; charset=UTF-8
# final URL: https://dog.ceo/dog-api/
# body: 9.6KB of marketing HTML, no admin surfaceThe scanner's BFLA heuristic flags any /admin, /manage, /config, /internal, or /health path that returns a non-404 status. /api/config returns a 301 here, which the heuristic counts as “reachable.” In an automated context that is the correct conservative call — a 301 to an admin login would absolutely be a finding. In this specific case, the redirect lands on the public marketing page, so the severity in context is informational, not HIGH.
We did not probe further. middleBrick is read-only by policy, so we did not attempt POSTs, did not enumerate breed paths for unindexed images, and did not stress-test the rate-limit boundary. Everything in this writeup is observable from a black-box GET on a single URL, plus one follow-up GET on the flagged BFLA path.
Results Overview
Dog API received a B grade with a score of 85. Seven findings, distributed: one CRITICAL, three HIGH, zero MEDIUM, three LOW.
The one CRITICAL is “API accessible without authentication” — the same structural finding every public catalog mock in this series produces. Intentional. The Dog API is meant to be world-readable, and an authentication requirement would defeat the point of it being the canonical “your first fetch” teaching endpoint.
The three HIGH findings are (1) “privileged endpoint accessible: /api/config” — discussed below as a false-positive in this specific case, (2) “CORS allows all origins (wildcard *)” — structural to a public mock that needs to be callable from any tutorial origin, and (3) “missing rate-limiting headers” — accurate; the API rate-limits behind Cloudflare but doesn't expose budget headers.
The three LOW findings are “missing security headers (3 of 4)” (HSTS, X-Content-Type-Options, X-Frame-Options absent), “no API versioning detected,” and “technology exposed via X-Powered-By: PHP/8.3.29.” The PHP version disclosure is the most actionable LOW — knowing the exact runtime version lets an attacker map straight to known CVEs for that build.
For comparison with peers in our public-API series:
- SWAPI: 4 findings, score 91 (A)
- Dog API: 7 findings, score 85 (B) — here
- HTTPBin: 11 findings, score 82 (B)
- Rick and Morty: 9 findings, score 78 (B)
- PokéAPI: 12 findings, score 76 (B)
- FakeStoreAPI: 10 findings, score 75 (C)
Dog API sits second only to SWAPI in our public-mock pool. The difference between Dog API and SWAPI is mostly the BFLA-redirect noise and the explicit framework version disclosure — neither of which is operationally exploitable against this specific service, but both of which would matter on an API that wasn't a static-data brochure.
Detailed Findings
API accessible without authentication
The endpoint returned 200 without any authentication credentials.
Implement authentication (API key, OAuth 2.0, or JWT) for all API endpoints.
Privileged endpoint accessible: /api/config
/api/config returned 200 without authentication. This may expose admin functionality.
Restrict access to admin/management endpoints. Implement RBAC with proper role checks.
CORS allows all origins (wildcard *)
Access-Control-Allow-Origin is set to *, allowing any website to make requests.
Restrict CORS to specific trusted origins. Avoid wildcard in production.
Missing rate limiting headers
Response contains no X-RateLimit-* or Retry-After headers. Without rate limiting, the API is vulnerable to resource exhaustion attacks (DoS, brute force, abuse).
Implement rate limiting (token bucket, sliding window) and return X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers.
Missing security headers (3/4)
Missing: HSTS — protocol downgrade attacks; X-Content-Type-Options — MIME sniffing; X-Frame-Options — clickjacking.
Add the following headers to all API responses: strict-transport-security, x-content-type-options, x-frame-options.
No API versioning detected
The API URL doesn't include a version prefix (e.g., /v1/) and no version header is present.
Implement API versioning via URL path (/v1/), header (API-Version), or query parameter.
Technology exposed via X-Powered-By: PHP/8.3.29
The X-Powered-By header reveals framework details.
Remove the X-Powered-By header in production.
Attacker Perspective
An attacker has nothing to do against the Dog API itself. The data is dog pictures, the response is one URL, the surface is a single endpoint. The only nominally interesting thing on the response is the X-Powered-By: PHP/8.3.29 header, which pinpoints the exact PHP runtime — useful for known-CVE mapping if there is ever a reachable PHP-runtime vulnerability. There isn't one on a read-only GET that takes no parameters and writes no state.
The PHP version disclosure
If you are an attacker scanning the open web for PHP runtimes, the Dog API tells you exactly what to map: PHP 8.3.29, served from origin behind Cloudflare. PHP 8.3.x has had its share of CVEs (some in the SAPI layer, some in core extensions). The Dog API's exposed endpoint doesn't take any input that would reach those code paths, so the disclosure is not exploitable here. It is exploitable in the general “use this disclosure to identify other PHP 8.3.29 services on the same operator and pivot” sense, which is the standard reason to suppress X-Powered-By.
The /api/config noise
The HIGH BFLA finding for /api/config lives on a 301 redirect chain that ends at a public marketing page. There is no admin surface behind it, no login form, no API config endpoint. An attacker chasing this finding spends thirty seconds verifying the redirect target is the dog-pictures brochure and moves on. The finding is correct under the scanner's model; the severity in context is informational.
The interesting attack — propagation, again
As with the other tutorial-target APIs in this series, the more substantive concern is downstream. Tutorials using the Dog API teach “take this URL field, drop it in an img src.” If a student copies that pattern into a real backend that returns user-uploaded image URLs, the URL is no longer maintainer-controlled and the img src can become an attack vector — XSS via SVG, exfiltration via referrer, content-type confusion. The Dog API is safe; the pattern survives to less safe contexts.
Analysis
The interesting walkthrough on this scan is the BFLA finding, because it's the one place where the raw scanner output and the in-context severity disagree. Here is the redirect chain on the flagged path:
$ curl -sI https://dog.ceo/api/config
HTTP/2 301
server: cloudflare
location: http://dog.ceo/api/config/
$ curl -sIL https://dog.ceo/api/config/
HTTP/2 200
server: cloudflare
content-type: text/html; charset=UTF-8
# final URL: https://dog.ceo/dog-api/
$ curl -s https://dog.ceo/dog-api/ | head -c 200
<!doctype html>
<html class="no-js" lang="">
<head>
<meta charset="utf-8">
<title>Dog API</title>The flagged path lands on the dog-api brochure HTML. There is no admin surface, no privileged JSON, no login form. The scanner's BFLA heuristic is doing the right thing in flagging any /config path that responds with a non-404 — on a service that did have admin tooling, this is exactly the finding you'd want raised. On the Dog API, the path is part of the public marketing site that happens to share the dog.ceo origin with the API.
The CORS finding is straightforward:
$ curl -sI https://dog.ceo/api/breeds/image/random
access-control-allow-origin: *
x-powered-by: PHP/8.3.29Wildcard CORS is correct for a public mock that needs to be callable from any tutorial origin (Codesandbox, Stackblitz, every localhost port, every Vercel preview URL). It's not configurable per origin without breaking the canonical use case. The pattern is fine here, and worth flagging for students who would copy the app.use(cors()) shortcut into a real backend that handles user data.
The rate-limit-headers finding is also accurate. The Dog API rate-limits at the Cloudflare edge — sustained scraping does eventually 429 — but the response carries no X-RateLimit-Limit, no X-RateLimit-Remaining, no Retry-After. Consumers see the budget only after they've already exceeded it. A tutorial that drives a student to hammer the API in a tight loop hits the wall with no warning.
The missing security headers — HSTS, X-Content-Type-Options, X-Frame-Options — are a one-line edge-config fix. The Dog API serves only JSON to the API path, which limits the practical impact of the missing headers, but they are also free to add.
The X-Powered-By disclosure is the most actionable single fix. PHP runtime versions are easy to suppress via expose_php = Off in php.ini, and the disclosure removes a free piece of reconnaissance for anyone fingerprinting the open web for specific PHP builds.
Industry Context
Dog API sits in the “render-an-image” niche alongside the unsplash demo API, the Lorem Picsum service, and (in the cat-equivalent) the Cat API and CATAAS. Among that group, Dog API has the cleanest response shape — a single message field with a URL — and the most disciplined surface. Cat API and CATAAS both expose more endpoints (favorites, ratings, breeds-with-images-and-metadata) and consequently produce more findings on a comparable scan.
Compared to a real production image-serving API — Cloudinary's media APIs, ImageKit, Imgix — the Dog API is obviously different and should be. Real image APIs handle authenticated uploads, signed URL generation, transformation pipelines, derivative caching, and per-tenant rate limiting. The Dog API does none of that because it doesn't need to. The gap between “return a static image URL from a fixed catalog” and “serve user-uploaded media with transformation and access control” is enormous, and tutorials that teach the former without acknowledging the gap leave students unprepared for the latter.
For compliance: there is no PII, no payment data, no health data. The Dog API is out of scope for GDPR, PCI-DSS, HIPAA, and the LATAM data-protection frameworks. The image dataset (Stanford Dogs Dataset) is licensed for non-commercial research; the Dog API's redistribution under a free public service falls within the spirit of that license.
OWASP API Top 10 2023 mapping for what this audit finds: API1 (broken authentication) on the no-auth-on-public-catalog finding (intentional). API3 (BOLA) is not flagged because the resource has no per-object authorization to break. API5 (BFLA) is the noisy /api/config finding. API8 (security misconfiguration) covers the wildcard CORS, missing rate-limit headers, missing security headers, and X-Powered-By disclosure. API9 (improper inventory management) covers the no-versioning finding. Five of ten categories represented. The remaining five categories aren't represented because the surface is too small to produce them.
Remediation Guide
X-Powered-By: PHP/8.3.29 disclosure
Suppress the header at the PHP level via php.ini. Zero behavior impact, removes the free reconnaissance signal.
; php.ini
expose_php = Off
; Or if you can't edit php.ini, suppress at the Cloudflare edge via a Transform Rule:
; Rules > Transform Rules > Modify Response Header
; When: hostname eq "dog.ceo"
; Then: Remove header "X-Powered-By" Missing rate-limit headers
Add X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and Retry-After via a Cloudflare Worker that wraps the origin. The edge already enforces the limit; the change is to expose the budget to consumers.
// Cloudflare Worker — fronts dog.ceo/api/*
export default {
async fetch(request, env) {
const ip = request.headers.get('cf-connecting-ip') || 'anon';
const key = `rl:${ip}:${Math.floor(Date.now() / 60000)}`;
const used = parseInt(await env.RL.get(key) || '0', 10);
const limit = 600; // 600 requests/minute, matching documented policy
if (used >= limit) {
return new Response(JSON.stringify({status: 'error', message: 'rate limit'}), {
status: 429,
headers: {
'Retry-After': '60',
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(Math.ceil(Date.now() / 1000 / 60) * 60)
}
});
}
await env.RL.put(key, String(used + 1), {expirationTtl: 70});
const resp = await fetch(request);
const headers = new Headers(resp.headers);
headers.set('X-RateLimit-Limit', String(limit));
headers.set('X-RateLimit-Remaining', String(limit - used - 1));
headers.set('X-RateLimit-Reset', String(Math.ceil(Date.now() / 1000 / 60) * 60));
return new Response(resp.body, {status: resp.status, headers});
}
}; Missing security headers (HSTS, X-Content-Type-Options, X-Frame-Options)
Add via Cloudflare HTTP Response Header Modification rule — no application code change.
// Cloudflare Transform Rules > Modify Response Header > Set static
// Header: Strict-Transport-Security
// Value: max-age=31536000; includeSubDomains; preload
//
// Header: X-Content-Type-Options
// Value: nosniff
//
// Header: X-Frame-Options
// Value: DENY BFLA false-positive on /api/config
Either 404 the path (the URL is undocumented and shouldn't be reachable) or redirect it to a documented JSON endpoint rather than the marketing brochure. Either kills the noise on every future scan.
// Apache .htaccess — return 404 instead of redirecting to brochure
RewriteEngine On
RewriteRule ^api/config/?$ - [R=404,L]
// Or in PHP, return a JSON 404 that matches the API's response shape
<?php
header('Content-Type: application/json');
http_response_code(404);
echo json_encode(['status' =>'error', 'message' => 'endpoint not found', 'code' => 404]); Consumer pattern: don't trust message URLs blindly downstream
When the URL is no longer maintainer-controlled (i.e. when you graduate from Dog API to a real backend that returns user-uploaded image URLs), validate the URL host before rendering. Allowlist your CDN origins; reject everything else.
function safeImageUrl(url) {
let parsed;
try { parsed = new URL(url); } catch { return null; }
const allowed = new Set(['cdn.example.com', 'images.example.com']);
if (parsed.protocol !== 'https:' || !allowed.has(parsed.hostname)) return null;
return parsed.toString();
}
const src = safeImageUrl(data.message);
if (src) img.src = src; Defense in Depth
For the Dog API maintainer, the action items are short and most of them are optional. None of the seven findings represent an actionable risk against the service itself. In rough priority order:
1. Suppress X-Powered-By. One line in php.ini: expose_php = Off. Removes the free reconnaissance signal. Zero behavior impact.
2. Emit rate-limit headers. The edge already enforces a limit; the change is to surface the budget to consumers. On a Cloudflare-fronted PHP origin, the cleanest path is a Cloudflare Workers script (or a Page Rule with a transform) that adds X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After to every response based on the operator's documented policy.
3. Add HSTS, X-Content-Type-Options, X-Frame-Options. Cloudflare HTTP Response Header Modification rule, three lines, no application change.
4. Decide what to do about /api/config. If /api/config is meant to redirect users to the marketing brochure, change it to a 404 (the path is undocumented and would cease to be reachable) or change the destination to a documented endpoint. Either kills the BFLA false-positive for every scanner that hits the API. Lowest priority because the destination is harmless, but the noise is real.
5. URL versioning. Optional. Adding /v1/ now is a breaking change for every tutorial that hard-codes the path. Document the implicit-v1 contract in the README rather than introducing the prefix.
For consumers — apps and tutorials that use the Dog API — the defenses are: cache responses where the use case allows (the breed taxonomy never changes), respect Cloudflare's soft rate limit by not hammering in tight loops, and don't carry the “drop the message URL into an img src with no validation” pattern into apps where the URL becomes user-controllable.
Conclusion
Dog API scored 85/100 with seven findings. One of them — the HIGH BFLA on /api/config — is a noisy false-positive that lands on a public marketing redirect. The others are split between the structural “public mock has no auth and wildcard CORS” pair and a hygiene set: missing rate-limit headers, three missing security headers, no URL versioning, and an X-Powered-By disclosure that pinpoints PHP/8.3.29.
For maintainers, the highest-leverage fix is suppressing X-Powered-By — one line in php.ini, no behavior change, removes the free reconnaissance signal. The next-highest is emitting rate-limit headers via a Cloudflare Worker or transform rule, which would let CI runs and tutorial loops back off pre-emptively rather than discovering the limit by hitting it. Both are small operational changes; together they would push the score above 90.
For consumers, the API is exactly what it presents: a free, no-auth catalog of breed-classified dog images that has been the JavaScript ecosystem's canonical “render an image” tutorial endpoint for the better part of a decade. Use it. Don't carry the trust-the-URL-field-blindly pattern into apps where the URL becomes user-controllable. Don't copy app.use(cors()) wildcard into a real backend that handles real data.