Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: CI

on:
push:
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false

- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Check syntax
run: node --check server.js && node --check lib/safe-fetch.js

- name: Run tests
run: pnpm test

- name: Audit production dependencies
run: pnpm audit --prod --audit-level=high
128 changes: 77 additions & 51 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,84 +1,110 @@
# SEO Audit & Social Media Preview

This Node.js application performs a basic SEO audit and displays social media previews of a website by analyzing its meta tags. It uses Express, Axios, and Cheerio on the backend and is styled with [Bootstrap 5](https://getbootstrap.com/) for a responsive, user-friendly interface.
A compact Node.js application that audits a public webpage's SEO metadata and renders Open Graph and Twitter Card previews. It demonstrates server-side HTML parsing, defensive outbound HTTP requests, secure rendering, and automated HTTP testing.

## Features

- **SEO Audit:**
Checks for key SEO elements such as:
- Title Tag
- Meta Description
- Canonical Link
- H1 Tag
- Language Attribute
- Robots Meta Tag
- Viewport Meta Tag
- Open Graph Tags (og:title, og:description, og:image, og:url)
- Twitter Card Tags (twitter:card, twitter:title, twitter:description, twitter:image)
- Checks title, description, canonical URL, H1, language, robots, and viewport metadata.
- Reports completeness of Open Graph and Twitter Card tags.
- Renders website, Facebook, Twitter, and TikTok-style preview cards.
- Lists discovered Open Graph and Twitter metadata for inspection.
- Resolves relative preview URLs against the audited page.
- Returns clear errors for invalid, unreachable, oversized, slow, or non-HTML targets.

- **Social Media Preview:**
Displays preview cards for Facebook, Twitter, and TikTok based on the extracted meta tags.
## Security and reliability

- **Responsive UI:**
Powered by Bootstrap 5 for a modern and clean design.
Because the application fetches user-provided URLs, outbound requests are treated as untrusted:

## Live Preview
- Only HTTP and HTTPS URLs without embedded credentials are accepted.
- Local, private, reserved, link-local, and non-public IPv4/IPv6 targets are blocked.
- DNS results and redirect destinations are checked to reduce SSRF risk.
- Requests have a 10-second timeout, five-redirect limit, and 2 MB response limit.
- Non-HTML responses are rejected.
- Metadata and URL attributes are escaped or sanitized before rendering.
- Helmet security headers, a Content Security Policy, and per-IP rate limiting are enabled.

Check out a live demo of the project here: [Live Demo](https://boiling-escarpment-36147-49817ed139e2.herokuapp.com/?url=https%3A%2F%2Fwww.wikipedia.com)
These controls are appropriate for a demonstration project, but a public high-traffic deployment should also enforce limits at its reverse proxy or hosting platform.

Alternatively, if you prefer to run the project locally, follow the installation and usage instructions below.
## Technology

## Installation
- Node.js and CommonJS
- Express 5
- Axios
- Cheerio
- Bootstrap 5
- Node's built-in test runner and Supertest

1. **Clone the Repository:**
```
git clone https://github.com/alianjum0/ogpreview-node.git
```
## Requirements

2. **Navigate to the Project Directory:**
- Node.js 20.18.1 or newer
- pnpm 10.33.0

```
cd seo-audit-preview
```
The repository is pnpm-based. Avoid running npm and pnpm against the same `node_modules` directory.

3. **Install Dependencies:**
## Run locally

```
npm install
```bash
corepack enable
pnpm install
pnpm start
```

## Usage
Open [http://localhost:3001](http://localhost:3001), then enter a complete public URL such as `https://example.com`.

1. **Start the Server:**
To use another port:

```bash
PORT=4000 pnpm start
```
npm start

No database, API key, or environment file is required.

If the application runs behind a reverse proxy, set the number of trusted proxy hops so rate limiting uses the client address safely:

```bash
TRUST_PROXY_HOPS=1 pnpm start
```

2. **Open Your Browser:**
## Tests

Navigate to "http://localhost:3000" and enter the URL you want to analyze in the input field. Click **Analyze** to view the SEO audit results and social media previews.
```bash
pnpm test
```

## Project Structure
The test suite covers:

- Private-network and DNS-based SSRF rejection
- IPv4 and IPv6 classification
- URL scheme and credential validation
- HTML and attribute escaping
- Unsafe preview URL removal
- Successful SEO and social metadata rendering
- HTTP security headers, error responses, and rate limiting

## Project structure

```text
.
├── .github/workflows/ci.yml # Node 20/22 CI checks
├── lib/safe-fetch.js # URL validation and bounded HTTP fetching
├── test/ # Unit and HTTP integration tests
├── server.js # Express app, metadata parsing, and rendering
├── package.json
└── pnpm-lock.yaml
```

seo-audit-preview/
├── server.js # Main Node.js application file
├── package.json # Node.js package file with dependencies and scripts
├── README.md # Project documentation
└── .gitignore # Files and directories to ignore in Git
## Deployment

## Dependencies
The included `Procfile` starts the application with `node server.js`, and the server honors the hosting platform's `PORT` environment variable. Deploy it to a Node.js host, verify the public URL, and then add that URL to the repository's GitHub **Website** field.

- [Express](https://expressjs.com/)
- [Axios](https://axios-http.com/)
- [Cheerio](https://cheerio.js.org/)
- [Bootstrap 5](https://getbootstrap.com/)
The previous Heroku demo is no longer linked because it currently returns 404.

## Contributing
## Known limitations

Contributions are welcome! Feel free to submit issues or pull requests if you have suggestions or improvements.
- Social previews approximate platform layouts; platforms may apply additional rules and image processing.
- JavaScript-rendered metadata is not executed because the application analyzes the fetched HTML response.
- Public websites may block automated requests or return different metadata based on geography or user agent.

## License

This project is licensed under the [MIT License](LICENSE).

Licensed under the [MIT License](LICENSE).
158 changes: 158 additions & 0 deletions lib/safe-fetch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
const dns = require("node:dns");
const net = require("node:net");
const axios = require("axios");
const ipaddr = require("ipaddr.js");

const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const REQUEST_TIMEOUT_MS = 10_000;

class TargetUrlError extends Error {
constructor(message) {
super(message);
this.name = "TargetUrlError";
this.statusCode = 400;
}
}

class FetchPageError extends Error {
constructor(message) {
super(message);
this.name = "FetchPageError";
this.statusCode = 502;
}
}

function isBlockedAddress(address) {
if (!ipaddr.isValid(address)) return true;

let parsed = ipaddr.parse(address);
if (parsed.kind() === "ipv6" && parsed.isIPv4MappedAddress()) {
parsed = parsed.toIPv4Address();
}

return parsed.range() !== "unicast";
}

function normalizeTargetUrl(value) {
if (typeof value !== "string" || !value.trim()) {
throw new TargetUrlError("Enter a valid HTTP or HTTPS URL.");
}

let parsed;
try {
parsed = new URL(value.trim());
} catch {
throw new TargetUrlError("Enter a valid HTTP or HTTPS URL.");
}

if (!["http:", "https:"].includes(parsed.protocol)) {
throw new TargetUrlError("Only HTTP and HTTPS URLs are supported.");
}
if (parsed.username || parsed.password) {
throw new TargetUrlError("URLs containing credentials are not supported.");
}

const hostname = parsed.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
throw new TargetUrlError(
"Private and local network addresses are not allowed.",
);
}
if (net.isIP(hostname) && isBlockedAddress(hostname)) {
throw new TargetUrlError(
"Private and local network addresses are not allowed.",
);
}

return parsed.toString();
}

function createSafeLookup(lookup = dns.lookup) {
return (hostname, options, callback) => {
const requestedOptions =
typeof options === "number" ? { family: options } : { ...options };

lookup(
hostname,
{ ...requestedOptions, all: true, verbatim: true },
(error, addresses) => {
if (error) return callback(error);

const results = Array.isArray(addresses) ? addresses : [addresses];
if (!results.length || results.some(({ address }) => isBlockedAddress(address))) {
return callback(
new TargetUrlError(
"Private and local network addresses are not allowed.",
),
);
}

if (requestedOptions.all) return callback(null, results);
return callback(null, results[0].address, results[0].family);
},
);
};
}

function validateRedirect(options) {
const port = options.port ? `:${options.port}` : "";
normalizeTargetUrl(
`${options.protocol}//${options.hostname}${port}${options.path || "/"}`,
);
}

async function fetchPage(
value,
{ client = axios, lookup = dns.lookup } = {},
) {
const url = normalizeTargetUrl(value);

try {
const response = await client.get(url, {
beforeRedirect: validateRedirect,
headers: {
Accept: "text/html,application/xhtml+xml",
"User-Agent": "OGPreview/1.0 (+https://github.com/alianjum0/ogpreview-node)",
},
lookup: createSafeLookup(lookup),
maxBodyLength: MAX_RESPONSE_BYTES,
maxContentLength: MAX_RESPONSE_BYTES,
maxRedirects: 5,
proxy: false,
responseType: "text",
timeout: REQUEST_TIMEOUT_MS,
});

const contentType = String(response.headers["content-type"] || "")
.split(";", 1)[0]
.trim()
.toLowerCase();
if (!["text/html", "application/xhtml+xml"].includes(contentType)) {
throw new TargetUrlError("The target URL did not return an HTML page.");
}

return response.data;
} catch (error) {
if (error instanceof TargetUrlError) throw error;
if (error.cause instanceof TargetUrlError) throw error.cause;
if (error.code === "ECONNABORTED") {
throw new FetchPageError("The target website took too long to respond.");
}
if (
error.code === "ERR_BAD_RESPONSE" &&
error.message.includes("maxContentLength")
) {
throw new FetchPageError("The target page is larger than 2 MB.");
}
throw new FetchPageError("The target website could not be fetched.");
}
}

module.exports = {
FetchPageError,
TargetUrlError,
createSafeLookup,
fetchPage,
isBlockedAddress,
normalizeTargetUrl,
};
19 changes: 13 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
"description": "A Node.js application that performs a basic SEO audit and displays social media previews based on a website's meta tags.",
"main": "server.js",
"scripts": {
"start": "node server.js"
"start": "node server.js",
"test": "node --test"
},
"packageManager": "pnpm@10.33.0",
"engines": {
"node": ">=20.0.0"
"node": ">=20.18.1"
},
"keywords": [
"seo",
Expand All @@ -20,9 +22,14 @@
"author": "Muhammad Ali Anjum",
"license": "MIT",
"dependencies": {
"axios": "^1.4.0",
"cheerio": "^1.0.0-rc.12",
"express": "^4.18.2"
"axios": "^1.20.0",
"cheerio": "^1.2.0",
"express": "^5.2.1",
"express-rate-limit": "^8.7.0",
"helmet": "^8.3.0",
"ipaddr.js": "^2.5.0"
},
"devDependencies": {
"supertest": "^7.3.0"
}
}

Loading
Loading