CVE-2024-28195: Insufficient Information (NVD-CWE-noinfo)
value: your_spotify is an open source, self hosted Spotify tracking dashboard. YourSpotify versions < 1.9.0 do not protect the API and login flow against Cross-Site Request Forgery (CSRF). Attackers can use this to execute CSRF attacks on victims, allowing them to retrieve, modify or delete data on the affected YourSpotify instance. Using repeated CSRF attacks, it is also possible to create a new user on the victim instance and promote the new user to instance administrator if a legitimate administrator visits a website prepared by an attacker. Note: Real-world exploitability of this vulnerability depends on the browser version and browser settings in use by the victim. This issue has been addressed in version 1.9.0. Users are advised to upgrade. There are no known workarounds for this vulnerability.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-03-13T18:15Z
Repository: https://github.com/Yooooomi/your_spotify
Language: TypeScript
Files changed: README.md, apps/client/public/index.html, apps/client/scripts/run/serve.json, apps/client/scripts/run/serve.sh, apps/client/scripts/run/variables.sh, apps/server/scripts/run/deprecated.sh, apps/server/src/app.ts, apps/server/src/database/queries/privateData.ts, apps/server/src/routes/oauth.ts, apps/server/src/routes/search.ts, apps/server/src/tools/crypto.ts, apps/server/src/tools/env.ts, apps/server/src/tools/oauth/Provider.ts, monorepo.code-workspace
Commit date: 2024-03-12 19:17:40 +0100
Commit message: Merge pull request from GHSA-hfgf-99p3-6fjj

* More secure OAuth, fixed frame ancestors

* Change generateRandomString to return base64-encoded entropy

While the old implementation also seems to yield correct results, it is
(at least in my opinion) not obvious that it is correct and sufficiently
secure for the following reasons:

1. It takes the output string length as a parameter instead of the
   required amount of entropy.
   For cryptographic applications, it is important to know how much
   entropy any generated secret contains. Therefore, the input to the
   function should be the required bytes of entropy instead of the
   output string length.

2. While it uses a rather simple algorithm to convert the entropy bytes
   to a string, using a well-known and dead simple encoding such as
   base64 (or hexadecimal, but base64 is more compact) is even easier to
   verify as "obviously correct". In this case URL-safe base64 is chosen
   because the values are also used in URLs.

All usages of the function were also changed to accomodate the parameter
change to requested bytes of entropy. For all of them 32 bytes (256
bits) of entropy were chosen, which nicely fits the purpose of being
used for SHA256 and is definitely enough entropy for things like OAuth
'state'.

* Apply more restrictive CSP and prevent MIME sniffing for backend

As the backend doesn't show HTML pages to users, a very restrictive CSP
can be used. I'm currently not aware of any active issues, but being as
restrictive as possible doesn't hurt, which is what default-src 'none'
does.

Also, set "X-Content-Type-Options: nosniff" to prevent MIME sniffing in
browsers, which can also be an issue in certain circumstances.

* Implement a basic Content-Security-Policy in the client

This commit implements a basic CSP in the client by setting it as a
<meta> tag in index.html. Normally it would be preferred to set the CSP
in a HTTP header, but the react dev server does not seem to support
that. However, it is very important to have the CSP active during
development to catch errors caused by a restrictive CSP before deploying
to production.

Note that a header-based CSP is still required to set
"frame-ancestors 'none'" to prevent clickjacking. It is perfectly fine
to set the CSP both in the <meta> tag and in the HTTP header, both CSPs
will be applied.

There is still room for improvement in the CSP. With some effort, the
script-src could be further restricted using a combination of hashes or
nonces and 'strict-dynamic' to get rid of 'self'. The connect-src could
also be restricted to only the backend origin.

That being said, this is still a very good starting point and much
better than not having a CSP at all.

* Prevent MIME sniffing for the client in production

Prevent MIME sniffing for the client in production by setting the header
"X-Content-Type-Options: nosniff".

* Use only the origin of CLIENT_ENDPOINT for CORS policy

When the full CLIENT_ENDPOINT is used to set the CORS policy, the policy
does not get applied correctly when the client is served under a
subdirectory. Always using the origin fixes this.

* removed pkce, connect-src is dynamic

* ensuring API_ENDPOINT ends with a /

* make sure api endpoint ends with a slash, updated CORS notice

* fixed comment on index.html

---------

Co-authored-by: Marius Renner <marius@mariusrenner.de>
Diff: diff --git a/README.md b/README.md
index cc62182..e0cbb61 100644
--- a/README.md
+++ b/README.md
@@ -89,25 +89,24 @@ You can follow the instructions [here](https://github.com/Yooooomi/your_spotify/
 | :--- | :--- | :--- |
 | CLIENT_ENDPOINT       | REQUIRED | The endpoint of your web application |
 | API_ENDPOINT          | REQUIRED | The endpoint of your server |
 | SPOTIFY_PUBLIC        | REQUIRED | The public key of your Spotify application (cf [Creating the Spotify Application](#creating-the-spotify-application)) |
 | SPOTIFY_SECRET        | REQUIRED | The secret key of your Spotify application (cf [Creating the Spotify Application](#creating-the-spotify-application)) |
-| CORS                  | _not defined_ | List of comma-separated origin allowed, or _nothing_ to allow any origin |
+| CORS                  | _not defined_ | List of comma-separated origin allowed |
 | MAX_IMPORT_CACHE_SIZE | Infinite | The maximum element in the cache when importing data from an outside source, more cache means less requests to Spotify, resulting in faster imports |
 | MONGO_ENDPOINT        | mongodb://mongo:27017/your_spotify | The endpoint of the Mongo database, where **mongo** is the name of your service in the compose file |
 | PORT                  | 8080 | The port of the server, do not modify if you're using docker |
 | TIMEZONE              | Europe/Paris | The timezone of your stats, only affects read requests since data is saved with UTC time |
 | LOG_LEVEL             | info | The log level, debug is useful if you encouter any bugs |
 | COOKIE_VALIDITY_MS | 1h | Validity time of the authentication cookie, following [this pattern](https://github.com/vercel/ms) |
 | MONGO_NO_ADMIN_RIGHTS | false | Do not ask for admin right on the Mongo database |
 
 ## CORS
 
-You can edit the CORS for the server:
-
-- `all` will allow every source.
+- Not defining it will default to authorize only the `CLIENT_ENDPOINT` origin.
 - `origin1,origin2` will allow `origin1` and `origin2`.
+> If you really want to allow every origin no matter what, you can set the `CORS` value to `i-want-a-security-vulnerability-and-want-to-allow-all-origins`.
 
 # Creating the Spotify Application
 
 For **YourSpotify** to work you need to provide a Spotify application **public** AND **secret** to the server environment.
 To do so, you need to create a **Spotify application** [here](https://developer.spotify.com/dashboard/applications).
diff --git a/apps/client/public/index.html b/apps/client/public/index.html
index d83a2bf..725e7f9 100644
--- a/apps/client/public/index.html
+++ b/apps/client/public/index.html
@@ -1,33 +1,47 @@
 <!DOCTYPE html>
 <html lang="en">
-  <head>
-    <meta charset="utf-8" />
-    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
-    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
-    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
-    <script src="%PUBLIC_URL%/variables.js"></script>
-
-    <title>Your Spotify</title>
-    <meta name="viewport" content="width=device-width, initial-scale=1" />
-    <meta name="theme-color" content="#000000" />
-
-    <meta name="title" content="Your Spotify">
-    <meta name="description" content="Keep track of your Spotify listening habits with Your Spotify.">
-
-    <meta property="og:type" content="image/png">
-    <meta property="og:title" content="Your Spotify">
-    <meta property="og:image:width" content="1200">
-    <meta property="og:image:height" content="628">
-    <meta property="og:description" content="Keep track of your Spotify listening habits with Your Spotify.">
-    <meta property="og:image" content="http://localhost:8080/static/your_spotify_1200.png">
-
-    <meta property="twitter:card" content="summary">
-    <meta property="twitter:title" content="Your Spotify">
-    <meta property="twitter:description" content="Keep track of your Spotify listening habits with Your Spotify.">
-    <meta property="twitter:image" content="http://localhost:8080/static/your_spotify_1200.png">
-  </head>
-  <body>
-    <noscript>You need to enable JavaScript to run this app.</noscript>
-    <div id="root"></div>
-  </body>
-</html>
+
+<head>
+  <meta charset="utf-8" />
+
+  <!--
+    While setting the CSP in HTTP headers is preferred, there does not seem to
+    be a good way to do this in the react dev environment, so it is set here as
+    a meta tag. Note that frame-ancestors CANNOT be set in the meta tag and
+    MUST be set as a HTTP header in production!
+
+    Restricting connect-src is done at start of the client server.
+    -->
+  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https://i.scdn.co; connect-src *;" />
+
+  <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+  <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
+  <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+  <script src="%PUBLIC_URL%/variables.js"></script>
+
+  <title>Your Spotify</title>
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <meta name="theme-color" content="#000000" />
+
+  <meta name="title" content="Your Spotify">
+  <meta name="description" content="Keep track of your Spotify listening habits with Your Spotify.">
+
+  <meta property="og:type" content="image/png">
+  <meta property="og:title" content="Your Spotify">
+  <meta property="og:image:width" content="1200">
+  <meta property="og:image:height" content="628">
+  <meta property="og:description" content="Keep track of your Spotify listening habits with Your Spotify.">
+  <meta property="og:image" content="http://localhost:8080/static/your_spotify_1200.png">
+
+  <meta property="twitter:card" content="summary">
+  <meta property="twitter:title" content="Your Spotify">
+  <meta property="twitter:description" content="Keep track of your Spotify listening habits with Your Spotify.">
+  <meta property="twitter:image" content="http://localhost:8080/static/your_spotify_1200.png">
+</head>
+
+<body>
+  <noscript>You need to enable JavaScript to run this app.</noscript>
+  <div id="root"></div>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/apps/client/scripts/run/serve.json b/apps/client/scripts/run/serve.json
new file mode 100644
index 0000000..7afcca6
--- /dev/null
+++ b/apps/client/scripts/run/serve.json
@@ -0,0 +1,17 @@
+{
+  "headers": [
+    {
+      "source": "*",
+      "headers": [
+        {
+          "key": "Content-Security-Policy",
+          "value": "frame-ancestors 'none';"
+        },
+        {
+          "key": "X-Content-Type-Options",
+          "value": "nosniff"
+        }
+      ]
+    }
+  ]
+}
diff --git a/apps/client/scripts/run/serve.sh b/apps/client/scripts/run/serve.sh
index e33e5b3..ca49cb9 100755
--- a/apps/client/scripts/run/serve.sh
+++ b/apps/client/scripts/run/serve.sh
@@ -2,6 +2,6 @@
 
 # -s means that all 404's will be redirected to index.html, so that react can handle router
 # -l 0.0.0.0 means that it's hosted on all the interfaces
 # build/ is the output of the package built at build-time
 
-serve -s -l tcp://0.0.0.0:3000 /app/apps/client/build/
\ No newline at end of file
+serve -c /app/apps/client/scripts/run/serve.json -s -l tcp://0.0.0.0:3000 /app/apps/client/build/
\ No newline at end of file
diff --git a/apps/client/scripts/run/variables.sh b/apps/client/scripts/run/variables.sh
index d3c2360..9da4b06 100644
--- a/apps/client/scripts/run/variables.sh
+++ b/apps/client/scripts/run/variables.sh
@@ -16,9 +16,17 @@ then
     echo "Setting API Endpoint to '$API_ENDPOINT'"
     sed -i "s;__API_ENDPOINT__;$API_ENDPOINT;g" "$VAR_PATH/variables.js"
 
     # Editing meta image urls
     sed -i "s;image\" content=\"\(.[^\"]*\);image\" content=\"$API_ENDPOINT/static/your_spotify_1200.png;g" "$VAR_PATH/index.html"
+
+    # Restricting connect-src to API_ENDPOINT with a trailing /
+    API_ENDPOINT_ENDING_WITH_SLASH=$API_ENDPOINT
+    if [[ "$API_ENDPOINT_ENDING_WITH_SLASH" != */ ]]
+    then
+        API_ENDPOINT_ENDING_WITH_SLASH="$API_ENDPOINT_ENDING_WITH_SLASH/"
+    fi
+    sed -i "s#connect-src \(.*\);#connect-src $API_ENDPOINT_ENDING_WITH_SLASH;#g" "$VAR_PATH/index.html"
 else
     echo "API_ENDPOINT is not defined, web app won't work"
     exit 1
 fi
diff --git a/apps/server/scripts/run/deprecated.sh b/apps/server/scripts/run/deprecated.sh
index 26ba36c..3538320 100644
--- a/apps/server/scripts/run/deprecated.sh
+++ b/apps/server/scripts/run/deprecated.sh
@@ -1,6 +1,7 @@
 #!/bin/sh
 
 if [ "$CORS" == "all" ]
 then
-  echo "Setting CORS to 'all' is not needed anymore, omitting it is the new way of authorizing every origin."
+  echo "Setting CORS to 'all' is not authorized anymore. To allow all sources, please specify CORS=i-want-a-security-vulnerability-and-want-to-allow-all-origins"
+  exit 1
 fi
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 3d572fc..0462c3a 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -15,13 +15,17 @@ import { router as trackRouter } from "./routes/track";
 import { router as searchRouter } from "./routes/search";
 import { get } from "./tools/env";
 import { LogLevelAccepts } from "./tools/logger";
 
 const app = express();
+const ALLOW_ALL_CORS =
+  "i-want-a-security-vulnerability-and-want-to-allow-all-origins";
 
-let corsValue = get("CORS")?.split(",");
-if (corsValue?.[0] === "all") {
+let corsValue: string[] | undefined = get("CORS")?.split(",") ?? [
+  new URL(get("CLIENT_ENDPOINT")).origin,
+];
+if (corsValue?.[0] === ALLOW_ALL_CORS) {
   corsValue = undefined;
 }
 
 app.use(
   cors({
@@ -29,10 +33,28 @@ app.use(
     methods: ["GET", "PUT", "POST", "DELETE"],
     credentials: true,
   }),
 );
 
+app.use((_, res, next) => {
+  // Apply security headers for the whole backend here
+
+  // Apply a restrictive CSP for the server API just in case. As there isn't any
+  // HTML content here, "default-src 'none'" is a good deny-all default in case
+  // an attacker tries something funny.
+  // "frame-ancestors 'none'" is required because frame-ancestors doesn't fall
+  // back to default-src and nobody has legitimate business framing the backend.
+  res.header(
+    "Content-Security-Policy",
+    "default-src 'none'; object-src 'none'; frame-ancestors 'none';"
+  );
+
+  // Prevent MIME sniffing in browsers
+  res.header("X-Content-Type-Options", "nosniff");
+  next();
+});
+
 if (LogLevelAccepts("info")) {
   app.use(morgan("dev"));
 }
 app.use(cookieParser());
 app.use("/static", express.static(path.join(__dirname, "public")));
diff --git a/apps/server/src/database/queries/privateData.ts b/apps/server/src/database/queries/privateData.ts
index a7f9abc..76f60e7 100644
--- a/apps/server/src/database/queries/privateData.ts
+++ b/apps/server/src/database/queries/privateData.ts
@@ -1,10 +1,10 @@
-import { randomUUID } from "crypto";
+import { generateRandomString } from "../../tools/crypto";
 import { PrivateDataModel } from "../Models";
 
 export async function createPrivateData() {
-  await PrivateDataModel.create({ jwtPrivateKey: randomUUID() });
+  await PrivateDataModel.create({ jwtPrivateKey: generateRandomString(32) });
 }
 
 export async function getPrivateData() {
   return PrivateDataModel.findOne({});
 }
diff --git a/apps/server/src/routes/oauth.ts b/apps/server/src/routes/oauth.ts
index a39eead..13f0d30 100644
--- a/apps/server/src/routes/oauth.ts
+++ b/apps/server/src/routes/oauth.ts
@@ -1,82 +1,139 @@
-import { Router } from "express";
+import { Request, Response, Router } from "express";
 import { sign } from "jsonwebtoken";
+import { z } from "zod";
 import {
   createUser,
   getUserCount,
   getUserFromField,
   storeInUser,
 } from "../database";
 import { get, getWithDefault } from "../tools/env";
 import { logger } from "../tools/logger";
 import {
   logged,
+  validating,
   withGlobalPreferences,
   withHttpClient,
 } from "../tools/middleware";
 import { Spotify } from "../tools/oauth/Provider";
-import { GlobalPreferencesRequest, SpotifyRequest } from "../tools/types";
+import {
+  GlobalPreferencesRequest,
+  SpotifyRequest,
+  TypedPayload,
+} from "../tools/types";
 import { getPrivateData } from "../database/queries/privateData";
 
 export const router = Router();
 
-router.get("/spotify", async (_, res) => {
+function storeTokenInCookie(
+  request: Request,
+  response: Response,
+  token: string,
+) {
+  response.cookie("token", token, {
+    sameSite: "strict",
+    httpOnly: true,
+    secure: request.secure,
+  });
+}
+
+const OAUTH_COOKIE_NAME = "oauth";
+const spotifyCallbackOAuthCookie = z.object({
+  state: z.string(),
+});
+type OAuthCookie = z.infer<typeof spotifyCallbackOAuthCookie>;
+
+router.get("/spotify", async (req, res) => {
   const isOffline = get("OFFLINE_DEV_ID");
   if (isOffline) {
     const privateData = await getPrivateData();
     if (!privateData?.jwtPrivateKey) {
       throw new Error("No private data found, cannot sign JWT");
     }
     const token = sign({ userId: isOffline }, privateData.jwtPrivateKey, {
       expiresIn: getWithDefault("COOKIE_VALIDITY_MS", "1h"),
     });
-    res.cookie("token", token);
+    storeTokenInCookie(req, res, token);
     res.status(204).end();
     return;
   }
-  res.redirect(Spotify.getRedirect());
+  const { url, state } = await Spotify.getRedirect();
+  const oauthCookie: OAuthCookie = {
+    state,
+  };
+
+  res.cookie(OAUTH_COOKIE_NAME, oauthCookie, {
+    sameSite: "lax",
+    httpOnly: true,
+    secure: req.secure,
+  });
+
+  res.redirect(url);
 });
 
-router.get("/spotify/callback", withGlobalPreferences, async (req, res) => {
-  const { query, globalPreferences } = req as GlobalPreferencesRequest;
-  const { code } = query;
+const spotifyCallback = z.object({
+  code: z.string(),
+  state: z.string(),
+});
 
-  const infos = await Spotify.exchangeCode(code as string);
+router.get(
+  "/spotify/callback",
+  validating(spotifyCallback, "query"),
+  withGlobalPreferences,
+  async (req, res) => {
+    const { query, globalPreferences } = req as GlobalPreferencesRequest;
+    const { code, state } = query as TypedPayload<typeof spotifyCallback>;
 
-  try {
-    const client = Spotify.getHttpClient(infos.accessToken);
-    const { data: spotifyMe } = await client.get("/me");
-    let user = await getUserFromField("spotifyId", spotifyMe.id, false);
-    if (!user) {
-      if (!globalPreferences.allowRegistrations) {
-        return res.redirect(`${get("CLIENT_ENDPOINT")}/registrations-disabled`);
+    try {
+      const cookie = spotifyCallbackOAuthCookie.parse(
+        req.cookies[OAUTH_COOKIE_NAME],
+      );
+
+      if (state !== cookie.state) {
+        throw new Error("State does not match");
       }
-      const nbUsers = await getUserCount();
-      user = await createUser(
-        spotifyMe.display_name,
-        spotifyMe.id,
-        nbUsers === 0,
+
+      const infos = await Spotify.exchangeCode(code, cookie.state);
+
+      const client = Spotify.getHttpClient(infos.accessToken);
+      const { data: spotifyMe } = await client.get("/me");
+      let user = await getUserFromField("spotifyId", spotifyMe.id, false);
+      if (!user) {
+        if (!globalPreferences.allowRegistrations) {
+          return res.redirect(
+            `${get("CLIENT_ENDPOINT")}/registrations-disabled`,
+          );
+        }
+        const nbUsers = await getUserCount();
+        user = await createUser(
+          spotifyMe.display_name,
+          spotifyMe.id,
+          nbUsers === 0,
+        );
+      }
+      await storeInUser("_id", user._id, infos);
+      const privateData = await getPrivateData();
+      if (!privateData?.jwtPrivateKey) {
+        throw new Error("No private data found, cannot sign JWT");
+      }
+      const token = sign(
+        { userId: user._id.toString() },
+        privateData.jwtPrivateKey,
+        {
+          expiresIn: getWithDefault("COOKIE_VALIDITY_MS", "1h"),
+        },
       );
+      storeTokenInCookie(req, res, token);
+    } catch (e) {
+      logger.error(e);
+    } finally {
+      res.clearCookie(OAUTH_COOKIE_NAME);
     }
-    await storeInUser("_id", user._id, infos);
-    const privateData = await getPrivateData();
-    if (!privateData?.jwtPrivateKey) {
-      throw new Error("No private data found, cannot sign JWT");
-    }
-    const token = sign(
-      { userId: user._id.toString() },
-      privateData.jwtPrivateKey,
-      {
-        expiresIn: getWithDefault("COOKIE_VALIDITY_MS", "1h"),
-      },
-    );
-    res.cookie("token", token);
-  } catch (e) {
-    logger.error(e);
-  }
-  return res.redirect(get("CLIENT_ENDPOINT"));
-});
+    return res.redirect(get("CLIENT_ENDPOINT"));
+  },
+);
 
 router.get("/spotify/me", logged, withHttpClient, async (req, res) => {
   const { client } = req as SpotifyRequest;
 
   try {
diff --git a/apps/server/src/routes/search.ts b/apps/server/src/routes/search.ts
index 5fbe609..6f8d8d4 100644
--- a/apps/server/src/routes/search.ts
+++ b/apps/server/src/routes/search.ts
@@ -1,12 +1,12 @@
-import { Router } from 'express';
-import { z } from 'zod';
-import { searchArtist, searchTrack } from '../database';
-import { logger } from '../tools/logger';
-import { isLoggedOrGuest, validating } from '../tools/middleware';
-import { TypedPayload } from '../tools/types';
-import { searchAlbum } from '../database/queries/album';
+import { Router } from "express";
+import { z } from "zod";
+import { searchArtist, searchTrack } from "../database";
+import { logger } from "../tools/logger";
+import { isLoggedOrGuest, validating } from "../tools/middleware";
+import { TypedPayload } from "../tools/types";
+import { searchAlbum } from "../database/queries/album";
 
 export const router = Router();
 
 const search = z.object({
   query: z.string().min(3).max(64),
@@ -21,11 +21,11 @@ router.get(
 
     try {
       const [artists, tracks, albums] = await Promise.all([
         searchArtist(query),
         searchTrack(query),
-        searchAlbum(query)
+        searchAlbum(query),
       ]);
       return res.status(200).send({ artists, tracks, albums });
     } catch (e) {
       logger.error(e);
       return res.status(500).end();
diff --git a/apps/server/src/tools/crypto.ts b/apps/server/src/tools/crypto.ts
new file mode 100644
index 0000000..53a08be
--- /dev/null
+++ b/apps/server/src/tools/crypto.ts
@@ -0,0 +1,12 @@
+import { getRandomValues, subtle } from "crypto";
+
+export function generateRandomString(entropyBytes: number) {
+  const entropy = getRandomValues(new Uint8Array(entropyBytes));
+  return Buffer.from(entropy).toString("base64url");
+}
+
+export async function sha256(plain: string) {
+  const encoder = new TextEncoder();
+  const data = encoder.encode(plain);
+  return subtle.digest("SHA-256", data);
+}
diff --git a/apps/server/src/tools/env.ts b/apps/server/src/tools/env.ts
index 89f563c..dc417b3 100644
--- a/apps/server/src/tools/env.ts
+++ b/apps/server/src/tools/env.ts
@@ -1,7 +1,6 @@
 import { z } from "zod";
-import { logger } from "./logger";
 import { toBoolean, toNumber } from "./zod";
 
 const validators = {
   CLIENT_ENDPOINT: z.string(),
   MAX_IMPORT_CACHE_SIZE: z.preprocess(toNumber, z.number().optional()),
diff --git a/apps/server/src/tools/oauth/Provider.ts b/apps/server/src/tools/oauth/Provider.ts
index 52831a4..2c7d901 100644
--- a/apps/server/src/tools/oauth/Provider.ts
+++ b/apps/server/src/tools/oauth/Provider.ts
@@ -1,14 +1,15 @@
 /* eslint-disable @typescript-eslint/no-unused-vars */
 import Axios from "axios";
+import { generateRandomString, sha256 } from "../crypto";
 import { credentials } from "./credentials";
 
 export class Provider {
   static getRedirect = () => {};
 
   // @ts-ignore
-  static exchangeCode = code => {};
+  static exchangeCode = (code: string, state: string) => {};
 
   // @ts-ignore
   static refresh = refreshToken => {};
 
   // @ts-ignore
@@ -17,32 +18,41 @@ export class Provider {
   // @ts-ignore
   static getHttpClient = accessToken => {};
 }
 
 export class Spotify extends Provider {
-  static getRedirect = () => {
+  static getRedirect = async () => {
     const { scopes } = credentials.spotify;
     const { redirectUri } = credentials.spotify;
 
-    return `https://accounts.spotify.com/authorize?response_type=code&client_id=${
-      credentials.spotify.public
-    }${
-      scopes ? `&scope=${encodeURIComponent(scopes)}` : ""
-    }&redirect_uri=${encodeURIComponent(redirectUri)}`;
+    const authorizeUrl = new URL("https://accounts.spotify.com/authorize");
+    const state = generateRandomString(32);
+
+    authorizeUrl.searchParams.append("client_id", credentials.spotify.public);
+    authorizeUrl.searchParams.append("response_type", "code");
+    authorizeUrl.searchParams.append("redirect_uri", redirectUri);
+    authorizeUrl.searchParams.append("state", state);
+    authorizeUrl.searchParams.append("scope", scopes);
+
+    return {
+      url: authorizeUrl.toString(),
+      state,
+    };
   };
 
-  static exchangeCode = async (code: string) => {
+  static exchangeCode = async (code: string, state: string) => {
     const { data } = await Axios.post(
       "https://accounts.spotify.com/api/token",
       null,
       {
         params: {
           grant_type: "authorization_code",
           code,
           redirect_uri: credentials.spotify.redirectUri,
           client_id: credentials.spotify.public,
           client_secret: credentials.spotify.secret,
+          state,
         },
         headers: {
           "Content-Type": "application/x-www-form-urlencoded",
         },
       },
diff --git a/monorepo.code-workspace b/monorepo.code-workspace
index f032b57..2354d85 100644
--- a/monorepo.code-workspace
+++ b/monorepo.code-workspace
@@ -15,10 +15,16 @@
     "eslint.workingDirectories": [
       {"mode": "auto"}
     ],
     "[typescript]": {
       "editor.defaultFormatter": "dbaeumer.vscode-eslint"
+    },
+    "[json]": {
+      "editor.defaultFormatter": "vscode.json-language-features"
+    },
+    "[html]": {
+      "editor.defaultFormatter": "vscode.html-language-features"
     }
   },
   "extensions": {
     "recommendations": [
       "dbaeumer.vscode-eslint"

Vulnerable code: | CORS                  | _not defined_ | List of comma-separated origin allowed, or _nothing_ to allow any origin |
You can edit the CORS for the server:

- `all` will allow every source.
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <script src="%PUBLIC_URL%/variables.js"></script>

    <title>Your Spotify</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />

    <meta name="title" content="Your Spotify">
    <meta name="description" content="Keep track of your Spotify listening habits with Your Spotify.">

    <meta property="og:type" content="image/png">
    <meta property="og:title" content="Your Spotify">
    <meta property="og:image:width" content="1200">
    <meta property="og:image:height" content="628">
    <meta property="og:description" content="Keep track of your Spotify listening habits with Your Spotify.">
    <meta property="og:image" content="http://localhost:8080/static/your_spotify_1200.png">

    <meta property="twitter:card" content="summary">
    <meta property="twitter:title" content="Your Spotify">
    <meta property="twitter:description" content="Keep track of your Spotify listening habits with Your Spotify.">
    <meta property="twitter:image" content="http://localhost:8080/static/your_spotify_1200.png">
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>
serve -s -l tcp://0.0.0.0:3000 /app/apps/client/build/
  echo "Setting CORS to 'all' is not needed anymore, omitting it is the new way of authorizing every origin."
let corsValue = get("CORS")?.split(",");
if (corsValue?.[0] === "all") {
import { randomUUID } from "crypto";
  await PrivateDataModel.create({ jwtPrivateKey: randomUUID() });
import { Router } from "express";
import { GlobalPreferencesRequest, SpotifyRequest } from "../tools/types";
router.get("/spotify", async (_, res) => {
    res.cookie("token", token);
  res.redirect(Spotify.getRedirect());
router.get("/spotify/callback", withGlobalPreferences, async (req, res) => {
  const { query, globalPreferences } = req as GlobalPreferencesRequest;
  const { code } = query;
  const infos = await Spotify.exchangeCode(code as string);
  try {
    const client = Spotify.getHttpClient(infos.accessToken);
    const { data: spotifyMe } = await client.get("/me");
    let user = await getUserFromField("spotifyId", spotifyMe.id, false);
    if (!user) {
      if (!globalPreferences.allowRegistrations) {
        return res.redirect(`${get("CLIENT_ENDPOINT")}/registrations-disabled`);
      const nbUsers = await getUserCount();
      user = await createUser(
        spotifyMe.display_name,
        spotifyMe.id,
        nbUsers === 0,
    await storeInUser("_id", user._id, infos);
    const privateData = await getPrivateData();
    if (!privateData?.jwtPrivateKey) {
      throw new Error("No private data found, cannot sign JWT");
    }
    const token = sign(
      { userId: user._id.toString() },
      privateData.jwtPrivateKey,
      {
        expiresIn: getWithDefault("COOKIE_VALIDITY_MS", "1h"),
      },
    );
    res.cookie("token", token);
  } catch (e) {
    logger.error(e);
  }
  return res.redirect(get("CLIENT_ENDPOINT"));
});
import { Router } from 'express';
import { z } from 'zod';
import { searchArtist, searchTrack } from '../database';
import { logger } from '../tools/logger';
import { isLoggedOrGuest, validating } from '../tools/middleware';
import { TypedPayload } from '../tools/types';
import { searchAlbum } from '../database/queries/album';
        searchAlbum(query)
import { logger } from "./logger";
  static exchangeCode = code => {};
  static getRedirect = () => {
    return `https://accounts.spotify.com/authorize?response_type=code&client_id=${
      credentials.spotify.public
    }${
      scopes ? `&scope=${encodeURIComponent(scopes)}` : ""
    }&redirect_uri=${encodeURIComponent(redirectUri)}`;
  static exchangeCode = async (code: string) => {
Fixed code: | CORS                  | _not defined_ | List of comma-separated origin allowed |
- Not defining it will default to authorize only the `CLIENT_ENDPOINT` origin.
> If you really want to allow every origin no matter what, you can set the `CORS` value to `i-want-a-security-vulnerability-and-want-to-allow-all-origins`.

<head>
  <meta charset="utf-8" />

  <!--
    While setting the CSP in HTTP headers is preferred, there does not seem to
    be a good way to do this in the react dev environment, so it is set here as
    a meta tag. Note that frame-ancestors CANNOT be set in the meta tag and
    MUST be set as a HTTP header in production!

    Restricting connect-src is done at start of the client server.
    -->
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https://i.scdn.co; connect-src *;" />

  <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
  <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
  <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
  <script src="%PUBLIC_URL%/variables.js"></script>

  <title>Your Spotify</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="theme-color" content="#000000" />

  <meta name="title" content="Your Spotify">
  <meta name="description" content="Keep track of your Spotify listening habits with Your Spotify.">

  <meta property="og:type" content="image/png">
  <meta property="og:title" content="Your Spotify">
  <meta property="og:image:width" content="1200">
  <meta property="og:image:height" content="628">
  <meta property="og:description" content="Keep track of your Spotify listening habits with Your Spotify.">
  <meta property="og:image" content="http://localhost:8080/static/your_spotify_1200.png">

  <meta property="twitter:card" content="summary">
  <meta property="twitter:title" content="Your Spotify">
  <meta property="twitter:description" content="Keep track of your Spotify listening habits with Your Spotify.">
  <meta property="twitter:image" content="http://localhost:8080/static/your_spotify_1200.png">
</head>

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root"></div>
</body>

</html>
{
  "headers": [
    {
      "source": "*",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "frame-ancestors 'none';"
        },
        {
          "key": "X-Content-Type-Options",
          "value": "nosniff"
        }
      ]
    }
  ]
}
serve -c /app/apps/client/scripts/run/serve.json -s -l tcp://0.0.0.0:3000 /app/apps/client/build/

    # Restricting connect-src to API_ENDPOINT with a trailing /
    API_ENDPOINT_ENDING_WITH_SLASH=$API_ENDPOINT
    if [[ "$API_ENDPOINT_ENDING_WITH_SLASH" != */ ]]
    then
        API_ENDPOINT_ENDING_WITH_SLASH="$API_ENDPOINT_ENDING_WITH_SLASH/"
    fi
    sed -i "s#connect-src \(.*\);#connect-src $API_ENDPOINT_ENDING_WITH_SLASH;#g" "$VAR_PATH/index.html"
  echo "Setting CORS to 'all' is not authorized anymore. To allow all sources, please specify CORS=i-want-a-security-vulnerability-and-want-to-allow-all-origins"
  exit 1
const ALLOW_ALL_CORS =
  "i-want-a-security-vulnerability-and-want-to-allow-all-origins";
let corsValue: string[] | undefined = get("CORS")?.split(",") ?? [
  new URL(get("CLIENT_ENDPOINT")).origin,
];
if (corsValue?.[0] === ALLOW_ALL_CORS) {
app.use((_, res, next) => {
  // Apply security headers for the whole backend here

  // Apply a restrictive CSP for the server API just in case. As there isn't any
  // HTML content here, "default-src 'none'" is a good deny-all default in case
  // an attacker tries something funny.
  // "frame-ancestors 'none'" is required because frame-ancestors doesn't fall
  // back to default-src and nobody has legitimate business framing the backend.
  res.header(
    "Content-Security-Policy",
    "default-src 'none'; object-src 'none'; frame-ancestors 'none';"
  );

  // Prevent MIME sniffing in browsers
  res.header("X-Content-Type-Options", "nosniff");
  next();
});

import { generateRandomString } from "../../tools/crypto";
  await PrivateDataModel.create({ jwtPrivateKey: generateRandomString(32) });
import { Request, Response, Router } from "express";
import { z } from "zod";
  validating,
import {
  GlobalPreferencesRequest,
  SpotifyRequest,
  TypedPayload,
} from "../tools/types";
function storeTokenInCookie(
  request: Request,
  response: Response,
  token: string,
) {
  response.cookie("token", token, {
    sameSite: "strict",
    httpOnly: true,
    secure: request.secure,
  });
}

const OAUTH_COOKIE_NAME = "oauth";
const spotifyCallbackOAuthCookie = z.object({
  state: z.string(),
});
type OAuthCookie = z.infer<typeof spotifyCallbackOAuthCookie>;

router.get("/spotify", async (req, res) => {
    storeTokenInCookie(req, res, token);
  const { url, state } = await Spotify.getRedirect();
  const oauthCookie: OAuthCookie = {
    state,
  };

  res.cookie(OAUTH_COOKIE_NAME, oauthCookie, {
    sameSite: "lax",
    httpOnly: true,
    secure: req.secure,
  });

  res.redirect(url);
const spotifyCallback = z.object({
  code: z.string(),
  state: z.string(),
});
router.get(
  "/spotify/callback",
  validating(spotifyCallback, "query"),
  withGlobalPreferences,
  async (req, res) => {
    const { query, globalPreferences } = req as GlobalPreferencesRequest;
    const { code, state } = query as TypedPayload<typeof spotifyCallback>;
    try {
      const cookie = spotifyCallbackOAuthCookie.parse(
        req.cookies[OAUTH_COOKIE_NAME],
      );

      if (state !== cookie.state) {
        throw new Error("State does not match");

      const infos = await Spotify.exchangeCode(code, cookie.state);

      const client = Spotify.getHttpClient(infos.accessToken);
      const { data: spotifyMe } = await client.get("/me");
      let user = await getUserFromField("spotifyId", spotifyMe.id, false);
      if (!user) {
        if (!globalPreferences.allowRegistrations) {
          return res.redirect(
            `${get("CLIENT_ENDPOINT")}/registrations-disabled`,
          );
        }
        const nbUsers = await getUserCount();
        user = await createUser(
          spotifyMe.display_name,
          spotifyMe.id,
          nbUsers === 0,
        );
      }
      await storeInUser("_id", user._id, infos);
      const privateData = await getPrivateData();
      if (!privateData?.jwtPrivateKey) {
        throw new Error("No private data found, cannot sign JWT");
      }
      const token = sign(
        { userId: user._id.toString() },
        privateData.jwtPrivateKey,
        {
          expiresIn: getWithDefault("COOKIE_VALIDITY_MS", "1h"),
        },
      storeTokenInCookie(req, res, token);
    } catch (e) {
      logger.error(e);
    } finally {
      res.clearCookie(OAUTH_COOKIE_NAME);
    return res.redirect(get("CLIENT_ENDPOINT"));
  },
);
import { Router } from "express";
import { z } from "zod";
import { searchArtist, searchTrack } from "../database";
import { logger } from "../tools/logger";
import { isLoggedOrGuest, validating } from "../tools/middleware";
import { TypedPayload } from "../tools/types";
import { searchAlbum } from "../database/queries/album";
        searchAlbum(query),
import { getRandomValues, subtle } from "crypto";

export function generateRandomString(entropyBytes: number) {
  const entropy = getRandomValues(new Uint8Array(entropyBytes));
  return Buffer.from(entropy).toString("base64url");
}

export async function sha256(plain: string) {
  const encoder = new TextEncoder();
  const data = encoder.encode(plain);
  return subtle.digest("SHA-256", data);
}
import { generateRandomString, sha256 } from "../crypto";
  static exchangeCode = (code: string, state: string) => {};
  static getRedirect = async () => {
    const authorizeUrl = new URL("https://accounts.spotify.com/authorize");
    const state = generateRandomString(32);

    authorizeUrl.searchParams.append("client_id", credentials.spotify.public);
    authorizeUrl.searchParams.append("response_type", "code");
    authorizeUrl.searchParams.append("redirect_uri", redirectUri);
    authorizeUrl.searchParams.append("state", state);
    authorizeUrl.searchParams.append("scope", scopes);

    return {
      url: authorizeUrl.toString(),
      state,
    };
  static exchangeCode = async (code: string, state: string) => {
          state,
    },
    "[json]": {
      "editor.defaultFormatter": "vscode.json-language-features"
    },
    "[html]": {
      "editor.defaultFormatter": "vscode.html-language-features"